Skip to main content

harn_vm/connectors/
mod.rs

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