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