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