dwara_core/extensions/analytics.rs
1//! Analytics sink extension point.
2//!
3//! # Contract: [`AnalyticsSink`]
4//!
5//! **Purpose:** accept request/flow events emitted by the dataplane. The
6//! sink is fire-and-forget from the caller's perspective.
7//!
8//! **Semantics:** `record` appends one event and must not block the caller
9//! beyond a bounded enqueue. Implementations bound themselves (ring buffer,
10//! batch, drop-oldest) — an analytics overload must never stall the
11//! dataplane. Ordering is best-effort; events may be coalesced or dropped
12//! under pressure, and `record` returning `Ok` means "accepted", not
13//! "durably persisted". Events must not contain secret material.
14//!
15//! **Failure model:** only enqueue failures (closed channel, full local
16//! buffer) produce [`ExtensionsError::Backend`]; call sites should log and
17//! continue, never fail the request. No retries.
18//!
19//! **Editions:** OSS ships [`InMemoryAnalyticsSink`] (bounded ring buffer)
20//! and the DW-043 embedded store (`analytics::EmbeddedAnalytics`, SQLite
21//! rollups), which implements this contract on top of the richer
22//! per-request fields below. Ent planned: federated sink + warehouse
23//! export (DW-095). The raw-record firehose (DW-121) deliberately does
24//! NOT implement this contract: it streams the access record itself
25//! (with `request_id` and the redacted path this event shape omits)
26//! through its own sink seam — see `events::stream`.
27
28use std::collections::VecDeque;
29use std::sync::Mutex;
30
31use async_trait::async_trait;
32
33use super::ExtensionsError;
34
35/// A single analytics event.
36///
37/// Deliberately minimal and extensible: new fields are additive. `kind`
38/// selects the event type (e.g. `request`); optional route/consumer
39/// identifiers carry the common correlation keys, and `attributes` holds
40/// everything else as string pairs to avoid a schema lock-in at M1. The
41/// DW-043 request-completion fields (listener, method, duration,
42/// outcomes) were added additively so the embedded store consumes this
43/// type directly; `attributes` carries custom analytics dimensions.
44#[derive(Debug, Clone, PartialEq)]
45#[non_exhaustive]
46pub struct Event {
47 /// Event type discriminator, e.g. `request`.
48 pub kind: String,
49 /// Unix epoch milliseconds.
50 pub timestamp_ms: u64,
51 /// Route name, when the event is tied to a matched route.
52 pub route: Option<String>,
53 /// Consumer name, when authenticated.
54 pub consumer: Option<String>,
55 /// Upstream endpoint the request was forwarded to, if any.
56 pub endpoint: Option<String>,
57 /// Upstream response status code, if a response was produced.
58 pub status: Option<u16>,
59 /// Listener that accepted the request (DW-043).
60 pub listener: Option<String>,
61 /// HTTP method (DW-043).
62 pub method: Option<String>,
63 /// End-to-end request duration in milliseconds (DW-043).
64 pub duration_ms: Option<f64>,
65 /// Upstream attempts made (DW-043).
66 pub attempts: Option<u32>,
67 /// The request was rejected by a rate limit (DW-043).
68 pub rate_limited: bool,
69 /// The request failed through an open breaker (DW-043).
70 pub broken: bool,
71 /// The request was shed by admission control (DW-043).
72 pub shed: bool,
73 /// The edge instance ID this event originated from (SCALE-07,
74 /// #186). `None` for single-process (OSS) deployments; set by
75 /// the controller's `EmbeddedCollector` when forwarding federated
76 /// events from edges to the aggregate store.
77 pub edge_id: Option<String>,
78 /// Additional string key/value data (custom dimensions ride here).
79 pub attributes: Vec<(String, String)>,
80}
81
82impl Event {
83 /// Build a `request` event stamped with the current time.
84 pub fn request_now() -> Self {
85 Self {
86 kind: "request".to_owned(),
87 timestamp_ms: std::time::SystemTime::now()
88 .duration_since(std::time::UNIX_EPOCH)
89 .map(|d| d.as_millis() as u64)
90 .unwrap_or(0),
91 route: None,
92 consumer: None,
93 endpoint: None,
94 status: None,
95 listener: None,
96 method: None,
97 duration_ms: None,
98 attempts: None,
99 rate_limited: false,
100 broken: false,
101 shed: false,
102 edge_id: None,
103 attributes: Vec::new(),
104 }
105 }
106}
107
108/// Swappable analytics event sink.
109#[async_trait]
110pub trait AnalyticsSink: Send + Sync {
111 /// Accept one event for eventual processing.
112 async fn record(&self, event: Event) -> Result<(), ExtensionsError>;
113}
114
115/// Bounded in-memory ring-buffer sink (OSS skeleton).
116///
117/// Keeps the most recent `capacity` events; `record` on a full buffer drops
118/// the oldest entry. DW-021 replaces the consumer side, not this contract.
119#[derive(Debug)]
120pub struct InMemoryAnalyticsSink {
121 events: Mutex<VecDeque<Event>>,
122 capacity: usize,
123}
124
125impl InMemoryAnalyticsSink {
126 /// New sink retaining the most recent `capacity` events.
127 pub fn new(capacity: usize) -> Self {
128 Self {
129 events: Mutex::new(VecDeque::with_capacity(capacity)),
130 capacity,
131 }
132 }
133
134 /// Snapshot the currently retained events, oldest first.
135 pub fn events(&self) -> Vec<Event> {
136 self.events
137 .lock()
138 .expect("analytics state poisoned")
139 .clone()
140 .into()
141 }
142}
143
144#[async_trait]
145impl AnalyticsSink for InMemoryAnalyticsSink {
146 async fn record(&self, event: Event) -> Result<(), ExtensionsError> {
147 let mut events = self.events.lock().expect("analytics state poisoned");
148 if events.len() == self.capacity {
149 events.pop_front();
150 }
151 events.push_back(event);
152 Ok(())
153 }
154}