feat(metrics): per-app observability dashboard over execution_logs (A2)
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>
This commit is contained in:
@@ -731,6 +731,40 @@ impl ExecutionLogCursor {
|
||||
}
|
||||
}
|
||||
|
||||
/// One `(key, count)` row of a metrics breakdown (by status or by source).
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct MetricsCount {
|
||||
pub key: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// One hour-bucket of the metrics time series.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct MetricsBucket {
|
||||
pub ts: chrono::DateTime<chrono::Utc>,
|
||||
pub total: i64,
|
||||
pub errors: i64,
|
||||
}
|
||||
|
||||
/// Aggregate execution metrics for one app over a trailing window — the read
|
||||
/// model behind the observability dashboard. Sourced entirely from
|
||||
/// `execution_logs` (no hot-path instrumentation): counts + error rate +
|
||||
/// latency percentiles + an hourly series.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct MetricsSummary {
|
||||
pub window_hours: i32,
|
||||
pub total: i64,
|
||||
pub errors: i64,
|
||||
/// Errors / total in `[0, 1]` (0 when there are no executions).
|
||||
pub error_rate: f64,
|
||||
pub avg_duration_ms: f64,
|
||||
pub p50_duration_ms: f64,
|
||||
pub p95_duration_ms: f64,
|
||||
pub by_status: Vec<MetricsCount>,
|
||||
pub by_source: Vec<MetricsCount>,
|
||||
pub series: Vec<MetricsBucket>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ExecutionLogRepository: Send + Sync {
|
||||
/// F-P-005: keyset cursor on `(created_at, id)` — `cursor` is the
|
||||
@@ -743,6 +777,14 @@ pub trait ExecutionLogRepository: Send + Sync {
|
||||
cursor: Option<ExecutionLogCursor>,
|
||||
source: Option<ExecutionSource>,
|
||||
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError>;
|
||||
|
||||
/// A2: aggregate metrics for `app_id` over the trailing `window_hours`.
|
||||
/// Read-only rollup over `execution_logs`.
|
||||
async fn summarize_for_app(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
window_hours: i32,
|
||||
) -> Result<MetricsSummary, ScriptRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresExecutionLogRepository {
|
||||
@@ -813,6 +855,129 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
||||
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn summarize_for_app(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
window_hours: i32,
|
||||
) -> Result<MetricsSummary, ScriptRepositoryError> {
|
||||
// Clamp the window to something sane (1 hour … 90 days) so a crafted
|
||||
// `?window=` can't ask for an unbounded scan.
|
||||
let window_hours = window_hours.clamp(1, 24 * 90);
|
||||
let app = app_id.into_inner();
|
||||
|
||||
// Headline aggregate: totals, error count, latency mean + percentiles.
|
||||
// `<> 'success'` counts every non-success terminal state as an error.
|
||||
let agg = sqlx::query_as::<_, AggRow>(
|
||||
"SELECT count(*)::int8 AS total, \
|
||||
count(*) FILTER (WHERE status <> 'success')::int8 AS errors, \
|
||||
COALESCE(avg(duration_ms), 0)::float8 AS avg_ms, \
|
||||
COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms), 0)::float8 AS p50, \
|
||||
COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms), 0)::float8 AS p95 \
|
||||
FROM execution_logs \
|
||||
WHERE app_id = $1 AND created_at >= now() - ($2 * interval '1 hour')",
|
||||
)
|
||||
.bind(app)
|
||||
.bind(window_hours)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
let by_status = sqlx::query_as::<_, CountRow>(
|
||||
"SELECT status AS key, count(*)::int8 AS count \
|
||||
FROM execution_logs \
|
||||
WHERE app_id = $1 AND created_at >= now() - ($2 * interval '1 hour') \
|
||||
GROUP BY status ORDER BY count DESC",
|
||||
)
|
||||
.bind(app)
|
||||
.bind(window_hours)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let by_source = sqlx::query_as::<_, CountRow>(
|
||||
"SELECT source AS key, count(*)::int8 AS count \
|
||||
FROM execution_logs \
|
||||
WHERE app_id = $1 AND created_at >= now() - ($2 * interval '1 hour') \
|
||||
GROUP BY source ORDER BY count DESC",
|
||||
)
|
||||
.bind(app)
|
||||
.bind(window_hours)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let series = sqlx::query_as::<_, BucketRow>(
|
||||
"SELECT date_trunc('hour', created_at) AS ts, count(*)::int8 AS total, \
|
||||
count(*) FILTER (WHERE status <> 'success')::int8 AS errors \
|
||||
FROM execution_logs \
|
||||
WHERE app_id = $1 AND created_at >= now() - ($2 * interval '1 hour') \
|
||||
GROUP BY ts ORDER BY ts",
|
||||
)
|
||||
.bind(app)
|
||||
.bind(window_hours)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
// Counts are execution tallies over a bounded window — always far below
|
||||
// f64's 2^53 exact-integer range, so the cast is lossless in practice.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let error_rate = if agg.total > 0 {
|
||||
agg.errors as f64 / agg.total as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Ok(MetricsSummary {
|
||||
window_hours,
|
||||
total: agg.total,
|
||||
errors: agg.errors,
|
||||
error_rate,
|
||||
avg_duration_ms: agg.avg_ms,
|
||||
p50_duration_ms: agg.p50,
|
||||
p95_duration_ms: agg.p95,
|
||||
by_status: by_status
|
||||
.into_iter()
|
||||
.map(|r| MetricsCount {
|
||||
key: r.key,
|
||||
count: r.count,
|
||||
})
|
||||
.collect(),
|
||||
by_source: by_source
|
||||
.into_iter()
|
||||
.map(|r| MetricsCount {
|
||||
key: r.key,
|
||||
count: r.count,
|
||||
})
|
||||
.collect(),
|
||||
series: series
|
||||
.into_iter()
|
||||
.map(|r| MetricsBucket {
|
||||
ts: r.ts,
|
||||
total: r.total,
|
||||
errors: r.errors,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AggRow {
|
||||
total: i64,
|
||||
errors: i64,
|
||||
avg_ms: f64,
|
||||
p50: f64,
|
||||
p95: f64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CountRow {
|
||||
key: String,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct BucketRow {
|
||||
ts: chrono::DateTime<chrono::Utc>,
|
||||
total: i64,
|
||||
errors: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
Reference in New Issue
Block a user