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:
slhx agent
2026-07-14 00:48:55 +02:00
parent ef8e38adf8
commit 4e0d9ebaff
5 changed files with 300 additions and 22 deletions
+114
View File
@@ -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
+87 -10
View File
@@ -11,6 +11,7 @@ use hemx_saas_example::{home_page, live_status, registry, settings_page, ui, App
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::path::PathBuf;
use std::time::Instant;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -29,6 +30,9 @@ fn app(ctx: AppContext) -> Router {
.route("/", get(home).post(interact))
.route("/settings", get(settings))
.route("/projects", post(create_project))
.route("/health/live", get(health_live))
.route("/health/ready", get(health_ready))
.route("/metrics", get(metrics))
.route("/events", get(events))
.route(runtime_js_path(), get(runtime))
.route("/app.css", get(css))
@@ -75,6 +79,7 @@ async fn create_project(
headers: HeaderMap,
Form(form): Form<BTreeMap<String, String>>,
) -> Response {
let started = Instant::now();
let bearer = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
@@ -85,18 +90,90 @@ async fn create_project(
.unwrap_or_default();
let name = form.get("name").map(String::as_str).unwrap_or_default();
let csrf = form.get("csrf").map(String::as_str).unwrap_or_default();
match ctx.create_project_authorized(name, bearer, csrf, origin) {
Ok(_) => (StatusCode::SEE_OTHER, [("location", "/")], "").into_response(),
Err(
error @ (hemx_saas_example::AppError::MissingSession
| hemx_saas_example::AppError::CsrfRejected
| hemx_saas_example::AppError::OriginRejected),
) => (StatusCode::FORBIDDEN, error.to_string()).into_response(),
Err(error @ hemx_saas_example::AppError::Validation(_)) => {
(StatusCode::BAD_REQUEST, error.to_string()).into_response()
if let Some(client_fingerprint) = headers
.get("x-hemx-fingerprint")
.and_then(|value| value.to_str().ok())
{
let current_fingerprint = ui::BUILD_FINGERPRINT.0.to_string();
if client_fingerprint != current_fingerprint {
ctx.record_mutation("mismatch", started.elapsed());
return Response::builder()
.status(StatusCode::CONFLICT)
.header("content-type", "application/problem+json")
.header("x-hemx-recovery", "reload")
.header("x-hemx-fingerprint", current_fingerprint)
.body(Body::from("{\"code\":\"deployment-mismatch\"}"))
.expect("deployment mismatch response");
}
Err(error) => (StatusCode::SERVICE_UNAVAILABLE, error.to_string()).into_response(),
}
let (outcome, response) = match ctx.create_project_authorized(name, bearer, csrf, origin) {
Ok(_) => (
"succeeded",
(StatusCode::SEE_OTHER, [("location", "/")], "").into_response(),
),
Err(
hemx_saas_example::AppError::MissingSession
| hemx_saas_example::AppError::CsrfRejected
| hemx_saas_example::AppError::OriginRejected,
) => (
"denied",
problem(StatusCode::FORBIDDEN, "authorization-denied"),
),
Err(hemx_saas_example::AppError::Validation(_)) => (
"invalid",
problem(StatusCode::BAD_REQUEST, "invalid-project"),
),
Err(_) => (
"failed",
problem(StatusCode::SERVICE_UNAVAILABLE, "storage-unavailable"),
),
};
ctx.record_mutation(outcome, started.elapsed());
response
}
fn problem(status: StatusCode, code: &'static str) -> Response {
Response::builder()
.status(status)
.header("content-type", "application/problem+json")
.body(Body::from(format!("{{\"code\":\"{code}\"}}")))
.expect("problem response")
}
// req: operations/007
async fn health_live() -> Response {
json_response(StatusCode::OK, "{\"status\":\"live\"}".to_owned())
}
// req: operations/007
async fn health_ready(State(ctx): State<AppContext>) -> Response {
if ctx.ready() {
json_response(
StatusCode::OK,
format!(
"{{\"status\":\"ready\",\"fingerprint\":\"{}\"}}",
ui::BUILD_FINGERPRINT.0
),
)
} else {
json_response(
StatusCode::SERVICE_UNAVAILABLE,
"{\"status\":\"not-ready\",\"code\":\"storage-unavailable\"}".to_owned(),
)
}
}
// req: operations/005 req: operations/007
async fn metrics(State(ctx): State<AppContext>) -> Response {
json_response(StatusCode::OK, ctx.metrics_json())
}
fn json_response(status: StatusCode, body: String) -> Response {
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Body::from(body))
.expect("JSON response")
}
async fn runtime() -> impl IntoResponse {