Skip to main content

harn_vm/connectors/
mod.rs

1//! Connector traits and shared helpers for inbound event-source providers.
2//!
3//! Runtime connector contracts live here alongside their event, secret, and trigger dependencies.
4
5use std::cell::RefCell;
6use std::collections::{BTreeMap, HashMap};
7use std::fmt;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex, OnceLock};
10use std::time::Duration as StdDuration;
11
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use serde_json::Value as JsonValue;
15use time::OffsetDateTime;
16use tokio::sync::Mutex as AsyncMutex;
17
18use crate::event_log::AnyEventLog;
19use crate::secrets::SecretProvider;
20use crate::triggers::test_util::clock::{self, ClockInstant};
21use crate::triggers::{
22    registered_provider_metadata, InboxIndex, ProviderId, ProviderMetadata,
23    ProviderRuntimeMetadata, TenantId, TriggerEvent,
24};
25
26pub mod a2a_push;
27pub mod cron;
28mod defaults;
29pub mod effect_policy;
30pub mod harn_module;
31pub mod hmac;
32pub mod shared;
33pub mod stream;
34mod stripe;
35#[cfg(test)]
36pub(crate) mod test_util;
37pub mod testkit;
38pub mod webhook;
39
40pub use a2a_push::A2aPushConnector;
41pub use cron::{CatchupMode, CronConnector};
42pub use effect_policy::{
43    connector_export_denied_builtin_reason, connector_export_denied_harness_method_reason,
44    connector_export_effect_class, default_connector_export_policy, ConnectorExportEffectClass,
45    HarnConnectorEffectPolicies,
46};
47pub use harn_module::{
48    load_contract as load_harn_connector_contract, HarnConnector, HarnConnectorContract,
49};
50pub use hmac::{
51    verify_hmac_authorization, HmacSignatureStyle, DEFAULT_CANONICAL_AUTHORIZATION_HEADER,
52    DEFAULT_CANONICAL_HMAC_SCHEME, DEFAULT_GITHUB_SIGNATURE_HEADER,
53    DEFAULT_LINEAR_SIGNATURE_HEADER, DEFAULT_NOTION_SIGNATURE_HEADER,
54    DEFAULT_SLACK_SIGNATURE_HEADER, DEFAULT_SLACK_TIMESTAMP_HEADER,
55    DEFAULT_STANDARD_WEBHOOKS_ID_HEADER, DEFAULT_STANDARD_WEBHOOKS_SIGNATURE_HEADER,
56    DEFAULT_STANDARD_WEBHOOKS_TIMESTAMP_HEADER, DEFAULT_STRIPE_SIGNATURE_HEADER,
57    SIGNATURE_VERIFY_AUDIT_TOPIC,
58};
59pub use shared::{
60    paginate_cursor, resolve_jwks, verify_hmac_signature, verify_jwt_claims, verify_jwt_json,
61    ConnectorBase, CursorPage, HmacSignatureAlgorithm, JwtKeySource, JwtVerificationOptions,
62};
63pub use stream::StreamConnector;
64pub use stripe::verify_stripe_signature;
65use webhook::WebhookProviderProfile;
66pub use webhook::{GenericWebhookConnector, WebhookSignatureVariant};
67
68use defaults::default_connector_for_provider;
69
70const OUTBOUND_CONNECTOR_HTTP_TIMEOUT: StdDuration = StdDuration::from_secs(30);
71
72pub(crate) fn outbound_http_client(user_agent: &'static str) -> reqwest::Client {
73    let builder = reqwest::Client::builder()
74        .user_agent(user_agent)
75        .timeout(OUTBOUND_CONNECTOR_HTTP_TIMEOUT)
76        .redirect(crate::egress::redirect_policy("connector_redirect", 10));
77    crate::egress::install_ssrf_guard(builder)
78        .build()
79        .expect("connector HTTP client configuration should be valid")
80}
81
82/// Shared owned handle to a connector instance registered with the runtime.
83pub type ConnectorHandle = Arc<AsyncMutex<Box<dyn Connector>>>;
84
85thread_local! {
86    static ACTIVE_CONNECTOR_CLIENTS: RefCell<BTreeMap<String, Arc<dyn ConnectorClient>>> =
87        RefCell::new(BTreeMap::new());
88}
89
90/// Provider implementation contract for inbound connectors.
91#[async_trait]
92pub trait Connector: Send + Sync {
93    /// Stable provider id such as `github`, `slack`, or `webhook`.
94    fn provider_id(&self) -> &ProviderId;
95
96    /// Trigger kinds this connector supports (`webhook`, `poll`, `stream`, ...).
97    fn kinds(&self) -> &[TriggerKind];
98
99    /// Called once per connector instance at orchestrator startup.
100    async fn init(&mut self, ctx: ConnectorCtx) -> Result<(), ConnectorError>;
101
102    /// Activate the bindings relevant to this connector instance.
103    async fn activate(
104        &self,
105        bindings: &[TriggerBinding],
106    ) -> Result<ActivationHandle, ConnectorError>;
107
108    /// Stop connector-owned background work and flush any connector-local state.
109    async fn shutdown(&self, _deadline: StdDuration) -> Result<(), ConnectorError> {
110        Ok(())
111    }
112
113    /// Verify + normalize a provider-native inbound request into `TriggerEvent`.
114    async fn normalize_inbound(&self, raw: RawInbound) -> Result<TriggerEvent, ConnectorError>;
115
116    /// Verify + normalize a provider-native inbound request into the richer
117    /// connector result contract used by ack-first webhook adapters.
118    async fn normalize_inbound_result(
119        &self,
120        raw: RawInbound,
121    ) -> Result<ConnectorNormalizeResult, ConnectorError> {
122        self.normalize_inbound(raw)
123            .await
124            .map(ConnectorNormalizeResult::event)
125    }
126
127    /// Payload schema surfaced to future trigger-type narrowing.
128    fn payload_schema(&self) -> ProviderPayloadSchema;
129
130    /// Outbound API wrapper exposed to handlers.
131    fn client(&self) -> Arc<dyn ConnectorClient>;
132}
133
134/// Provider-supplied HTTP response returned before or instead of trigger dispatch.
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub struct ConnectorHttpResponse {
137    pub status: u16,
138    pub headers: BTreeMap<String, String>,
139    pub body: JsonValue,
140}
141
142impl ConnectorHttpResponse {
143    pub fn new(status: u16, headers: BTreeMap<String, String>, body: JsonValue) -> Self {
144        Self {
145            status,
146            headers,
147            body,
148        }
149    }
150}
151
152/// Normalized inbound result accepted by the runtime connector adapter.
153#[derive(Clone, Debug, PartialEq)]
154pub enum ConnectorNormalizeResult {
155    Event(Box<TriggerEvent>),
156    Batch(Vec<TriggerEvent>),
157    ImmediateResponse {
158        response: ConnectorHttpResponse,
159        events: Vec<TriggerEvent>,
160    },
161    Reject(ConnectorHttpResponse),
162}
163
164impl ConnectorNormalizeResult {
165    pub fn event(event: TriggerEvent) -> Self {
166        Self::Event(Box::new(event))
167    }
168
169    pub fn into_events(self) -> Vec<TriggerEvent> {
170        match self {
171            Self::Event(event) => vec![*event],
172            Self::Batch(events) | Self::ImmediateResponse { events, .. } => events,
173            Self::Reject(_) => Vec::new(),
174        }
175    }
176}
177
178#[derive(Clone, Debug, PartialEq)]
179pub enum PostNormalizeOutcome {
180    Ready(Box<TriggerEvent>),
181    DuplicateDropped,
182}
183
184pub async fn postprocess_normalized_event(
185    inbox: &InboxIndex,
186    binding_id: &str,
187    dedupe_enabled: bool,
188    dedupe_ttl: StdDuration,
189    mut event: TriggerEvent,
190) -> Result<PostNormalizeOutcome, ConnectorError> {
191    if dedupe_enabled && !event.dedupe_claimed() {
192        if !inbox
193            .insert_if_new(binding_id, &event.dedupe_key, dedupe_ttl)
194            .await?
195        {
196            return Ok(PostNormalizeOutcome::DuplicateDropped);
197        }
198        event.mark_dedupe_claimed();
199    }
200
201    Ok(PostNormalizeOutcome::Ready(Box::new(event)))
202}
203
204/// Outbound provider client interface used by connector-backed stdlib modules.
205#[async_trait]
206pub trait ConnectorClient: Send + Sync {
207    async fn call(&self, method: &str, args: JsonValue) -> Result<JsonValue, ClientError>;
208}
209
210/// Minimal outbound client errors shared by connector implementations.
211#[derive(Clone, Debug, PartialEq, Eq)]
212pub enum ClientError {
213    MethodNotFound(String),
214    InvalidArgs(String),
215    RateLimited(String),
216    Transport(String),
217    EgressBlocked(crate::egress::EgressBlocked),
218    Other(String),
219}
220
221impl fmt::Display for ClientError {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        match self {
224            Self::MethodNotFound(message)
225            | Self::InvalidArgs(message)
226            | Self::RateLimited(message)
227            | Self::Transport(message)
228            | Self::Other(message) => message.fmt(f),
229            Self::EgressBlocked(blocked) => blocked.fmt(f),
230        }
231    }
232}
233
234impl std::error::Error for ClientError {}
235
236/// Shared connector-layer errors.
237#[derive(Debug)]
238pub enum ConnectorError {
239    DuplicateProvider(String),
240    DuplicateDelivery(String),
241    UnknownProvider(String),
242    MissingHeader(String),
243    InvalidHeader {
244        name: String,
245        detail: String,
246    },
247    InvalidSignature(String),
248    TimestampOutOfWindow {
249        timestamp: OffsetDateTime,
250        now: OffsetDateTime,
251        window: time::Duration,
252    },
253    Json(String),
254    Secret(String),
255    EventLog(String),
256    HarnRuntime(String),
257    Client(ClientError),
258    Unsupported(String),
259    Activation(String),
260}
261
262impl ConnectorError {
263    pub fn invalid_signature(message: impl Into<String>) -> Self {
264        Self::InvalidSignature(message.into())
265    }
266}
267
268impl fmt::Display for ConnectorError {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        match self {
271            Self::DuplicateProvider(provider) => {
272                write!(f, "connector provider `{provider}` is already registered")
273            }
274            Self::DuplicateDelivery(message) => message.fmt(f),
275            Self::UnknownProvider(provider) => {
276                write!(f, "connector provider `{provider}` is not registered")
277            }
278            Self::MissingHeader(header) => write!(f, "missing required header `{header}`"),
279            Self::InvalidHeader { name, detail } => {
280                write!(f, "invalid header `{name}`: {detail}")
281            }
282            Self::InvalidSignature(message)
283            | Self::Json(message)
284            | Self::Secret(message)
285            | Self::EventLog(message)
286            | Self::HarnRuntime(message)
287            | Self::Unsupported(message)
288            | Self::Activation(message) => message.fmt(f),
289            Self::TimestampOutOfWindow {
290                timestamp,
291                now,
292                window,
293            } => write!(
294                f,
295                "timestamp {timestamp} is outside the allowed verification window of {window} around {now}"
296            ),
297            Self::Client(error) => error.fmt(f),
298        }
299    }
300}
301
302impl std::error::Error for ConnectorError {}
303
304impl From<crate::event_log::LogError> for ConnectorError {
305    fn from(value: crate::event_log::LogError) -> Self {
306        Self::EventLog(value.to_string())
307    }
308}
309
310impl From<crate::secrets::SecretError> for ConnectorError {
311    fn from(value: crate::secrets::SecretError) -> Self {
312        Self::Secret(value.to_string())
313    }
314}
315
316impl From<serde_json::Error> for ConnectorError {
317    fn from(value: serde_json::Error) -> Self {
318        Self::Json(value.to_string())
319    }
320}
321
322impl From<ClientError> for ConnectorError {
323    fn from(value: ClientError) -> Self {
324        Self::Client(value)
325    }
326}
327
328/// Startup context shared with connector instances.
329#[derive(Clone)]
330pub struct ConnectorCtx {
331    pub event_log: Arc<AnyEventLog>,
332    pub secrets: Arc<dyn SecretProvider>,
333    pub inbox: Arc<InboxIndex>,
334    pub metrics: Arc<MetricsRegistry>,
335    pub rate_limiter: Arc<RateLimiterFactory>,
336}
337
338/// Snapshot of connector-local metrics surfaced for tests and diagnostics.
339#[derive(Clone, Debug, Default, PartialEq, Eq)]
340pub struct ConnectorMetricsSnapshot {
341    pub inbox_claims_written: u64,
342    pub inbox_duplicates_rejected: u64,
343    pub inbox_fast_path_hits: u64,
344    pub inbox_durable_hits: u64,
345    pub inbox_expired_entries: u64,
346    pub inbox_active_entries: u64,
347    pub linear_timestamp_rejections_total: u64,
348    pub dispatch_succeeded_total: u64,
349    pub dispatch_failed_total: u64,
350    pub retry_scheduled_total: u64,
351    pub slack_delivery_success_total: u64,
352    pub slack_delivery_failure_total: u64,
353}
354
355type MetricLabels = BTreeMap<String, String>;
356
357#[derive(Clone, Debug, Default, PartialEq)]
358struct HistogramMetric {
359    buckets: BTreeMap<String, u64>,
360    count: u64,
361    sum: f64,
362}
363
364static ACTIVE_METRICS_REGISTRY: OnceLock<Mutex<Option<Arc<MetricsRegistry>>>> = OnceLock::new();
365
366pub fn install_active_metrics_registry(metrics: Arc<MetricsRegistry>) {
367    let slot = ACTIVE_METRICS_REGISTRY.get_or_init(|| Mutex::new(None));
368    *slot.lock().expect("active metrics registry poisoned") = Some(metrics);
369}
370
371pub fn clear_active_metrics_registry() {
372    if let Some(slot) = ACTIVE_METRICS_REGISTRY.get() {
373        *slot.lock().expect("active metrics registry poisoned") = None;
374    }
375}
376
377pub fn active_metrics_registry() -> Option<Arc<MetricsRegistry>> {
378    ACTIVE_METRICS_REGISTRY.get().and_then(|slot| {
379        slot.lock()
380            .expect("active metrics registry poisoned")
381            .clone()
382    })
383}
384
385/// Shared metrics surface for connector-local counters and timings.
386#[derive(Debug, Default)]
387pub struct MetricsRegistry {
388    inbox_claims_written: AtomicU64,
389    inbox_duplicates_rejected: AtomicU64,
390    inbox_fast_path_hits: AtomicU64,
391    inbox_durable_hits: AtomicU64,
392    inbox_expired_entries: AtomicU64,
393    inbox_active_entries: AtomicU64,
394    linear_timestamp_rejections_total: AtomicU64,
395    dispatch_succeeded_total: AtomicU64,
396    dispatch_failed_total: AtomicU64,
397    retry_scheduled_total: AtomicU64,
398    slack_delivery_success_total: AtomicU64,
399    slack_delivery_failure_total: AtomicU64,
400    custom_counters: Mutex<BTreeMap<String, u64>>,
401    counters: Mutex<BTreeMap<(String, MetricLabels), f64>>,
402    gauges: Mutex<BTreeMap<(String, MetricLabels), f64>>,
403    histograms: Mutex<BTreeMap<(String, MetricLabels), HistogramMetric>>,
404    pending_trigger_events: Mutex<BTreeMap<MetricLabels, BTreeMap<String, i64>>>,
405}
406
407impl MetricsRegistry {
408    const DURATION_BUCKETS: [f64; 9] = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0];
409    const TRIGGER_LATENCY_BUCKETS: [f64; 15] = [
410        0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0,
411    ];
412    const SIZE_BUCKETS: [f64; 9] = [
413        128.0, 512.0, 1024.0, 4096.0, 16384.0, 65536.0, 262144.0, 1048576.0, 10485760.0,
414    ];
415
416    pub fn snapshot(&self) -> ConnectorMetricsSnapshot {
417        ConnectorMetricsSnapshot {
418            inbox_claims_written: self.inbox_claims_written.load(Ordering::Relaxed),
419            inbox_duplicates_rejected: self.inbox_duplicates_rejected.load(Ordering::Relaxed),
420            inbox_fast_path_hits: self.inbox_fast_path_hits.load(Ordering::Relaxed),
421            inbox_durable_hits: self.inbox_durable_hits.load(Ordering::Relaxed),
422            inbox_expired_entries: self.inbox_expired_entries.load(Ordering::Relaxed),
423            inbox_active_entries: self.inbox_active_entries.load(Ordering::Relaxed),
424            linear_timestamp_rejections_total: self
425                .linear_timestamp_rejections_total
426                .load(Ordering::Relaxed),
427            dispatch_succeeded_total: self.dispatch_succeeded_total.load(Ordering::Relaxed),
428            dispatch_failed_total: self.dispatch_failed_total.load(Ordering::Relaxed),
429            retry_scheduled_total: self.retry_scheduled_total.load(Ordering::Relaxed),
430            slack_delivery_success_total: self.slack_delivery_success_total.load(Ordering::Relaxed),
431            slack_delivery_failure_total: self.slack_delivery_failure_total.load(Ordering::Relaxed),
432        }
433    }
434
435    pub(crate) fn record_inbox_claim(&self) {
436        self.inbox_claims_written.fetch_add(1, Ordering::Relaxed);
437    }
438
439    pub(crate) fn record_inbox_duplicate_fast_path(&self) {
440        self.inbox_duplicates_rejected
441            .fetch_add(1, Ordering::Relaxed);
442        self.inbox_fast_path_hits.fetch_add(1, Ordering::Relaxed);
443    }
444
445    pub(crate) fn record_inbox_duplicate_durable(&self) {
446        self.inbox_duplicates_rejected
447            .fetch_add(1, Ordering::Relaxed);
448        self.inbox_durable_hits.fetch_add(1, Ordering::Relaxed);
449    }
450
451    pub(crate) fn record_inbox_expired_entries(&self, count: u64) {
452        if count > 0 {
453            self.inbox_expired_entries
454                .fetch_add(count, Ordering::Relaxed);
455        }
456    }
457
458    pub(crate) fn set_inbox_active_entries(&self, count: usize) {
459        self.inbox_active_entries
460            .store(count as u64, Ordering::Relaxed);
461    }
462
463    pub fn record_linear_timestamp_rejection(&self) {
464        self.linear_timestamp_rejections_total
465            .fetch_add(1, Ordering::Relaxed);
466    }
467
468    pub fn record_dispatch_succeeded(&self) {
469        self.dispatch_succeeded_total
470            .fetch_add(1, Ordering::Relaxed);
471    }
472
473    pub fn record_dispatch_failed(&self) {
474        self.dispatch_failed_total.fetch_add(1, Ordering::Relaxed);
475    }
476
477    pub fn record_retry_scheduled(&self) {
478        self.retry_scheduled_total.fetch_add(1, Ordering::Relaxed);
479    }
480
481    pub fn record_slack_delivery_success(&self) {
482        self.slack_delivery_success_total
483            .fetch_add(1, Ordering::Relaxed);
484    }
485
486    pub fn record_slack_delivery_failure(&self) {
487        self.slack_delivery_failure_total
488            .fetch_add(1, Ordering::Relaxed);
489    }
490
491    pub fn record_custom_counter(&self, name: &str, amount: u64) {
492        if amount == 0 {
493            return;
494        }
495        let mut counters = self
496            .custom_counters
497            .lock()
498            .expect("custom counters poisoned");
499        *counters.entry(name.to_string()).or_default() += amount;
500    }
501
502    pub fn record_http_request(
503        &self,
504        endpoint: &str,
505        method: &str,
506        status: u16,
507        duration: StdDuration,
508        body_size_bytes: usize,
509    ) {
510        self.increment_counter(
511            "harn_http_requests_total",
512            labels([
513                ("endpoint", endpoint),
514                ("method", method),
515                ("status", &status.to_string()),
516            ]),
517            1,
518        );
519        self.observe_histogram(
520            "harn_http_request_duration_seconds",
521            labels([("endpoint", endpoint)]),
522            duration.as_secs_f64(),
523            &Self::DURATION_BUCKETS,
524        );
525        self.observe_histogram(
526            "harn_http_body_size_bytes",
527            labels([("endpoint", endpoint)]),
528            body_size_bytes as f64,
529            &Self::SIZE_BUCKETS,
530        );
531    }
532
533    pub fn record_trigger_received(&self, trigger_id: &str, provider: &str) {
534        self.increment_counter(
535            "harn_trigger_received_total",
536            labels([("trigger_id", trigger_id), ("provider", provider)]),
537            1,
538        );
539    }
540
541    pub fn record_trigger_deduped(&self, trigger_id: &str, reason: &str) {
542        self.increment_counter(
543            "harn_trigger_deduped_total",
544            labels([("trigger_id", trigger_id), ("reason", reason)]),
545            1,
546        );
547    }
548
549    pub fn record_trigger_predicate_evaluation(
550        &self,
551        trigger_id: &str,
552        result: bool,
553        cost_usd: f64,
554    ) {
555        self.increment_counter(
556            "harn_trigger_predicate_evaluations_total",
557            labels([
558                ("trigger_id", trigger_id),
559                ("result", if result { "true" } else { "false" }),
560            ]),
561            1,
562        );
563        self.observe_histogram(
564            "harn_trigger_predicate_cost_usd",
565            labels([("trigger_id", trigger_id)]),
566            cost_usd.max(0.0),
567            &[0.0, 0.001, 0.01, 0.05, 0.1, 1.0],
568        );
569    }
570
571    pub fn record_trigger_dispatched(&self, trigger_id: &str, handler_kind: &str, outcome: &str) {
572        self.increment_counter(
573            "harn_trigger_dispatched_total",
574            labels([
575                ("trigger_id", trigger_id),
576                ("handler_kind", handler_kind),
577                ("outcome", outcome),
578            ]),
579            1,
580        );
581    }
582
583    pub fn record_trigger_retry(&self, trigger_id: &str, attempt: u32) {
584        self.increment_counter(
585            "harn_trigger_retries_total",
586            labels([
587                ("trigger_id", trigger_id),
588                ("attempt", &attempt.to_string()),
589            ]),
590            1,
591        );
592    }
593
594    pub fn record_trigger_dlq(&self, trigger_id: &str, reason: &str) {
595        self.increment_counter(
596            "harn_trigger_dlq_total",
597            labels([("trigger_id", trigger_id), ("reason", reason)]),
598            1,
599        );
600    }
601
602    pub fn record_trigger_accepted_to_normalized(
603        &self,
604        trigger_id: &str,
605        binding_key: &str,
606        provider: &str,
607        tenant_id: Option<&str>,
608        status: &str,
609        duration: StdDuration,
610    ) {
611        self.observe_histogram(
612            "harn_trigger_webhook_accepted_to_normalized_seconds",
613            trigger_lifecycle_labels(trigger_id, binding_key, provider, tenant_id, status),
614            duration.as_secs_f64(),
615            &Self::TRIGGER_LATENCY_BUCKETS,
616        );
617    }
618
619    pub fn record_trigger_accepted_to_queue_append(
620        &self,
621        trigger_id: &str,
622        binding_key: &str,
623        provider: &str,
624        tenant_id: Option<&str>,
625        status: &str,
626        duration: StdDuration,
627    ) {
628        self.observe_histogram(
629            "harn_trigger_webhook_accepted_to_queue_append_seconds",
630            trigger_lifecycle_labels(trigger_id, binding_key, provider, tenant_id, status),
631            duration.as_secs_f64(),
632            &Self::TRIGGER_LATENCY_BUCKETS,
633        );
634    }
635
636    pub fn record_trigger_queue_age_at_dispatch_admission(
637        &self,
638        trigger_id: &str,
639        binding_key: &str,
640        provider: &str,
641        tenant_id: Option<&str>,
642        status: &str,
643        age: StdDuration,
644    ) {
645        self.observe_histogram(
646            "harn_trigger_queue_age_at_dispatch_admission_seconds",
647            trigger_lifecycle_labels(trigger_id, binding_key, provider, tenant_id, status),
648            age.as_secs_f64(),
649            &Self::TRIGGER_LATENCY_BUCKETS,
650        );
651    }
652
653    pub fn record_trigger_queue_age_at_dispatch_start(
654        &self,
655        trigger_id: &str,
656        binding_key: &str,
657        provider: &str,
658        tenant_id: Option<&str>,
659        status: &str,
660        age: StdDuration,
661    ) {
662        self.observe_histogram(
663            "harn_trigger_queue_age_at_dispatch_start_seconds",
664            trigger_lifecycle_labels(trigger_id, binding_key, provider, tenant_id, status),
665            age.as_secs_f64(),
666            &Self::TRIGGER_LATENCY_BUCKETS,
667        );
668    }
669
670    pub fn record_trigger_dispatch_runtime(
671        &self,
672        trigger_id: &str,
673        binding_key: &str,
674        provider: &str,
675        tenant_id: Option<&str>,
676        status: &str,
677        duration: StdDuration,
678    ) {
679        self.observe_histogram(
680            "harn_trigger_dispatch_runtime_seconds",
681            trigger_lifecycle_labels(trigger_id, binding_key, provider, tenant_id, status),
682            duration.as_secs_f64(),
683            &Self::TRIGGER_LATENCY_BUCKETS,
684        );
685    }
686
687    pub fn record_trigger_retry_delay(
688        &self,
689        trigger_id: &str,
690        binding_key: &str,
691        provider: &str,
692        tenant_id: Option<&str>,
693        status: &str,
694        duration: StdDuration,
695    ) {
696        self.observe_histogram(
697            "harn_trigger_retry_delay_seconds",
698            trigger_lifecycle_labels(trigger_id, binding_key, provider, tenant_id, status),
699            duration.as_secs_f64(),
700            &Self::TRIGGER_LATENCY_BUCKETS,
701        );
702    }
703
704    pub fn record_trigger_accepted_to_dlq(
705        &self,
706        trigger_id: &str,
707        binding_key: &str,
708        provider: &str,
709        tenant_id: Option<&str>,
710        status: &str,
711        duration: StdDuration,
712    ) {
713        self.observe_histogram(
714            "harn_trigger_accepted_to_dlq_seconds",
715            trigger_lifecycle_labels(trigger_id, binding_key, provider, tenant_id, status),
716            duration.as_secs_f64(),
717            &Self::TRIGGER_LATENCY_BUCKETS,
718        );
719    }
720
721    pub fn note_trigger_pending_event(
722        &self,
723        event_id: &str,
724        trigger_id: &str,
725        binding_key: &str,
726        provider: &str,
727        tenant_id: Option<&str>,
728        accepted_at_ms: i64,
729        now_ms: i64,
730    ) {
731        let labels = trigger_pending_labels(trigger_id, binding_key, provider, tenant_id);
732        {
733            let mut pending = self
734                .pending_trigger_events
735                .lock()
736                .expect("pending trigger events poisoned");
737            pending
738                .entry(labels.clone())
739                .or_default()
740                .insert(event_id.to_string(), accepted_at_ms);
741        }
742        self.refresh_oldest_pending_gauge(labels, now_ms);
743    }
744
745    pub fn clear_trigger_pending_event(
746        &self,
747        event_id: &str,
748        trigger_id: &str,
749        binding_key: &str,
750        provider: &str,
751        tenant_id: Option<&str>,
752        now_ms: i64,
753    ) {
754        let labels = trigger_pending_labels(trigger_id, binding_key, provider, tenant_id);
755        {
756            let mut pending = self
757                .pending_trigger_events
758                .lock()
759                .expect("pending trigger events poisoned");
760            if let Some(events) = pending.get_mut(&labels) {
761                events.remove(event_id);
762                if events.is_empty() {
763                    pending.remove(&labels);
764                }
765            }
766        }
767        self.refresh_oldest_pending_gauge(labels, now_ms);
768    }
769
770    pub fn set_trigger_inflight(&self, trigger_id: &str, count: u64) {
771        self.set_gauge(
772            "harn_trigger_inflight",
773            labels([("trigger_id", trigger_id)]),
774            count as f64,
775        );
776    }
777
778    pub fn set_trigger_budget_cost_today(&self, trigger_id: &str, cost_usd: f64) {
779        self.set_gauge(
780            "harn_trigger_budget_cost_today_usd",
781            labels([("trigger_id", trigger_id)]),
782            cost_usd.max(0.0),
783        );
784    }
785
786    pub fn record_trigger_budget_exhausted(&self, trigger_id: &str, strategy: &str) {
787        self.increment_counter(
788            "harn_trigger_budget_exhausted_total",
789            labels([("trigger_id", trigger_id), ("strategy", strategy)]),
790            1,
791        );
792    }
793
794    pub fn record_backpressure_event(&self, dimension: &str, action: &str) {
795        self.increment_counter(
796            "harn_backpressure_events_total",
797            labels([("dimension", dimension), ("action", action)]),
798            1,
799        );
800    }
801
802    pub fn record_event_log_append(
803        &self,
804        topic: &str,
805        duration: StdDuration,
806        payload_bytes: usize,
807    ) {
808        self.observe_histogram(
809            "harn_event_log_append_duration_seconds",
810            labels([("topic", topic)]),
811            duration.as_secs_f64(),
812            &Self::DURATION_BUCKETS,
813        );
814        self.set_gauge(
815            "harn_event_log_topic_size_bytes",
816            labels([("topic", topic)]),
817            payload_bytes as f64,
818        );
819    }
820
821    pub fn set_event_log_consumer_lag(&self, topic: &str, consumer: &str, lag: u64) {
822        self.set_gauge(
823            "harn_event_log_consumer_lag",
824            labels([("topic", topic), ("consumer", consumer)]),
825            lag as f64,
826        );
827    }
828
829    pub fn record_a2a_hop(&self, target: &str, outcome: &str, duration: StdDuration) {
830        self.increment_counter(
831            "harn_a2a_hops_total",
832            labels([("target", target), ("outcome", outcome)]),
833            1,
834        );
835        self.observe_histogram(
836            "harn_a2a_hop_duration_seconds",
837            labels([("target", target)]),
838            duration.as_secs_f64(),
839            &Self::DURATION_BUCKETS,
840        );
841    }
842
843    pub fn set_worker_queue_depth(&self, queue: &str, depth: u64) {
844        self.set_gauge(
845            "harn_worker_queue_depth",
846            labels([("queue", queue)]),
847            depth as f64,
848        );
849    }
850
851    pub fn record_worker_queue_claim_age(&self, queue: &str, age_seconds: f64) {
852        self.observe_histogram(
853            "harn_worker_queue_claim_age_seconds",
854            labels([("queue", queue)]),
855            age_seconds.max(0.0),
856            &Self::DURATION_BUCKETS,
857        );
858    }
859
860    /// Increment the scheduler-selection counter for a particular fairness key.
861    pub fn record_scheduler_selection(
862        &self,
863        queue: &str,
864        fairness_dimension: &str,
865        fairness_key: &str,
866    ) {
867        self.increment_counter(
868            "harn_scheduler_selections_total",
869            labels([
870                ("queue", queue),
871                ("fairness_dimension", fairness_dimension),
872                ("fairness_key", fairness_key),
873            ]),
874            1,
875        );
876    }
877
878    /// Increment the scheduler-deferred counter (queue had work but couldn't
879    /// be selected because the key was at its concurrency cap).
880    pub fn record_scheduler_deferral(
881        &self,
882        queue: &str,
883        fairness_dimension: &str,
884        fairness_key: &str,
885    ) {
886        self.increment_counter(
887            "harn_scheduler_deferrals_total",
888            labels([
889                ("queue", queue),
890                ("fairness_dimension", fairness_dimension),
891                ("fairness_key", fairness_key),
892            ]),
893            1,
894        );
895    }
896
897    /// Increment the scheduler starvation-promotion counter.
898    pub fn record_scheduler_starvation_promotion(
899        &self,
900        queue: &str,
901        fairness_dimension: &str,
902        fairness_key: &str,
903    ) {
904        self.increment_counter(
905            "harn_scheduler_starvation_promotions_total",
906            labels([
907                ("queue", queue),
908                ("fairness_dimension", fairness_dimension),
909                ("fairness_key", fairness_key),
910            ]),
911            1,
912        );
913    }
914
915    /// Set the current scheduler deficit gauge for a fairness key.
916    pub fn set_scheduler_deficit(
917        &self,
918        queue: &str,
919        fairness_dimension: &str,
920        fairness_key: &str,
921        deficit: i64,
922    ) {
923        self.set_gauge(
924            "harn_scheduler_deficit",
925            labels([
926                ("queue", queue),
927                ("fairness_dimension", fairness_dimension),
928                ("fairness_key", fairness_key),
929            ]),
930            deficit as f64,
931        );
932    }
933
934    /// Set the oldest-eligible-job-age gauge for a fairness key (seconds).
935    pub fn set_scheduler_oldest_eligible_age(
936        &self,
937        queue: &str,
938        fairness_dimension: &str,
939        fairness_key: &str,
940        age_ms: u64,
941    ) {
942        self.set_gauge(
943            "harn_scheduler_oldest_eligible_age_seconds",
944            labels([
945                ("queue", queue),
946                ("fairness_dimension", fairness_dimension),
947                ("fairness_key", fairness_key),
948            ]),
949            age_ms as f64 / 1000.0,
950        );
951    }
952
953    pub fn set_orchestrator_pump_backlog(&self, topic: &str, count: u64) {
954        self.set_gauge(
955            "harn_orchestrator_pump_backlog",
956            labels([("topic", topic)]),
957            count as f64,
958        );
959    }
960
961    pub fn set_orchestrator_pump_outstanding(&self, topic: &str, count: usize) {
962        self.set_gauge(
963            "harn_orchestrator_pump_outstanding",
964            labels([("topic", topic)]),
965            count as f64,
966        );
967    }
968
969    pub fn record_orchestrator_pump_admission_delay(&self, topic: &str, duration: StdDuration) {
970        self.observe_histogram(
971            "harn_orchestrator_pump_admission_delay_seconds",
972            labels([("topic", topic)]),
973            duration.as_secs_f64(),
974            &Self::DURATION_BUCKETS,
975        );
976    }
977
978    pub fn record_llm_call(&self, provider: &str, model: &str, outcome: &str, cost_usd: f64) {
979        self.increment_counter(
980            "harn_llm_calls_total",
981            labels([
982                ("provider", provider),
983                ("model", model),
984                ("outcome", outcome),
985            ]),
986            1,
987        );
988        if cost_usd > 0.0 {
989            self.increment_counter(
990                "harn_llm_cost_usd_total",
991                labels([("provider", provider), ("model", model)]),
992                cost_usd,
993            );
994        } else {
995            self.ensure_counter(
996                "harn_llm_cost_usd_total",
997                labels([("provider", provider), ("model", model)]),
998            );
999        }
1000    }
1001
1002    pub fn record_llm_cache_hit(&self, provider: &str) {
1003        self.increment_counter(
1004            "harn_llm_cache_hits_total",
1005            labels([("provider", provider)]),
1006            1,
1007        );
1008    }
1009
1010    /// Track LLM streaming responses aborted mid-stream by
1011    /// `schema_stream_abort` because the partial JSON cannot satisfy
1012    /// `output_schema`. Counter is labelled by `(provider, model)` so
1013    /// dashboards can attribute the savings — each abort short-circuits
1014    /// a provider stream that would otherwise have run to completion.
1015    pub fn record_schema_stream_aborted(&self, provider: &str, model: &str) {
1016        self.increment_counter(
1017            "harn_llm_schema_stream_aborted_total",
1018            labels([("provider", provider), ("model", model)]),
1019            1,
1020        );
1021    }
1022
1023    pub fn render_prometheus(&self) -> String {
1024        let snapshot = self.snapshot();
1025        let counters = [
1026            (
1027                "connector_linear_timestamp_rejections_total",
1028                snapshot.linear_timestamp_rejections_total,
1029            ),
1030            (
1031                "dispatch_succeeded_total",
1032                snapshot.dispatch_succeeded_total,
1033            ),
1034            ("dispatch_failed_total", snapshot.dispatch_failed_total),
1035            ("inbox_duplicates_total", snapshot.inbox_duplicates_rejected),
1036            ("retry_scheduled_total", snapshot.retry_scheduled_total),
1037            (
1038                "slack_events_delivery_success_total",
1039                snapshot.slack_delivery_success_total,
1040            ),
1041            (
1042                "slack_events_delivery_failure_total",
1043                snapshot.slack_delivery_failure_total,
1044            ),
1045        ];
1046
1047        let mut rendered = String::new();
1048        for (name, value) in counters {
1049            rendered.push_str("# TYPE ");
1050            rendered.push_str(name);
1051            rendered.push_str(" counter\n");
1052            rendered.push_str(name);
1053            rendered.push(' ');
1054            rendered.push_str(&value.to_string());
1055            rendered.push('\n');
1056        }
1057        let custom_counters = self
1058            .custom_counters
1059            .lock()
1060            .expect("custom counters poisoned");
1061        for (name, value) in custom_counters.iter() {
1062            let metric_name = format!(
1063                "connector_custom_{}_total",
1064                name.chars()
1065                    .map(|ch| if ch.is_ascii_alphanumeric() || ch == '_' {
1066                        ch
1067                    } else {
1068                        '_'
1069                    })
1070                    .collect::<String>()
1071            );
1072            rendered.push_str("# TYPE ");
1073            rendered.push_str(&metric_name);
1074            rendered.push_str(" counter\n");
1075            rendered.push_str(&metric_name);
1076            rendered.push(' ');
1077            rendered.push_str(&value.to_string());
1078            rendered.push('\n');
1079        }
1080        rendered.push_str("# TYPE slack_events_auto_disable_min_success_ratio gauge\n");
1081        rendered.push_str("slack_events_auto_disable_min_success_ratio 0.05\n");
1082        rendered.push_str("# TYPE slack_events_auto_disable_min_events_per_hour gauge\n");
1083        rendered.push_str("slack_events_auto_disable_min_events_per_hour 1000\n");
1084        self.render_generic_metrics(&mut rendered);
1085        rendered
1086    }
1087
1088    fn increment_counter(&self, name: &str, labels: MetricLabels, amount: impl Into<f64>) {
1089        let amount = amount.into();
1090        if amount <= 0.0 || !amount.is_finite() {
1091            return;
1092        }
1093        let mut counters = self.counters.lock().expect("metrics counters poisoned");
1094        *counters.entry((name.to_string(), labels)).or_default() += amount;
1095    }
1096
1097    fn ensure_counter(&self, name: &str, labels: MetricLabels) {
1098        let mut counters = self.counters.lock().expect("metrics counters poisoned");
1099        counters.entry((name.to_string(), labels)).or_default();
1100    }
1101
1102    fn set_gauge(&self, name: &str, labels: MetricLabels, value: f64) {
1103        let mut gauges = self.gauges.lock().expect("metrics gauges poisoned");
1104        gauges.insert((name.to_string(), labels), value);
1105    }
1106
1107    fn observe_histogram(
1108        &self,
1109        name: &str,
1110        labels: MetricLabels,
1111        value: f64,
1112        bucket_bounds: &[f64],
1113    ) {
1114        if !value.is_finite() {
1115            return;
1116        }
1117        let mut histograms = self.histograms.lock().expect("metrics histograms poisoned");
1118        let histogram = histograms
1119            .entry((name.to_string(), labels))
1120            .or_insert_with(|| HistogramMetric {
1121                buckets: bucket_bounds
1122                    .iter()
1123                    .map(|bound| (prometheus_float(*bound), 0))
1124                    .chain(std::iter::once(("+Inf".to_string(), 0)))
1125                    .collect(),
1126                count: 0,
1127                sum: 0.0,
1128            });
1129        histogram.count += 1;
1130        histogram.sum += value;
1131        for bound in bucket_bounds {
1132            if value <= *bound {
1133                let key = prometheus_float(*bound);
1134                *histogram.buckets.entry(key).or_default() += 1;
1135            }
1136        }
1137        *histogram.buckets.entry("+Inf".to_string()).or_default() += 1;
1138    }
1139
1140    fn refresh_oldest_pending_gauge(&self, labels: MetricLabels, now_ms: i64) {
1141        let oldest_accepted_at_ms = self
1142            .pending_trigger_events
1143            .lock()
1144            .expect("pending trigger events poisoned")
1145            .get(&labels)
1146            .and_then(|events| events.values().min().copied());
1147        let age_seconds = oldest_accepted_at_ms
1148            .map(|accepted_at_ms| millis_delta(now_ms, accepted_at_ms).as_secs_f64())
1149            .unwrap_or(0.0);
1150        self.set_gauge(
1151            "harn_trigger_oldest_pending_age_seconds",
1152            labels,
1153            age_seconds,
1154        );
1155    }
1156
1157    fn render_generic_metrics(&self, rendered: &mut String) {
1158        let counters = self
1159            .counters
1160            .lock()
1161            .expect("metrics counters poisoned")
1162            .clone();
1163        let gauges = self.gauges.lock().expect("metrics gauges poisoned").clone();
1164        let histograms = self
1165            .histograms
1166            .lock()
1167            .expect("metrics histograms poisoned")
1168            .clone();
1169
1170        for name in metric_family_names(MetricKind::Counter) {
1171            rendered.push_str("# TYPE ");
1172            rendered.push_str(name);
1173            rendered.push_str(" counter\n");
1174            for ((sample_name, labels), value) in counters.iter().filter(|((n, _), _)| n == name) {
1175                render_sample(rendered, sample_name, labels, *value);
1176            }
1177        }
1178        for name in metric_family_names(MetricKind::Gauge) {
1179            rendered.push_str("# TYPE ");
1180            rendered.push_str(name);
1181            rendered.push_str(" gauge\n");
1182            for ((sample_name, labels), value) in gauges.iter().filter(|((n, _), _)| n == name) {
1183                render_sample(rendered, sample_name, labels, *value);
1184            }
1185        }
1186        for name in metric_family_names(MetricKind::Histogram) {
1187            rendered.push_str("# TYPE ");
1188            rendered.push_str(name);
1189            rendered.push_str(" histogram\n");
1190            for ((sample_name, labels), histogram) in
1191                histograms.iter().filter(|((n, _), _)| n == name)
1192            {
1193                for (le, value) in &histogram.buckets {
1194                    let mut bucket_labels = labels.clone();
1195                    bucket_labels.insert("le".to_string(), le.clone());
1196                    render_sample(
1197                        rendered,
1198                        &format!("{sample_name}_bucket"),
1199                        &bucket_labels,
1200                        *value as f64,
1201                    );
1202                }
1203                render_sample(
1204                    rendered,
1205                    &format!("{sample_name}_sum"),
1206                    labels,
1207                    histogram.sum,
1208                );
1209                render_sample(
1210                    rendered,
1211                    &format!("{sample_name}_count"),
1212                    labels,
1213                    histogram.count as f64,
1214                );
1215            }
1216        }
1217    }
1218}
1219
1220#[derive(Clone, Copy)]
1221enum MetricKind {
1222    Counter,
1223    Gauge,
1224    Histogram,
1225}
1226
1227fn metric_family_names(kind: MetricKind) -> &'static [&'static str] {
1228    match kind {
1229        MetricKind::Counter => &[
1230            "harn_http_requests_total",
1231            "harn_trigger_received_total",
1232            "harn_trigger_deduped_total",
1233            "harn_trigger_predicate_evaluations_total",
1234            "harn_trigger_dispatched_total",
1235            "harn_trigger_retries_total",
1236            "harn_trigger_dlq_total",
1237            "harn_trigger_budget_exhausted_total",
1238            "harn_backpressure_events_total",
1239            "harn_a2a_hops_total",
1240            "harn_llm_calls_total",
1241            "harn_llm_cost_usd_total",
1242            "harn_llm_cache_hits_total",
1243            "harn_llm_schema_stream_aborted_total",
1244            "harn_scheduler_selections_total",
1245            "harn_scheduler_deferrals_total",
1246            "harn_scheduler_starvation_promotions_total",
1247        ],
1248        MetricKind::Gauge => &[
1249            "harn_trigger_inflight",
1250            "harn_event_log_topic_size_bytes",
1251            "harn_event_log_consumer_lag",
1252            "harn_trigger_budget_cost_today_usd",
1253            "harn_worker_queue_depth",
1254            "harn_orchestrator_pump_backlog",
1255            "harn_orchestrator_pump_outstanding",
1256            "harn_trigger_oldest_pending_age_seconds",
1257            "harn_scheduler_deficit",
1258            "harn_scheduler_oldest_eligible_age_seconds",
1259        ],
1260        MetricKind::Histogram => &[
1261            "harn_http_request_duration_seconds",
1262            "harn_http_body_size_bytes",
1263            "harn_trigger_predicate_cost_usd",
1264            "harn_event_log_append_duration_seconds",
1265            "harn_a2a_hop_duration_seconds",
1266            "harn_worker_queue_claim_age_seconds",
1267            "harn_orchestrator_pump_admission_delay_seconds",
1268            "harn_trigger_webhook_accepted_to_normalized_seconds",
1269            "harn_trigger_webhook_accepted_to_queue_append_seconds",
1270            "harn_trigger_queue_age_at_dispatch_admission_seconds",
1271            "harn_trigger_queue_age_at_dispatch_start_seconds",
1272            "harn_trigger_dispatch_runtime_seconds",
1273            "harn_trigger_retry_delay_seconds",
1274            "harn_trigger_accepted_to_dlq_seconds",
1275        ],
1276    }
1277}
1278
1279fn labels<const N: usize>(pairs: [(&str, &str); N]) -> MetricLabels {
1280    pairs
1281        .into_iter()
1282        .map(|(name, value)| (name.to_string(), value.to_string()))
1283        .collect()
1284}
1285
1286fn trigger_lifecycle_labels(
1287    trigger_id: &str,
1288    binding_key: &str,
1289    provider: &str,
1290    tenant_id: Option<&str>,
1291    status: &str,
1292) -> MetricLabels {
1293    labels([
1294        ("binding_key", binding_key),
1295        ("provider", provider),
1296        ("status", status),
1297        ("tenant_id", tenant_label(tenant_id)),
1298        ("trigger_id", trigger_id),
1299    ])
1300}
1301
1302fn trigger_pending_labels(
1303    trigger_id: &str,
1304    binding_key: &str,
1305    provider: &str,
1306    tenant_id: Option<&str>,
1307) -> MetricLabels {
1308    labels([
1309        ("binding_key", binding_key),
1310        ("provider", provider),
1311        ("tenant_id", tenant_label(tenant_id)),
1312        ("trigger_id", trigger_id),
1313    ])
1314}
1315
1316fn tenant_label(tenant_id: Option<&str>) -> &str {
1317    tenant_id
1318        .map(str::trim)
1319        .filter(|value| !value.is_empty())
1320        .unwrap_or("none")
1321}
1322
1323fn millis_delta(later_ms: i64, earlier_ms: i64) -> StdDuration {
1324    StdDuration::from_millis(later_ms.saturating_sub(earlier_ms).max(0) as u64)
1325}
1326
1327fn render_sample(rendered: &mut String, name: &str, labels: &MetricLabels, value: f64) {
1328    rendered.push_str(name);
1329    if !labels.is_empty() {
1330        rendered.push('{');
1331        for (index, (label, label_value)) in labels.iter().enumerate() {
1332            if index > 0 {
1333                rendered.push(',');
1334            }
1335            rendered.push_str(label);
1336            rendered.push_str("=\"");
1337            rendered.push_str(&escape_label_value(label_value));
1338            rendered.push('"');
1339        }
1340        rendered.push('}');
1341    }
1342    rendered.push(' ');
1343    rendered.push_str(&prometheus_float(value));
1344    rendered.push('\n');
1345}
1346
1347fn escape_label_value(value: &str) -> String {
1348    value
1349        .chars()
1350        .flat_map(|ch| match ch {
1351            '\\' => "\\\\".chars().collect::<Vec<_>>(),
1352            '"' => "\\\"".chars().collect::<Vec<_>>(),
1353            '\n' => "\\n".chars().collect::<Vec<_>>(),
1354            other => vec![other],
1355        })
1356        .collect()
1357}
1358
1359fn prometheus_float(value: f64) -> String {
1360    if value.is_infinite() && value.is_sign_positive() {
1361        return "+Inf".to_string();
1362    }
1363    if value.fract() == 0.0 {
1364        format!("{value:.0}")
1365    } else {
1366        let rendered = format!("{value:.6}");
1367        rendered
1368            .trim_end_matches('0')
1369            .trim_end_matches('.')
1370            .to_string()
1371    }
1372}
1373
1374/// Provider payload schema metadata exposed by a connector.
1375#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1376pub struct ProviderPayloadSchema {
1377    pub harn_schema_name: String,
1378    #[serde(default)]
1379    pub json_schema: JsonValue,
1380}
1381
1382impl ProviderPayloadSchema {
1383    pub fn new(harn_schema_name: impl Into<String>, json_schema: JsonValue) -> Self {
1384        Self {
1385            harn_schema_name: harn_schema_name.into(),
1386            json_schema,
1387        }
1388    }
1389
1390    pub fn named(harn_schema_name: impl Into<String>) -> Self {
1391        Self::new(harn_schema_name, JsonValue::Null)
1392    }
1393}
1394
1395impl Default for ProviderPayloadSchema {
1396    fn default() -> Self {
1397        Self::named("raw")
1398    }
1399}
1400
1401/// High-level transport kind a connector supports.
1402#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1403#[serde(transparent)]
1404pub struct TriggerKind(String);
1405
1406impl TriggerKind {
1407    pub fn new(value: impl Into<String>) -> Self {
1408        Self(value.into())
1409    }
1410
1411    pub fn as_str(&self) -> &str {
1412        self.0.as_str()
1413    }
1414}
1415
1416impl From<&str> for TriggerKind {
1417    fn from(value: &str) -> Self {
1418        Self::new(value)
1419    }
1420}
1421
1422impl From<String> for TriggerKind {
1423    fn from(value: String) -> Self {
1424        Self::new(value)
1425    }
1426}
1427
1428/// Future trigger manifest binding routed to a connector activation.
1429#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1430pub struct TriggerBinding {
1431    pub provider: ProviderId,
1432    pub kind: TriggerKind,
1433    pub binding_id: String,
1434    #[serde(default)]
1435    pub dedupe_key: Option<String>,
1436    #[serde(default = "default_dedupe_retention_days")]
1437    pub dedupe_retention_days: u32,
1438    #[serde(default)]
1439    pub config: JsonValue,
1440}
1441
1442impl TriggerBinding {
1443    pub fn new(
1444        provider: ProviderId,
1445        kind: impl Into<TriggerKind>,
1446        binding_id: impl Into<String>,
1447    ) -> Self {
1448        Self {
1449            provider,
1450            kind: kind.into(),
1451            binding_id: binding_id.into(),
1452            dedupe_key: None,
1453            dedupe_retention_days: crate::triggers::DEFAULT_INBOX_RETENTION_DAYS,
1454            config: JsonValue::Null,
1455        }
1456    }
1457}
1458
1459fn default_dedupe_retention_days() -> u32 {
1460    crate::triggers::DEFAULT_INBOX_RETENTION_DAYS
1461}
1462
1463/// Small in-memory trigger-binding registry used to fan bindings into connectors.
1464#[derive(Clone, Debug, Default)]
1465pub struct TriggerRegistry {
1466    bindings: BTreeMap<ProviderId, Vec<TriggerBinding>>,
1467}
1468
1469impl TriggerRegistry {
1470    pub fn register(&mut self, binding: TriggerBinding) {
1471        self.bindings
1472            .entry(binding.provider.clone())
1473            .or_default()
1474            .push(binding);
1475    }
1476
1477    pub fn bindings(&self) -> &BTreeMap<ProviderId, Vec<TriggerBinding>> {
1478        &self.bindings
1479    }
1480
1481    pub fn bindings_for(&self, provider: &ProviderId) -> &[TriggerBinding] {
1482        self.bindings
1483            .get(provider)
1484            .map(Vec::as_slice)
1485            .unwrap_or(&[])
1486    }
1487}
1488
1489/// Metadata returned from connector activation.
1490#[derive(Clone, Debug, PartialEq, Eq)]
1491pub struct ActivationHandle {
1492    pub provider: ProviderId,
1493    pub binding_count: usize,
1494}
1495
1496impl ActivationHandle {
1497    pub fn new(provider: ProviderId, binding_count: usize) -> Self {
1498        Self {
1499            provider,
1500            binding_count,
1501        }
1502    }
1503}
1504
1505/// Provider-native inbound request payload preserved as raw bytes.
1506#[derive(Clone, Debug, PartialEq, Eq)]
1507pub struct RawInbound {
1508    pub kind: String,
1509    pub headers: BTreeMap<String, String>,
1510    pub query: BTreeMap<String, String>,
1511    pub body: Vec<u8>,
1512    pub received_at: OffsetDateTime,
1513    pub occurred_at: Option<OffsetDateTime>,
1514    pub tenant_id: Option<TenantId>,
1515    pub metadata: JsonValue,
1516}
1517
1518impl RawInbound {
1519    pub fn new(kind: impl Into<String>, headers: BTreeMap<String, String>, body: Vec<u8>) -> Self {
1520        Self {
1521            kind: kind.into(),
1522            headers,
1523            query: BTreeMap::new(),
1524            body,
1525            received_at: clock::now_utc(),
1526            occurred_at: None,
1527            tenant_id: None,
1528            metadata: JsonValue::Null,
1529        }
1530    }
1531
1532    pub fn json_body(&self) -> Result<JsonValue, ConnectorError> {
1533        Ok(serde_json::from_slice(&self.body)?)
1534    }
1535}
1536
1537/// Token-bucket configuration shared across connector clients.
1538#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1539pub struct RateLimitConfig {
1540    pub capacity: u32,
1541    pub refill_tokens: u32,
1542    pub refill_interval: StdDuration,
1543}
1544
1545impl Default for RateLimitConfig {
1546    fn default() -> Self {
1547        Self {
1548            capacity: 60,
1549            refill_tokens: 1,
1550            refill_interval: StdDuration::from_secs(1),
1551        }
1552    }
1553}
1554
1555#[derive(Clone, Debug)]
1556struct TokenBucket {
1557    tokens: f64,
1558    last_refill: ClockInstant,
1559}
1560
1561impl TokenBucket {
1562    fn full(config: RateLimitConfig) -> Self {
1563        Self {
1564            tokens: config.capacity as f64,
1565            last_refill: clock::instant_now(),
1566        }
1567    }
1568
1569    fn refill(&mut self, config: RateLimitConfig, now: ClockInstant) {
1570        let interval = config.refill_interval.as_secs_f64().max(f64::EPSILON);
1571        let rate = config.refill_tokens.max(1) as f64 / interval;
1572        let elapsed = now.duration_since(self.last_refill).as_secs_f64();
1573        self.tokens = (self.tokens + elapsed * rate).min(config.capacity.max(1) as f64);
1574        self.last_refill = now;
1575    }
1576
1577    fn try_acquire(&mut self, config: RateLimitConfig, now: ClockInstant) -> bool {
1578        self.refill(config, now);
1579        if self.tokens >= 1.0 {
1580            self.tokens -= 1.0;
1581            true
1582        } else {
1583            false
1584        }
1585    }
1586
1587    fn wait_duration(&self, config: RateLimitConfig) -> StdDuration {
1588        if self.tokens >= 1.0 {
1589            return StdDuration::ZERO;
1590        }
1591        let interval = config.refill_interval.as_secs_f64().max(f64::EPSILON);
1592        let rate = config.refill_tokens.max(1) as f64 / interval;
1593        let missing = (1.0 - self.tokens).max(0.0);
1594        StdDuration::from_secs_f64((missing / rate).max(0.001))
1595    }
1596}
1597
1598/// Shared per-provider, per-key token bucket factory for outbound connector clients.
1599#[derive(Debug)]
1600pub struct RateLimiterFactory {
1601    config: RateLimitConfig,
1602    buckets: Mutex<HashMap<(String, String), TokenBucket>>,
1603}
1604
1605impl RateLimiterFactory {
1606    pub fn new(config: RateLimitConfig) -> Self {
1607        Self {
1608            config,
1609            buckets: Mutex::new(HashMap::new()),
1610        }
1611    }
1612
1613    pub fn config(&self) -> RateLimitConfig {
1614        self.config
1615    }
1616
1617    pub fn scoped(&self, provider: &ProviderId, key: impl Into<String>) -> ScopedRateLimiter<'_> {
1618        ScopedRateLimiter {
1619            factory: self,
1620            provider: provider.clone(),
1621            key: key.into(),
1622        }
1623    }
1624
1625    pub fn try_acquire(&self, provider: &ProviderId, key: &str) -> bool {
1626        self.try_acquire_at(provider, key, clock::instant_now())
1627    }
1628
1629    pub(crate) fn try_acquire_at(
1630        &self,
1631        provider: &ProviderId,
1632        key: &str,
1633        now: clock::ClockInstant,
1634    ) -> bool {
1635        let mut buckets = self.buckets.lock().expect("rate limiter mutex poisoned");
1636        let bucket = buckets
1637            .entry((provider.as_str().to_string(), key.to_string()))
1638            .or_insert_with(|| TokenBucket::full(self.config));
1639        bucket.try_acquire(self.config, now)
1640    }
1641
1642    pub async fn acquire(&self, provider: &ProviderId, key: &str) {
1643        loop {
1644            let wait = {
1645                let mut buckets = self.buckets.lock().expect("rate limiter mutex poisoned");
1646                let bucket = buckets
1647                    .entry((provider.as_str().to_string(), key.to_string()))
1648                    .or_insert_with(|| TokenBucket::full(self.config));
1649                if bucket.try_acquire(self.config, clock::instant_now()) {
1650                    return;
1651                }
1652                bucket.wait_duration(self.config)
1653            };
1654            // Honor the unified mock clock so tests that pin time via
1655            // `mock_time(...)` don't deadlock here: the bucket reads
1656            // `instant_now()` (mocked), and this sleep advances the same
1657            // mock instead of waiting on a wall-clock that never moves.
1658            clock::sleep(wait).await;
1659        }
1660    }
1661}
1662
1663impl Default for RateLimiterFactory {
1664    fn default() -> Self {
1665        Self::new(RateLimitConfig::default())
1666    }
1667}
1668
1669/// Borrowed view onto a single provider/key rate-limit scope.
1670#[derive(Clone, Debug)]
1671pub struct ScopedRateLimiter<'a> {
1672    factory: &'a RateLimiterFactory,
1673    provider: ProviderId,
1674    key: String,
1675}
1676
1677impl<'a> ScopedRateLimiter<'a> {
1678    pub fn try_acquire(&self) -> bool {
1679        self.factory.try_acquire(&self.provider, &self.key)
1680    }
1681
1682    pub async fn acquire(&self) {
1683        self.factory.acquire(&self.provider, &self.key).await;
1684    }
1685}
1686
1687/// Runtime connector registry keyed by provider id.
1688pub struct ConnectorRegistry {
1689    connectors: BTreeMap<ProviderId, ConnectorHandle>,
1690}
1691
1692impl ConnectorRegistry {
1693    pub fn empty() -> Self {
1694        Self {
1695            connectors: BTreeMap::new(),
1696        }
1697    }
1698
1699    pub fn with_defaults() -> Self {
1700        Self::with_defaults_and_clock(harn_clock::RealClock::arc())
1701    }
1702
1703    /// Build the default connector set with a shared clock for time-driven providers.
1704    pub fn with_defaults_and_clock(clock: Arc<dyn harn_clock::Clock>) -> Self {
1705        let mut registry = Self::empty();
1706        for provider in registered_provider_metadata() {
1707            if !matches!(provider.runtime, ProviderRuntimeMetadata::Builtin { .. }) {
1708                continue;
1709            }
1710            registry
1711                .register(default_connector_for_provider(&provider, clock.clone()))
1712                .expect("default connector registration should not fail");
1713        }
1714        registry
1715    }
1716
1717    pub fn register(&mut self, connector: Box<dyn Connector>) -> Result<(), ConnectorError> {
1718        let provider = connector.provider_id().clone();
1719        if self.connectors.contains_key(&provider) {
1720            return Err(ConnectorError::DuplicateProvider(provider.0));
1721        }
1722        self.connectors
1723            .insert(provider, Arc::new(AsyncMutex::new(connector)));
1724        Ok(())
1725    }
1726
1727    pub fn get(&self, id: &ProviderId) -> Option<ConnectorHandle> {
1728        self.connectors.get(id).cloned()
1729    }
1730
1731    pub fn remove(&mut self, id: &ProviderId) -> Option<ConnectorHandle> {
1732        self.connectors.remove(id)
1733    }
1734
1735    pub fn list(&self) -> Vec<ProviderId> {
1736        self.connectors.keys().cloned().collect()
1737    }
1738
1739    pub async fn init_all(&self, ctx: ConnectorCtx) -> Result<(), ConnectorError> {
1740        for connector in self.connectors.values() {
1741            connector.lock().await.init(ctx.clone()).await?;
1742        }
1743        Ok(())
1744    }
1745
1746    pub async fn client_map(&self) -> BTreeMap<ProviderId, Arc<dyn ConnectorClient>> {
1747        let mut clients = BTreeMap::new();
1748        for (provider, connector) in &self.connectors {
1749            let client = connector.lock().await.client();
1750            clients.insert(provider.clone(), client);
1751        }
1752        clients
1753    }
1754
1755    pub async fn activate_all(
1756        &self,
1757        registry: &TriggerRegistry,
1758    ) -> Result<Vec<ActivationHandle>, ConnectorError> {
1759        let mut handles = Vec::new();
1760        for (provider, connector) in &self.connectors {
1761            let bindings = registry.bindings_for(provider);
1762            if bindings.is_empty() {
1763                continue;
1764            }
1765            let connector = connector.lock().await;
1766            handles.push(connector.activate(bindings).await?);
1767        }
1768        Ok(handles)
1769    }
1770}
1771
1772impl Default for ConnectorRegistry {
1773    fn default() -> Self {
1774        Self::with_defaults()
1775    }
1776}
1777
1778struct PlaceholderConnector {
1779    provider_id: ProviderId,
1780    kinds: Vec<TriggerKind>,
1781    schema_name: String,
1782}
1783
1784impl PlaceholderConnector {
1785    fn from_metadata(metadata: &ProviderMetadata) -> Self {
1786        Self {
1787            provider_id: ProviderId::from(metadata.provider.clone()),
1788            kinds: metadata
1789                .kinds
1790                .iter()
1791                .cloned()
1792                .map(TriggerKind::from)
1793                .collect(),
1794            schema_name: metadata.schema_name.clone(),
1795        }
1796    }
1797}
1798
1799struct PlaceholderClient;
1800
1801#[async_trait]
1802impl ConnectorClient for PlaceholderClient {
1803    async fn call(&self, method: &str, _args: JsonValue) -> Result<JsonValue, ClientError> {
1804        Err(ClientError::Other(format!(
1805            "connector client method '{method}' is not implemented for this provider"
1806        )))
1807    }
1808}
1809
1810#[async_trait]
1811impl Connector for PlaceholderConnector {
1812    fn provider_id(&self) -> &ProviderId {
1813        &self.provider_id
1814    }
1815
1816    fn kinds(&self) -> &[TriggerKind] {
1817        &self.kinds
1818    }
1819
1820    async fn init(&mut self, _ctx: ConnectorCtx) -> Result<(), ConnectorError> {
1821        Ok(())
1822    }
1823
1824    async fn activate(
1825        &self,
1826        bindings: &[TriggerBinding],
1827    ) -> Result<ActivationHandle, ConnectorError> {
1828        Ok(ActivationHandle::new(
1829            self.provider_id.clone(),
1830            bindings.len(),
1831        ))
1832    }
1833
1834    async fn normalize_inbound(&self, _raw: RawInbound) -> Result<TriggerEvent, ConnectorError> {
1835        Err(ConnectorError::Unsupported(format!(
1836            "provider '{}' is cataloged but does not have a concrete inbound connector yet",
1837            self.provider_id.as_str()
1838        )))
1839    }
1840
1841    fn payload_schema(&self) -> ProviderPayloadSchema {
1842        ProviderPayloadSchema::named(self.schema_name.clone())
1843    }
1844
1845    fn client(&self) -> Arc<dyn ConnectorClient> {
1846        Arc::new(PlaceholderClient)
1847    }
1848}
1849
1850pub fn install_active_connector_clients(clients: BTreeMap<ProviderId, Arc<dyn ConnectorClient>>) {
1851    ACTIVE_CONNECTOR_CLIENTS.with(|slot| {
1852        *slot.borrow_mut() = clients
1853            .into_iter()
1854            .map(|(provider, client)| (provider.as_str().to_string(), client))
1855            .collect();
1856    });
1857}
1858
1859pub fn active_connector_client(provider: &str) -> Option<Arc<dyn ConnectorClient>> {
1860    ACTIVE_CONNECTOR_CLIENTS.with(|slot| slot.borrow().get(provider).cloned())
1861}
1862
1863pub fn clear_active_connector_clients() {
1864    ACTIVE_CONNECTOR_CLIENTS.with(|slot| slot.borrow_mut().clear());
1865}
1866
1867#[cfg(test)]
1868mod tests {
1869    use super::*;
1870
1871    use std::sync::atomic::{AtomicUsize, Ordering};
1872
1873    use async_trait::async_trait;
1874    use serde_json::json;
1875
1876    struct NoopClient;
1877
1878    #[async_trait]
1879    impl ConnectorClient for NoopClient {
1880        async fn call(&self, method: &str, _args: JsonValue) -> Result<JsonValue, ClientError> {
1881            Ok(json!({ "method": method }))
1882        }
1883    }
1884
1885    struct FakeConnector {
1886        provider_id: ProviderId,
1887        kinds: Vec<TriggerKind>,
1888        activate_calls: Arc<AtomicUsize>,
1889    }
1890
1891    impl FakeConnector {
1892        fn new(provider_id: &str, activate_calls: Arc<AtomicUsize>) -> Self {
1893            Self {
1894                provider_id: ProviderId::from(provider_id),
1895                kinds: vec![TriggerKind::from("webhook")],
1896                activate_calls,
1897            }
1898        }
1899    }
1900
1901    #[async_trait]
1902    impl Connector for FakeConnector {
1903        fn provider_id(&self) -> &ProviderId {
1904            &self.provider_id
1905        }
1906
1907        fn kinds(&self) -> &[TriggerKind] {
1908            &self.kinds
1909        }
1910
1911        async fn init(&mut self, _ctx: ConnectorCtx) -> Result<(), ConnectorError> {
1912            Ok(())
1913        }
1914
1915        async fn activate(
1916            &self,
1917            bindings: &[TriggerBinding],
1918        ) -> Result<ActivationHandle, ConnectorError> {
1919            self.activate_calls.fetch_add(1, Ordering::SeqCst);
1920            Ok(ActivationHandle::new(
1921                self.provider_id.clone(),
1922                bindings.len(),
1923            ))
1924        }
1925
1926        async fn normalize_inbound(
1927            &self,
1928            _raw: RawInbound,
1929        ) -> Result<TriggerEvent, ConnectorError> {
1930            Err(ConnectorError::Unsupported(
1931                "not needed for registry tests".to_string(),
1932            ))
1933        }
1934
1935        fn payload_schema(&self) -> ProviderPayloadSchema {
1936            ProviderPayloadSchema::named("FakePayload")
1937        }
1938
1939        fn client(&self) -> Arc<dyn ConnectorClient> {
1940            Arc::new(NoopClient)
1941        }
1942    }
1943
1944    #[tokio::test]
1945    async fn connector_registry_rejects_duplicate_providers() {
1946        let activate_calls = Arc::new(AtomicUsize::new(0));
1947        let mut registry = ConnectorRegistry::empty();
1948        registry
1949            .register(Box::new(FakeConnector::new(
1950                "github",
1951                activate_calls.clone(),
1952            )))
1953            .unwrap();
1954
1955        let error = registry
1956            .register(Box::new(FakeConnector::new("github", activate_calls)))
1957            .unwrap_err();
1958        assert!(matches!(
1959            error,
1960            ConnectorError::DuplicateProvider(provider) if provider == "github"
1961        ));
1962    }
1963
1964    #[tokio::test]
1965    async fn connector_registry_activates_only_bound_connectors() {
1966        let github_calls = Arc::new(AtomicUsize::new(0));
1967        let slack_calls = Arc::new(AtomicUsize::new(0));
1968        let mut registry = ConnectorRegistry::empty();
1969        registry
1970            .register(Box::new(FakeConnector::new("github", github_calls.clone())))
1971            .unwrap();
1972        registry
1973            .register(Box::new(FakeConnector::new("slack", slack_calls.clone())))
1974            .unwrap();
1975
1976        let mut trigger_registry = TriggerRegistry::default();
1977        trigger_registry.register(TriggerBinding::new(
1978            ProviderId::from("github"),
1979            "webhook",
1980            "github.push",
1981        ));
1982        trigger_registry.register(TriggerBinding::new(
1983            ProviderId::from("github"),
1984            "webhook",
1985            "github.installation",
1986        ));
1987
1988        let handles = registry.activate_all(&trigger_registry).await.unwrap();
1989        assert_eq!(handles.len(), 1);
1990        assert_eq!(handles[0].provider.as_str(), "github");
1991        assert_eq!(handles[0].binding_count, 2);
1992        assert_eq!(github_calls.load(Ordering::SeqCst), 1);
1993        assert_eq!(slack_calls.load(Ordering::SeqCst), 0);
1994    }
1995
1996    #[test]
1997    fn rate_limiter_scopes_tokens_by_provider_and_key() {
1998        let factory = RateLimiterFactory::new(RateLimitConfig {
1999            capacity: 1,
2000            refill_tokens: 1,
2001            refill_interval: StdDuration::from_mins(1),
2002        });
2003
2004        assert!(factory.try_acquire(&ProviderId::from("github"), "org:1"));
2005        assert!(!factory.try_acquire(&ProviderId::from("github"), "org:1"));
2006        assert!(factory.try_acquire(&ProviderId::from("github"), "org:2"));
2007        assert!(factory.try_acquire(&ProviderId::from("slack"), "org:1"));
2008    }
2009
2010    #[test]
2011    fn raw_inbound_json_body_preserves_raw_bytes() {
2012        let raw = RawInbound::new(
2013            "push",
2014            BTreeMap::from([("Content-Type".to_string(), "application/json".to_string())]),
2015            br#"{"ok":true}"#.to_vec(),
2016        );
2017
2018        assert_eq!(raw.json_body().unwrap(), json!({ "ok": true }));
2019    }
2020
2021    #[test]
2022    fn connector_registry_lists_core_catalog_providers() {
2023        let registry = ConnectorRegistry::default();
2024        let providers = registry.list();
2025        assert!(providers.contains(&ProviderId::from("cron")));
2026        assert!(providers.contains(&ProviderId::from("webhook")));
2027        assert!(providers.contains(&ProviderId::from("kafka")));
2028        assert!(!providers.contains(&ProviderId::from("github")));
2029        assert!(!providers.contains(&ProviderId::from("slack")));
2030    }
2031
2032    #[test]
2033    fn pure_harn_pivot_only_keeps_core_builtin_connectors() {
2034        let core_runtime_providers = [
2035            "a2a-push",
2036            "cron",
2037            "email",
2038            "kafka",
2039            "nats",
2040            "postgres-cdc",
2041            "pulsar",
2042            "webhook",
2043            "websocket",
2044        ];
2045
2046        for provider in registered_provider_metadata() {
2047            if !matches!(provider.runtime, ProviderRuntimeMetadata::Builtin { .. }) {
2048                continue;
2049            }
2050
2051            let allowed_core = core_runtime_providers.contains(&provider.provider.as_str());
2052            assert!(
2053                allowed_core,
2054                "provider '{}' is registered as a Rust builtin connector; new service connectors \
2055                 must ship as pure-Harn packages and register with connector = {{ harn = \"...\" }}",
2056                provider.provider
2057            );
2058        }
2059    }
2060
2061    #[test]
2062    fn metrics_registry_exports_orchestrator_metric_families() {
2063        let metrics = MetricsRegistry::default();
2064        metrics.record_http_request(
2065            "/triggers/github",
2066            "POST",
2067            200,
2068            StdDuration::from_millis(25),
2069            512,
2070        );
2071        metrics.record_trigger_received("github-new-issue", "github");
2072        metrics.record_trigger_deduped("github-new-issue", "inbox_duplicate");
2073        metrics.record_trigger_predicate_evaluation("github-new-issue", true, 0.002);
2074        metrics.record_trigger_dispatched("github-new-issue", "local", "succeeded");
2075        metrics.record_trigger_retry("github-new-issue", 2);
2076        metrics.record_trigger_dlq("github-new-issue", "retry_exhausted");
2077        metrics.set_trigger_inflight("github-new-issue", 0);
2078        metrics.record_event_log_append(
2079            "orchestrator.triggers.pending",
2080            StdDuration::from_millis(1),
2081            2048,
2082        );
2083        metrics.set_event_log_consumer_lag("orchestrator.triggers.pending", "orchestrator-pump", 0);
2084        metrics.set_trigger_budget_cost_today("github-new-issue", 0.002);
2085        metrics.record_trigger_budget_exhausted("github-new-issue", "daily_budget_exceeded");
2086        metrics.record_a2a_hop("agent.example", "succeeded", StdDuration::from_millis(10));
2087        metrics.set_worker_queue_depth("triage", 1);
2088        metrics.record_worker_queue_claim_age("triage", 3.0);
2089        metrics.set_orchestrator_pump_backlog("trigger.inbox.envelopes", 2);
2090        metrics.set_orchestrator_pump_outstanding("trigger.inbox.envelopes", 1);
2091        metrics.record_orchestrator_pump_admission_delay(
2092            "trigger.inbox.envelopes",
2093            StdDuration::from_millis(50),
2094        );
2095        metrics.record_trigger_accepted_to_normalized(
2096            "github-new-issue",
2097            "github-new-issue@v7",
2098            "github",
2099            Some("tenant-a"),
2100            "normalized",
2101            StdDuration::from_millis(25),
2102        );
2103        metrics.record_trigger_accepted_to_queue_append(
2104            "github-new-issue",
2105            "github-new-issue@v7",
2106            "github",
2107            Some("tenant-a"),
2108            "queued",
2109            StdDuration::from_millis(40),
2110        );
2111        metrics.record_trigger_queue_age_at_dispatch_admission(
2112            "github-new-issue",
2113            "github-new-issue@v7",
2114            "github",
2115            Some("tenant-a"),
2116            "admitted",
2117            StdDuration::from_millis(75),
2118        );
2119        metrics.record_trigger_queue_age_at_dispatch_start(
2120            "github-new-issue",
2121            "github-new-issue@v7",
2122            "github",
2123            Some("tenant-a"),
2124            "started",
2125            StdDuration::from_millis(125),
2126        );
2127        metrics.record_trigger_dispatch_runtime(
2128            "github-new-issue",
2129            "github-new-issue@v7",
2130            "github",
2131            Some("tenant-a"),
2132            "succeeded",
2133            StdDuration::from_millis(250),
2134        );
2135        metrics.record_trigger_retry_delay(
2136            "github-new-issue",
2137            "github-new-issue@v7",
2138            "github",
2139            Some("tenant-a"),
2140            "scheduled",
2141            StdDuration::from_secs(2),
2142        );
2143        metrics.record_trigger_accepted_to_dlq(
2144            "github-new-issue",
2145            "github-new-issue@v7",
2146            "github",
2147            Some("tenant-a"),
2148            "retry_exhausted",
2149            StdDuration::from_secs(45),
2150        );
2151        metrics.record_backpressure_event("ingest", "reject");
2152        metrics.note_trigger_pending_event(
2153            "evt-1",
2154            "github-new-issue",
2155            "github-new-issue@v7",
2156            "github",
2157            Some("tenant-a"),
2158            1_000,
2159            4_000,
2160        );
2161        metrics.record_llm_call("mock", "mock", "succeeded", 0.01);
2162        metrics.record_llm_cache_hit("mock");
2163
2164        let rendered = metrics.render_prometheus();
2165        for needle in [
2166            "harn_http_requests_total{endpoint=\"/triggers/github\",method=\"POST\",status=\"200\"} 1",
2167            "harn_http_request_duration_seconds_bucket{endpoint=\"/triggers/github\",le=\"0.05\"} 1",
2168            "harn_http_body_size_bytes_bucket{endpoint=\"/triggers/github\",le=\"512\"} 1",
2169            "harn_trigger_received_total{provider=\"github\",trigger_id=\"github-new-issue\"} 1",
2170            "harn_trigger_deduped_total{reason=\"inbox_duplicate\",trigger_id=\"github-new-issue\"} 1",
2171            "harn_trigger_predicate_evaluations_total{result=\"true\",trigger_id=\"github-new-issue\"} 1",
2172            "harn_trigger_predicate_cost_usd_bucket{le=\"0.01\",trigger_id=\"github-new-issue\"} 1",
2173            "harn_trigger_dispatched_total{handler_kind=\"local\",outcome=\"succeeded\",trigger_id=\"github-new-issue\"} 1",
2174            "harn_trigger_retries_total{attempt=\"2\",trigger_id=\"github-new-issue\"} 1",
2175            "harn_trigger_dlq_total{reason=\"retry_exhausted\",trigger_id=\"github-new-issue\"} 1",
2176            "harn_trigger_inflight{trigger_id=\"github-new-issue\"} 0",
2177            "harn_event_log_append_duration_seconds_bucket{le=\"0.005\",topic=\"orchestrator.triggers.pending\"} 1",
2178            "harn_event_log_topic_size_bytes{topic=\"orchestrator.triggers.pending\"} 2048",
2179            "harn_event_log_consumer_lag{consumer=\"orchestrator-pump\",topic=\"orchestrator.triggers.pending\"} 0",
2180            "harn_trigger_budget_cost_today_usd{trigger_id=\"github-new-issue\"} 0.002",
2181            "harn_trigger_budget_exhausted_total{strategy=\"daily_budget_exceeded\",trigger_id=\"github-new-issue\"} 1",
2182            "harn_backpressure_events_total{action=\"reject\",dimension=\"ingest\"} 1",
2183            "harn_a2a_hops_total{outcome=\"succeeded\",target=\"agent.example\"} 1",
2184            "harn_a2a_hop_duration_seconds_bucket{le=\"0.01\",target=\"agent.example\"} 1",
2185            "harn_worker_queue_depth{queue=\"triage\"} 1",
2186            "harn_worker_queue_claim_age_seconds_bucket{le=\"5\",queue=\"triage\"} 1",
2187            "harn_orchestrator_pump_backlog{topic=\"trigger.inbox.envelopes\"} 2",
2188            "harn_orchestrator_pump_outstanding{topic=\"trigger.inbox.envelopes\"} 1",
2189            "harn_orchestrator_pump_admission_delay_seconds_bucket{le=\"0.05\",topic=\"trigger.inbox.envelopes\"} 1",
2190            "harn_trigger_webhook_accepted_to_normalized_seconds_bucket{binding_key=\"github-new-issue@v7\",le=\"0.025\",provider=\"github\",status=\"normalized\",tenant_id=\"tenant-a\",trigger_id=\"github-new-issue\"} 1",
2191            "harn_trigger_webhook_accepted_to_queue_append_seconds_bucket{binding_key=\"github-new-issue@v7\",le=\"0.05\",provider=\"github\",status=\"queued\",tenant_id=\"tenant-a\",trigger_id=\"github-new-issue\"} 1",
2192            "harn_trigger_queue_age_at_dispatch_admission_seconds_bucket{binding_key=\"github-new-issue@v7\",le=\"0.1\",provider=\"github\",status=\"admitted\",tenant_id=\"tenant-a\",trigger_id=\"github-new-issue\"} 1",
2193            "harn_trigger_queue_age_at_dispatch_start_seconds_bucket{binding_key=\"github-new-issue@v7\",le=\"0.25\",provider=\"github\",status=\"started\",tenant_id=\"tenant-a\",trigger_id=\"github-new-issue\"} 1",
2194            "harn_trigger_dispatch_runtime_seconds_bucket{binding_key=\"github-new-issue@v7\",le=\"0.25\",provider=\"github\",status=\"succeeded\",tenant_id=\"tenant-a\",trigger_id=\"github-new-issue\"} 1",
2195            "harn_trigger_retry_delay_seconds_bucket{binding_key=\"github-new-issue@v7\",le=\"2.5\",provider=\"github\",status=\"scheduled\",tenant_id=\"tenant-a\",trigger_id=\"github-new-issue\"} 1",
2196            "harn_trigger_accepted_to_dlq_seconds_bucket{binding_key=\"github-new-issue@v7\",le=\"60\",provider=\"github\",status=\"retry_exhausted\",tenant_id=\"tenant-a\",trigger_id=\"github-new-issue\"} 1",
2197            "harn_trigger_oldest_pending_age_seconds{binding_key=\"github-new-issue@v7\",provider=\"github\",tenant_id=\"tenant-a\",trigger_id=\"github-new-issue\"} 3",
2198            "harn_llm_calls_total{model=\"mock\",outcome=\"succeeded\",provider=\"mock\"} 1",
2199            "harn_llm_cost_usd_total{model=\"mock\",provider=\"mock\"} 0.01",
2200            "harn_llm_cache_hits_total{provider=\"mock\"} 1",
2201        ] {
2202            assert!(rendered.contains(needle), "missing {needle}\n{rendered}");
2203        }
2204    }
2205}