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:
MechaCat02
2026-07-15 21:42:18 +02:00
parent 35345e62de
commit 6b2dcd41a6
10 changed files with 774 additions and 20 deletions

View File

@@ -39,6 +39,7 @@
href: `${base}/apps/${slug}/workflows`,
adminOnly: true
},
{ id: 'metrics', label: 'Metrics', href: `${base}/apps/${slug}/metrics`, adminOnly: true },
{
id: 'dead-letters',
label: 'Dead letters',

View File

@@ -218,6 +218,33 @@ export interface ExecutionLog {
created_at: string;
}
/** A2: one `(key, count)` row of a metrics breakdown. */
export interface MetricsCount {
key: string;
count: number;
}
/** A2: one hour-bucket of the metrics time series. */
export interface MetricsBucket {
ts: string;
total: number;
errors: number;
}
/** A2: the observability rollup for one app over a trailing window. */
export interface MetricsSummary {
window_hours: number;
total: number;
errors: number;
error_rate: number;
avg_duration_ms: number;
p50_duration_ms: number;
p95_duration_ms: number;
by_status: MetricsCount[];
by_source: MetricsCount[];
series: MetricsBucket[];
}
export interface CreateScriptInput {
app_id: string;
name: string;
@@ -1169,6 +1196,14 @@ export const api = {
)
},
metrics: {
/** A2: aggregate execution metrics for an app over the trailing window (hours). */
summary: (idOrSlug: string, windowHours = 24) =>
adminRequest<MetricsSummary>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/metrics?window=${windowHours}`
)
},
deadLetters: {
count: (idOrSlug: string) =>
adminRequest<{ unresolved: number }>(

View File

@@ -72,6 +72,8 @@
return 'queues';
case 'workflows':
return 'workflows';
case 'metrics':
return 'metrics';
case 'dead-letters':
return 'dead-letters';
default:

View File

@@ -0,0 +1,275 @@
<script lang="ts">
import { page } from '$app/state';
import { api, ApiError, type MetricsSummary } from '$lib/api';
let slug = $derived(page.params.slug ?? '');
let summary = $state<MetricsSummary | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let windowHours = $state(24);
const windows = [
{ label: '24 h', hours: 24 },
{ label: '7 d', hours: 24 * 7 },
{ label: '30 d', hours: 24 * 30 }
];
async function load() {
loading = true;
error = null;
try {
summary = await api.metrics.summary(slug, windowHours);
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
} finally {
loading = false;
}
}
$effect(() => {
// Re-load whenever the slug or window changes.
void slug;
void windowHours;
void load();
});
function pct(x: number): string {
return `${(x * 100).toFixed(1)}%`;
}
function ms(x: number): string {
return `${Math.round(x)} ms`;
}
// Chart geometry. Bars are scaled to the busiest bucket; the error portion
// is drawn on top of the success portion so the split reads at a glance.
const chartW = 720;
const chartH = 160;
let maxTotal = $derived(Math.max(1, ...(summary?.series ?? []).map((b) => b.total)));
let barStep = $derived(
summary && summary.series.length > 0 ? chartW / summary.series.length : chartW
);
function barW(): number {
return Math.max(1, barStep * 0.8);
}
function fmtBucket(iso: string): string {
return new Date(iso).toLocaleString();
}
</script>
<svelte:head>
<title>Metrics · {slug} · PiCloud</title>
</svelte:head>
<div class="panel">
<header class="panel-head">
<div>
<h2>Metrics</h2>
<p class="subtitle">Execution rollup over the selected window (from the request log).</p>
</div>
<div class="controls" role="group" aria-label="Time window">
{#each windows as w (w.hours)}
<button
class:active={windowHours === w.hours}
onclick={() => (windowHours = w.hours)}
type="button">{w.label}</button
>
{/each}
</div>
</header>
{#if error}
<p class="error" role="alert">{error}</p>
{:else if loading && !summary}
<p class="muted">Loading…</p>
{:else if summary}
{#if summary.total === 0}
<p class="muted">No executions recorded in this window.</p>
{:else}
<div class="cards">
<div class="card">
<span class="k">Executions</span>
<span class="v">{summary.total}</span>
</div>
<div class="card" class:bad={summary.errors > 0}>
<span class="k">Error rate</span>
<span class="v">{pct(summary.error_rate)}</span>
<span class="sub">{summary.errors} errors</span>
</div>
<div class="card">
<span class="k">Avg latency</span>
<span class="v">{ms(summary.avg_duration_ms)}</span>
</div>
<div class="card">
<span class="k">p50</span>
<span class="v">{ms(summary.p50_duration_ms)}</span>
</div>
<div class="card">
<span class="k">p95</span>
<span class="v">{ms(summary.p95_duration_ms)}</span>
</div>
</div>
<h3>Executions per hour</h3>
<div class="chart-wrap">
<svg
viewBox="0 0 {chartW} {chartH}"
width="100%"
height={chartH}
role="img"
aria-label="Executions per hour, error portion highlighted"
>
{#each summary.series as b, i (b.ts)}
{@const total = (b.total / maxTotal) * (chartH - 4)}
{@const errs = (b.errors / maxTotal) * (chartH - 4)}
{@const x = i * barStep + (barStep - barW()) / 2}
<rect
{x}
y={chartH - total}
width={barW()}
height={total - errs}
class="bar-ok"
>
<title>{fmtBucket(b.ts)}: {b.total} ({b.errors} errors)</title>
</rect>
<rect {x} y={chartH - errs} width={barW()} height={errs} class="bar-err">
<title>{fmtBucket(b.ts)}: {b.errors} errors</title>
</rect>
{/each}
</svg>
</div>
<div class="breakdowns">
<div>
<h3>By status</h3>
<table>
<tbody>
{#each summary.by_status as row (row.key)}
<tr>
<td>{row.key}</td>
<td class="num">{row.count}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div>
<h3>By source</h3>
<table>
<tbody>
{#each summary.by_source as row (row.key)}
<tr>
<td>{row.key}</td>
<td class="num">{row.count}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{/if}
</div>
<style>
.panel-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.subtitle {
color: var(--text-muted);
margin: 0.25rem 0 0;
font-size: 0.875rem;
}
.controls {
display: inline-flex;
gap: 0.25rem;
}
.controls button {
padding: 0.35rem 0.7rem;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
border-radius: var(--radius-sm);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
}
.controls button.active {
color: var(--color-link);
border-color: var(--color-link);
}
.cards {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1rem 0 1.5rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 8rem;
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.card.bad .v {
color: var(--color-danger-fg);
}
.card .k {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.card .v {
font-size: 1.4rem;
font-weight: 600;
}
.card .sub {
font-size: 0.72rem;
color: var(--text-muted);
}
.chart-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.5rem;
}
.bar-ok {
fill: var(--color-link);
}
.bar-err {
fill: var(--color-danger-fg);
}
.breakdowns {
display: flex;
flex-wrap: wrap;
gap: 2rem;
margin-top: 1.5rem;
}
table {
border-collapse: collapse;
}
td {
padding: 0.25rem 0.75rem 0.25rem 0;
font-size: 0.875rem;
}
td.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.muted {
color: var(--text-muted);
}
.error {
color: var(--color-danger-fg);
}
h3 {
margin: 1rem 0 0.5rem;
font-size: 0.95rem;
}
</style>