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