feat(saas): expose redacted operational signals
req: operations/003 req: operations/005 req: operations/007 req: security/008 req: v1_release/003
This commit is contained in:
@@ -13,7 +13,9 @@ use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use ui::dashboard;
|
||||
|
||||
@@ -172,6 +174,16 @@ impl LocalProjectStore {
|
||||
pub fn list(&self) -> Vec<ProjectRecord> {
|
||||
self.projects.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn ready(&self) -> bool {
|
||||
let Some(path) = self.path.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
if path.exists() && !path.is_file() {
|
||||
return false;
|
||||
}
|
||||
path.parent().unwrap_or_else(|| Path::new(".")).is_dir()
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_projects(path: &Path, projects: &[ProjectRecord]) -> io::Result<()> {
|
||||
@@ -192,10 +204,44 @@ fn persist_projects(path: &Path, projects: &[ProjectRecord]) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MutationDiagnostic {
|
||||
pub outcome: &'static str,
|
||||
pub duration_micros: u64,
|
||||
}
|
||||
|
||||
pub trait DiagnosticSink: Send + Sync {
|
||||
fn record(&self, diagnostic: MutationDiagnostic);
|
||||
}
|
||||
|
||||
struct StderrDiagnosticSink;
|
||||
|
||||
impl DiagnosticSink for StderrDiagnosticSink {
|
||||
fn record(&self, diagnostic: MutationDiagnostic) {
|
||||
eprintln!(
|
||||
"event=saas.project_mutation outcome={} duration_micros={}",
|
||||
diagnostic.outcome, diagnostic.duration_micros
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MutationMetrics {
|
||||
attempts: AtomicU64,
|
||||
succeeded: AtomicU64,
|
||||
denied: AtomicU64,
|
||||
invalid: AtomicU64,
|
||||
mismatch: AtomicU64,
|
||||
failed: AtomicU64,
|
||||
duration_micros: AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppContext {
|
||||
session: Session,
|
||||
store: LocalProjectStore,
|
||||
metrics: Arc<MutationMetrics>,
|
||||
diagnostics: Arc<dyn DiagnosticSink>,
|
||||
}
|
||||
|
||||
impl AppContext {
|
||||
@@ -203,6 +249,8 @@ impl AppContext {
|
||||
Self {
|
||||
session: Session::demo(),
|
||||
store: LocalProjectStore::default(),
|
||||
metrics: Arc::default(),
|
||||
diagnostics: Arc::new(StderrDiagnosticSink),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +260,8 @@ impl AppContext {
|
||||
Ok(Self {
|
||||
session,
|
||||
store: LocalProjectStore::durable(path)?,
|
||||
metrics: Arc::default(),
|
||||
diagnostics: Arc::new(StderrDiagnosticSink),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,6 +291,48 @@ impl AppContext {
|
||||
self.store.list()
|
||||
}
|
||||
|
||||
pub fn ready(&self) -> bool {
|
||||
self.store.ready()
|
||||
}
|
||||
|
||||
pub fn with_diagnostic_sink(mut self, diagnostics: Arc<dyn DiagnosticSink>) -> Self {
|
||||
self.diagnostics = diagnostics;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn record_mutation(&self, outcome: &'static str, duration: Duration) {
|
||||
self.metrics.attempts.fetch_add(1, Ordering::Relaxed);
|
||||
match outcome {
|
||||
"succeeded" => &self.metrics.succeeded,
|
||||
"denied" => &self.metrics.denied,
|
||||
"invalid" => &self.metrics.invalid,
|
||||
"mismatch" => &self.metrics.mismatch,
|
||||
_ => &self.metrics.failed,
|
||||
}
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
let duration_micros = duration.as_micros().min(u128::from(u64::MAX)) as u64;
|
||||
self.metrics
|
||||
.duration_micros
|
||||
.fetch_add(duration_micros, Ordering::Relaxed);
|
||||
self.diagnostics.record(MutationDiagnostic {
|
||||
outcome,
|
||||
duration_micros,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn metrics_json(&self) -> String {
|
||||
format!(
|
||||
"{{\"project_mutation\":{{\"attempts\":{},\"succeeded\":{},\"denied\":{},\"invalid\":{},\"mismatch\":{},\"failed\":{},\"duration_micros\":{}}}}}",
|
||||
self.metrics.attempts.load(Ordering::Relaxed),
|
||||
self.metrics.succeeded.load(Ordering::Relaxed),
|
||||
self.metrics.denied.load(Ordering::Relaxed),
|
||||
self.metrics.invalid.load(Ordering::Relaxed),
|
||||
self.metrics.mismatch.load(Ordering::Relaxed),
|
||||
self.metrics.failed.load(Ordering::Relaxed),
|
||||
self.metrics.duration_micros.load(Ordering::Relaxed),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_project_authorized(
|
||||
&self,
|
||||
name: &str,
|
||||
@@ -477,6 +569,28 @@ mod tests {
|
||||
Selector::parse(value).expect("test selector parses")
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingDiagnostics(Mutex<Vec<MutationDiagnostic>>);
|
||||
|
||||
impl DiagnosticSink for RecordingDiagnostics {
|
||||
fn record(&self, diagnostic: MutationDiagnostic) {
|
||||
self.0.lock().unwrap().push(diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutation_diagnostics_are_structured_and_cannot_carry_request_secrets() {
|
||||
// req: operations/003 req: operations/005 req: security/008
|
||||
let diagnostics = Arc::new(RecordingDiagnostics::default());
|
||||
let ctx = AppContext::demo().with_diagnostic_sink(diagnostics.clone());
|
||||
ctx.record_mutation("denied", Duration::from_micros(7));
|
||||
|
||||
let recorded = diagnostics.0.lock().unwrap();
|
||||
assert_eq!(recorded.len(), 1);
|
||||
assert_eq!(recorded[0].outcome, "denied");
|
||||
assert_eq!(recorded[0].duration_micros, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_page_documents_the_production_app_boundaries() {
|
||||
// req: examples/001 req: auth/001 req: auth/004 req: interop/003
|
||||
|
||||
Reference in New Issue
Block a user