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