Aggregates the existing execution_logs table (no hot-path instrumentation):
ExecutionLogRepository::summarize_for_app returns counts, error rate, latency
avg/p50/p95 (percentile_cont), by-status/by-source breakdowns, and an hourly
series over a trailing window (clamped 1h..90d). New metrics_api router at
GET /api/v1/admin/apps/{id}/metrics?window= (AppLogRead), and a dashboard
Metrics tab (summary cards + an inline-SVG per-hour bar chart, no JS dep).
Pinned by a manager-core test asserting the exact rollup (percentiles included)
and the empty-app zero case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
151 lines
5.0 KiB
Rust
151 lines
5.0 KiB
Rust
//! A2: `ExecutionLogRepository::summarize_for_app` — the read model behind the
|
|
//! observability dashboard. Seeds a known set of `execution_logs` rows for one
|
|
//! app and asserts the rollup: totals, error rate, latency mean + percentiles,
|
|
//! the by-status / by-source breakdowns, and the hourly series. Scoped to a
|
|
//! unique `app_id`, so it is unaffected by any other rows in a shared DB.
|
|
//! Skips cleanly when `DATABASE_URL` is unset.
|
|
|
|
use picloud_manager_core::repo::{ExecutionLogRepository, PostgresExecutionLogRepository};
|
|
use picloud_shared::AppId;
|
|
use sqlx::postgres::PgPoolOptions;
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
async fn pool_or_skip() -> Option<PgPool> {
|
|
let Ok(url) = std::env::var("DATABASE_URL") else {
|
|
picloud_test_support::abort_if_db_required("metrics_summary");
|
|
eprintln!("metrics_summary: DATABASE_URL unset — skipping");
|
|
return None;
|
|
};
|
|
let pool = PgPoolOptions::new()
|
|
.max_connections(2)
|
|
.connect(&url)
|
|
.await
|
|
.expect("connect to DATABASE_URL");
|
|
sqlx::migrate!("./migrations")
|
|
.run(&pool)
|
|
.await
|
|
.expect("apply migrations");
|
|
Some(pool)
|
|
}
|
|
|
|
async fn make_app(pool: &PgPool) -> Uuid {
|
|
// `apps.group_id` is NOT NULL, so anchor the app under a fresh root group.
|
|
let gslug = format!("metrics-g-{}", Uuid::new_v4());
|
|
let group: (Uuid,) =
|
|
sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
|
|
.bind(&gslug)
|
|
.fetch_one(pool)
|
|
.await
|
|
.expect("group insert");
|
|
let slug = format!("metrics-{}", Uuid::new_v4());
|
|
let row: (Uuid,) =
|
|
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
|
|
.bind(&slug)
|
|
.bind(group.0)
|
|
.fetch_one(pool)
|
|
.await
|
|
.expect("app insert");
|
|
row.0
|
|
}
|
|
|
|
async fn seed(pool: &PgPool, app: Uuid, duration_ms: i32, status: &str, source: &str) {
|
|
sqlx::query(
|
|
"INSERT INTO execution_logs (app_id, request_id, duration_ms, status, source) \
|
|
VALUES ($1, gen_random_uuid(), $2, $3, $4)",
|
|
)
|
|
.bind(app)
|
|
.bind(duration_ms)
|
|
.bind(status)
|
|
.bind(source)
|
|
.execute(pool)
|
|
.await
|
|
.expect("seed execution_logs row");
|
|
}
|
|
|
|
fn count_of(counts: &[picloud_manager_core::repo::MetricsCount], key: &str) -> i64 {
|
|
counts.iter().find(|c| c.key == key).map_or(0, |c| c.count)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn summarize_rolls_up_counts_error_rate_and_percentiles() {
|
|
let Some(pool) = pool_or_skip().await else {
|
|
return;
|
|
};
|
|
let app = make_app(&pool).await;
|
|
|
|
// 3 successes (http), 1 error (kv), 1 timeout (cron). Durations chosen so
|
|
// the percentiles are exact: sorted [10, 20, 30, 50, 100].
|
|
seed(&pool, app, 10, "success", "http").await;
|
|
seed(&pool, app, 20, "success", "http").await;
|
|
seed(&pool, app, 30, "success", "http").await;
|
|
seed(&pool, app, 100, "error", "kv").await;
|
|
seed(&pool, app, 50, "timeout", "cron").await;
|
|
|
|
let repo = PostgresExecutionLogRepository::new(pool.clone());
|
|
let s = repo
|
|
.summarize_for_app(AppId::from(app), 24)
|
|
.await
|
|
.expect("summarize");
|
|
|
|
assert_eq!(s.total, 5, "total executions");
|
|
// Every non-success terminal state counts as an error: error + timeout = 2.
|
|
assert_eq!(s.errors, 2, "error count");
|
|
assert!(
|
|
(s.error_rate - 0.4).abs() < 1e-9,
|
|
"error rate: {}",
|
|
s.error_rate
|
|
);
|
|
// mean of [10,20,30,100,50] = 42.
|
|
assert!(
|
|
(s.avg_duration_ms - 42.0).abs() < 1e-6,
|
|
"avg: {}",
|
|
s.avg_duration_ms
|
|
);
|
|
// percentile_cont(0.5) of the 5 sorted values = 30.
|
|
assert!(
|
|
(s.p50_duration_ms - 30.0).abs() < 1e-6,
|
|
"p50: {}",
|
|
s.p50_duration_ms
|
|
);
|
|
// percentile_cont(0.95): 0.95*(n-1)=3.8 → interpolate 50..100 → 90.
|
|
assert!(
|
|
(s.p95_duration_ms - 90.0).abs() < 1e-6,
|
|
"p95: {}",
|
|
s.p95_duration_ms
|
|
);
|
|
|
|
assert_eq!(count_of(&s.by_status, "success"), 3);
|
|
assert_eq!(count_of(&s.by_status, "error"), 1);
|
|
assert_eq!(count_of(&s.by_status, "timeout"), 1);
|
|
|
|
assert_eq!(count_of(&s.by_source, "http"), 3);
|
|
assert_eq!(count_of(&s.by_source, "kv"), 1);
|
|
assert_eq!(count_of(&s.by_source, "cron"), 1);
|
|
|
|
// All rows land in the current hour bucket; the series sums back to the total.
|
|
assert!(!s.series.is_empty(), "series has at least one bucket");
|
|
assert_eq!(s.series.iter().map(|b| b.total).sum::<i64>(), 5);
|
|
assert_eq!(s.series.iter().map(|b| b.errors).sum::<i64>(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn summarize_of_an_empty_app_is_all_zero() {
|
|
let Some(pool) = pool_or_skip().await else {
|
|
return;
|
|
};
|
|
let app = make_app(&pool).await;
|
|
let repo = PostgresExecutionLogRepository::new(pool.clone());
|
|
let s = repo
|
|
.summarize_for_app(AppId::from(app), 24)
|
|
.await
|
|
.expect("summarize");
|
|
assert_eq!(s.total, 0);
|
|
assert_eq!(s.errors, 0);
|
|
assert!(
|
|
(s.error_rate - 0.0).abs() < 1e-9,
|
|
"no executions → zero rate"
|
|
);
|
|
assert!(s.series.is_empty());
|
|
}
|