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