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:
@@ -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',
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
return 'queues';
|
||||
case 'workflows':
|
||||
return 'workflows';
|
||||
case 'metrics':
|
||||
return 'metrics';
|
||||
case 'dead-letters':
|
||||
return 'dead-letters';
|
||||
default:
|
||||
|
||||
275
dashboard/src/routes/apps/[slug]/metrics/+page.svelte
Normal file
275
dashboard/src/routes/apps/[slug]/metrics/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user