1use rusqlite::Connection;
7use std::path::Path;
8use thiserror::Error;
9use time::format_description::well_known::Rfc3339;
10use time::OffsetDateTime;
11use uuid::Uuid;
12
13use crate::envelope::CheckResult;
14
15pub const SCHEMA_VERSION: u32 = 2;
16
17pub const FAILOPEN_RULE: &str = "pushkin.failopen.malformed_input";
20
21pub const ESCALATION_RULE: &str = "pushkin.escalation";
24
25const MIGRATIONS: &[&str] = &[
29 "
30 CREATE TABLE IF NOT EXISTS schema_meta (
31 version INTEGER NOT NULL
32 );
33 CREATE TABLE IF NOT EXISTS events (
34 id INTEGER PRIMARY KEY AUTOINCREMENT,
35 session TEXT NOT NULL,
36 seq INTEGER NOT NULL,
37 ts TEXT NOT NULL, -- UTC ISO-8601 (RFC 3339)
38 decision TEXT NOT NULL,
39 rule TEXT,
40 file TEXT,
41 payload TEXT NOT NULL,
42 UNIQUE (session, seq)
43 );
44 CREATE TRIGGER IF NOT EXISTS events_no_update
45 BEFORE UPDATE ON events
46 BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
47 CREATE TRIGGER IF NOT EXISTS events_no_delete
48 BEFORE DELETE ON events
49 BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
50 ",
51 "
55 CREATE TABLE IF NOT EXISTS delivered_slices (
56 session TEXT NOT NULL,
57 cwd TEXT NOT NULL,
58 slice_key TEXT NOT NULL,
59 delivered_at_emission INTEGER NOT NULL,
60 PRIMARY KEY (session, cwd, slice_key)
61 );
62 CREATE TABLE IF NOT EXISTS delivery_counters (
63 session TEXT NOT NULL,
64 cwd TEXT NOT NULL,
65 emissions INTEGER NOT NULL,
66 PRIMARY KEY (session, cwd)
67 );
68 ",
69];
70
71#[derive(Debug, Error)]
72pub enum EventLogError {
73 #[error("event log storage error: {0}")]
74 Storage(#[from] rusqlite::Error),
75 #[error("event serialization error: {0}")]
76 Serialize(#[from] serde_json::Error),
77 #[error("timestamp formatting error: {0}")]
78 Timestamp(#[from] time::error::Format),
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct SessionId(String);
84
85impl SessionId {
86 #[must_use]
89 pub fn from_name(name: &str) -> Self {
90 Self(name.to_owned())
91 }
92
93 #[must_use]
94 pub fn as_str(&self) -> &str {
95 &self.0
96 }
97}
98
99#[derive(Debug)]
101pub struct StatsSummary {
102 pub blocks_by_rule: Vec<(String, u64)>,
103 pub compression_events: u64,
104 pub compression_saved_chars: u64,
105 pub nudge_arms: Vec<(String, u64)>,
106 pub failopen_events: u64,
107}
108
109#[derive(Debug)]
111pub struct Telemetry<'a> {
112 pub rule: &'a str,
113 pub payload: String,
114}
115
116#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct GateEvent {
120 pub session: String,
121 pub seq: u64,
122 pub ts: String,
123}
124
125pub struct EventLog {
126 conn: Connection,
127}
128
129impl EventLog {
130 pub fn open(path: impl AsRef<Path>) -> Result<Self, EventLogError> {
136 let conn = Connection::open(path)?;
137 apply_migrations(&conn)?;
138 Ok(Self { conn })
139 }
140
141 pub fn begin_session(&self) -> Result<SessionId, EventLogError> {
146 Ok(SessionId(Uuid::new_v4().to_string()))
147 }
148
149 pub fn append(
154 &self,
155 session: &SessionId,
156 result: &CheckResult,
157 ) -> Result<GateEvent, EventLogError> {
158 let rule = result.violations.first().map(|v| v.rule.clone());
159 let file = result.violations.first().map(|v| v.file.clone());
160 self.append_row(
161 session,
162 result.decision.as_str(),
163 rule,
164 file,
165 serde_json::to_string(result)?,
166 )
167 }
168
169 pub fn append_failopen(
174 &self,
175 session: &SessionId,
176 detail: &str,
177 ) -> Result<GateEvent, EventLogError> {
178 let payload = serde_json::json!({ "failopen": true, "detail": detail }).to_string();
179 self.append_row(
180 session,
181 "allow",
182 Some(FAILOPEN_RULE.to_owned()),
183 None,
184 payload,
185 )
186 }
187
188 pub fn append_escalation(
193 &self,
194 session: &SessionId,
195 detail: &str,
196 ) -> Result<GateEvent, EventLogError> {
197 let payload = serde_json::json!({ "escalation": true, "detail": detail }).to_string();
198 self.append_row(
199 session,
200 "block",
201 Some(ESCALATION_RULE.to_owned()),
202 None,
203 payload,
204 )
205 }
206
207 pub fn append_telemetry(
214 &self,
215 session: &SessionId,
216 telemetry: Telemetry<'_>,
217 ) -> Result<GateEvent, EventLogError> {
218 self.append_row(
219 session,
220 "telemetry",
221 Some(telemetry.rule.to_owned()),
222 None,
223 telemetry.payload,
224 )
225 }
226
227 pub fn attempts(
233 &self,
234 session: &SessionId,
235 rule: &str,
236 file: &str,
237 ) -> Result<u64, EventLogError> {
238 let count: u64 = self.conn.query_row(
239 "SELECT COUNT(*) FROM events
240 WHERE session = ?1 AND rule IN (?2, ?3) AND file = ?4",
241 (
242 session.as_str(),
243 rule,
244 crate::legacy::legacy_rule_id(rule).as_ref(),
245 file,
246 ),
247 |row| row.get(0),
248 )?;
249 Ok(count)
250 }
251
252 pub fn block_count(&self, session: &SessionId) -> Result<u64, EventLogError> {
257 let count: u64 = self.conn.query_row(
258 "SELECT COUNT(*) FROM events WHERE session = ?1 AND decision = 'block'",
259 [session.as_str()],
260 |row| row.get(0),
261 )?;
262 Ok(count)
263 }
264
265 pub fn rule_count(&self, session: &SessionId, rule: &str) -> Result<u64, EventLogError> {
270 let count: u64 = self.conn.query_row(
271 "SELECT COUNT(*) FROM events WHERE session = ?1 AND rule IN (?2, ?3)",
272 (
273 session.as_str(),
274 rule,
275 crate::legacy::legacy_rule_id(rule).as_ref(),
276 ),
277 |row| row.get(0),
278 )?;
279 Ok(count)
280 }
281
282 pub fn failopen_count(&self, session: &SessionId) -> Result<u64, EventLogError> {
287 let count: u64 = self.conn.query_row(
288 "SELECT COUNT(*) FROM events WHERE session = ?1 AND rule IN (?2, ?3)",
289 (
290 session.as_str(),
291 FAILOPEN_RULE,
292 crate::legacy::legacy_rule_id(FAILOPEN_RULE).as_ref(),
293 ),
294 |row| row.get(0),
295 )?;
296 Ok(count)
297 }
298
299 pub fn stats(&self) -> Result<StatsSummary, EventLogError> {
308 let mut blocks = self.conn.prepare(
309 "SELECT rule, COUNT(*) FROM events
310 WHERE decision = 'block' AND rule IS NOT NULL
311 GROUP BY rule ORDER BY COUNT(*) DESC, rule",
312 )?;
313 let raw_blocks = blocks
314 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
315 .collect::<Result<Vec<(String, u64)>, _>>()?;
316 let blocks_by_rule = group_modern(raw_blocks);
317 let (compression_events, compression_saved_chars): (u64, u64) = self.conn.query_row(
318 "SELECT COUNT(*), COALESCE(SUM(json_extract(payload, '$.saved_chars')), 0)
319 FROM events WHERE rule IN (?1, ?2)",
320 (
321 "pushkin.compression",
322 crate::legacy::legacy_rule_id("pushkin.compression").as_ref(),
323 ),
324 |row| Ok((row.get(0)?, row.get(1)?)),
325 )?;
326 let mut arms = self.conn.prepare(
327 "SELECT COALESCE(json_extract(payload, '$.arm'), 'unknown'), COUNT(*)
328 FROM events WHERE rule IN (?1, ?2) GROUP BY 1 ORDER BY 1",
329 )?;
330 let nudge_arms = arms
331 .query_map(
332 (
333 "pushkin.nudge",
334 crate::legacy::legacy_rule_id("pushkin.nudge").as_ref(),
335 ),
336 |row| Ok((row.get(0)?, row.get(1)?)),
337 )?
338 .collect::<Result<Vec<(String, u64)>, _>>()?;
339 let failopen_events: u64 = self.conn.query_row(
340 "SELECT COUNT(*) FROM events WHERE rule IN (?1, ?2)",
341 (
342 FAILOPEN_RULE,
343 crate::legacy::legacy_rule_id(FAILOPEN_RULE).as_ref(),
344 ),
345 |row| row.get(0),
346 )?;
347 Ok(StatsSummary {
348 blocks_by_rule,
349 compression_events,
350 compression_saved_chars,
351 nudge_arms,
352 failopen_events,
353 })
354 }
355
356 pub fn decision_counts(&self) -> Result<(u64, u64), EventLogError> {
363 let row = self.conn.query_row(
364 "SELECT
365 COUNT(*) FILTER (WHERE decision IN ('allow', 'block')),
366 COUNT(*) FILTER (WHERE decision = 'block')
367 FROM events WHERE rule IS NULL OR rule NOT IN (?1, ?2)",
368 (
369 ESCALATION_RULE,
370 crate::legacy::legacy_rule_id(ESCALATION_RULE).as_ref(),
371 ),
372 |row| Ok((row.get(0)?, row.get(1)?)),
373 )?;
374 Ok(row)
375 }
376
377 pub fn latest_session_escalated(&self) -> Result<bool, EventLogError> {
382 let escalated: u64 = self.conn.query_row(
383 "SELECT COUNT(*) FROM events
384 WHERE rule IN (?1, ?2) AND session =
385 (SELECT session FROM events ORDER BY id DESC LIMIT 1)",
386 (
387 ESCALATION_RULE,
388 crate::legacy::legacy_rule_id(ESCALATION_RULE).as_ref(),
389 ),
390 |row| row.get(0),
391 )?;
392 Ok(escalated > 0)
393 }
394
395 pub fn mean_attempts_to_compliance(&self) -> Result<Option<f64>, EventLogError> {
403 let mut statement = self.conn.prepare(
404 "SELECT session, decision FROM events
405 WHERE decision IN ('allow', 'block')
406 AND (rule IS NULL OR rule NOT IN (?1, ?2))
407 ORDER BY session, seq",
408 )?;
409 let rows = statement
410 .query_map(
411 (
412 ESCALATION_RULE,
413 crate::legacy::legacy_rule_id(ESCALATION_RULE).as_ref(),
414 ),
415 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
416 )?
417 .collect::<Result<Vec<_>, _>>()?;
418
419 let mut recoveries: Vec<u64> = Vec::new();
420 let mut current_session = String::new();
421 let mut open_blocks: u64 = 0;
422 for (session, decision) in rows {
423 if session != current_session {
424 current_session = session;
425 open_blocks = 0;
426 }
427 match decision.as_str() {
428 "block" => open_blocks += 1,
429 "allow" if open_blocks > 0 => {
430 recoveries.push(open_blocks + 1);
431 open_blocks = 0;
432 }
433 _ => {}
434 }
435 }
436 if recoveries.is_empty() {
437 return Ok(None);
438 }
439 #[allow(clippy::cast_precision_loss)]
440 let mean = recoveries.iter().sum::<u64>() as f64 / recoveries.len() as f64;
441 Ok(Some(mean))
442 }
443
444 pub fn schema_version(&self) -> Result<u32, EventLogError> {
449 let version: u32 = self
450 .conn
451 .query_row("SELECT version FROM schema_meta", [], |row| row.get(0))?;
452 Ok(version)
453 }
454
455 fn append_row(
456 &self,
457 session: &SessionId,
458 decision: &str,
459 rule: Option<String>,
460 file: Option<String>,
461 payload: String,
462 ) -> Result<GateEvent, EventLogError> {
463 let next_seq: u64 = self.conn.query_row(
464 "SELECT COALESCE(MAX(seq), 0) + 1 FROM events WHERE session = ?1",
465 [session.as_str()],
466 |row| row.get(0),
467 )?;
468 let ts = OffsetDateTime::now_utc().format(&Rfc3339)?;
469 self.conn.execute(
470 "INSERT INTO events (session, seq, ts, decision, rule, file, payload)
471 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
472 (
473 session.as_str(),
474 next_seq,
475 &ts,
476 decision,
477 rule,
478 file,
479 payload,
480 ),
481 )?;
482 Ok(GateEvent {
483 session: session.as_str().to_owned(),
484 seq: next_seq,
485 ts,
486 })
487 }
488}
489
490fn group_modern(rows: Vec<(String, u64)>) -> Vec<(String, u64)> {
494 let mut merged: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
495 for (rule, count) in rows {
496 *merged
497 .entry(crate::legacy::modern_rule_id(&rule).into_owned())
498 .or_insert(0) += count;
499 }
500 let mut grouped: Vec<(String, u64)> = merged.into_iter().collect();
501 grouped.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
502 grouped
503}
504
505pub(crate) fn apply_migrations(conn: &Connection) -> Result<(), EventLogError> {
506 let current: u32 = conn
507 .query_row("SELECT version FROM schema_meta", [], |row| row.get(0))
508 .unwrap_or(0);
509 for (index, step) in MIGRATIONS.iter().enumerate() {
510 let step_version = u32::try_from(index).unwrap_or(u32::MAX).saturating_add(1);
511 if step_version > current {
512 conn.execute_batch(step)?;
513 }
514 }
515 if current == 0 {
516 conn.execute(
517 "INSERT INTO schema_meta (version) VALUES (?1)",
518 [SCHEMA_VERSION],
519 )?;
520 } else if current < SCHEMA_VERSION {
521 conn.execute("UPDATE schema_meta SET version = ?1", [SCHEMA_VERSION])?;
524 }
525 Ok(())
526}