Skip to content

Commit 691e9e1

Browse files
committed
feat: add the /report health report (get_report MCP tool + CLI + API)
1 parent 0dfaec2 commit 691e9e1

27 files changed

Lines changed: 3220 additions & 3 deletions

.changeset/report-health.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"trigger.dev": patch
4+
---
5+
6+
Add the `get_report` MCP tool, a `trigger report` CLI command, and `GET /api/v1/reports/:key`, starting with the `health` report: a deterministic verdict on whether work is flowing, whether the runs that start are healthy, and whether telemetry is fresh — rendered as text with unicode sparklines (coloured in a terminal). Also adds a `report` MCP prompt, surfaced as a slash command in hosts that support prompts.
7+
8+
Also: `trigger mcp` now always starts the server — the install wizard is gated behind `trigger mcp --install`. A TTY previously launched the wizard, so MCP hosts spawning the server over a PTY timed out.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Thin orchestrator: load -> interpret -> return the generic ReportViewModel. No SQL or render
3+
* here — data access lives in each report's `load`, meaning in `interpret`, presentation in the
4+
* renderers, and the catalog of reports in `report-registry.ts`.
5+
*
6+
* `call` takes a resolved AuthenticatedEnvironment and is transport-independent (Seam B, §7):
7+
* any future surface (MCP Resource, etc.) is just another caller of this same method.
8+
*/
9+
10+
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
11+
import { REPORT_REGISTRY } from "./report-registry";
12+
import { type ReportViewModel } from "./report-view-model";
13+
14+
const DEFAULT_PERIOD = "1h";
15+
16+
/**
17+
* Single-flight: collapse concurrent identical requests (same report/env/period) into one
18+
* computation. A report fires up to ~9 ClickHouse queries and MCP/CLI clients easily call it
19+
* several times at once — without this, N callers each launch the full query set and pile onto
20+
* the per-project query-concurrency limit. Keyed per (key, env, period); entry drops on settle.
21+
*/
22+
const inFlight = new Map<string, Promise<ReportViewModel | undefined>>();
23+
24+
export class ReportPresenter {
25+
async call({
26+
environment,
27+
key,
28+
period = DEFAULT_PERIOD,
29+
}: {
30+
environment: AuthenticatedEnvironment;
31+
key: string;
32+
period?: string;
33+
}): Promise<ReportViewModel | undefined> {
34+
const loader = REPORT_REGISTRY[key];
35+
if (!loader) return undefined;
36+
37+
const flightKey = `${key} ${environment.id} ${period}`;
38+
const existing = inFlight.get(flightKey);
39+
if (existing) return existing;
40+
41+
const promise = (async () => {
42+
const input = await loader.load(environment, period);
43+
return loader.interpret(input);
44+
})().finally(() => inFlight.delete(flightKey));
45+
46+
inFlight.set(flightKey, promise);
47+
return promise;
48+
}
49+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* EXECUTION analyzer: "are the runs that DO start completing OK?" — failures + durations only.
3+
* Its "read:" line answers whether the flow problem is a code problem.
4+
*/
5+
6+
import { isOk, maxSeverity, type Finding, type Metric } from "../report-view-model";
7+
import { HEALTH_THRESHOLDS, metricById, type HealthInput } from "./health-core";
8+
9+
export const EXECUTION_METRIC_IDS = ["failures", "dur_p95"];
10+
11+
export function interpretExecution(metrics: Metric[], input: HealthInput): Finding {
12+
const exec = EXECUTION_METRIC_IDS.map((id) => metricById(metrics, id));
13+
const severity = maxSeverity(...exec.map((m) => m.severity));
14+
const failures = metricById(metrics, "failures");
15+
16+
if (isOk(severity)) {
17+
return { type: "execution", severity, reason: "healthy", metricIds: EXECUTION_METRIC_IDS };
18+
}
19+
20+
const reason = !isOk(failures.severity) ? "failures_up" : "slow_runs";
21+
const recommendation =
22+
reason === "failures_up"
23+
? { code: "review_failing_tasks", link: "runs_failed" }
24+
: { code: "review_slow_runs", link: "runs" };
25+
26+
// Lazy attribution when it owns >= minShare of failures.
27+
let attribution: Finding["attribution"];
28+
const fb = input.failureBreakdown;
29+
if (fb && fb.share >= HEALTH_THRESHOLDS.attribution.minShare) {
30+
attribution = { dim: "task", key: fb.task, share: fb.share, of: "failures" };
31+
}
32+
33+
return {
34+
type: "execution",
35+
severity,
36+
reason,
37+
metricIds: EXECUTION_METRIC_IDS,
38+
recommendation,
39+
attribution,
40+
};
41+
}
42+
43+
/** Flow causes that provably CAN'T be user code, so a healthy execution reads "not a code problem".
44+
* dequeue_stall is platform-side (capacity free but nothing dequeuing). Trigger spike/surge are
45+
* excluded: a code path fanning out task.trigger can BE the cause, so we only state the runs that
46+
* execute are fine — never the global "not a code problem". */
47+
const NOT_A_CODE_PROBLEM_CAUSES = new Set(["dequeue_stall"]);
48+
49+
export function buildExecutionRead(execution: Finding, flow: Finding): string {
50+
if (execution.reason === "unknown") return "data_stale";
51+
if (isOk(execution.severity)) {
52+
return NOT_A_CODE_PROBLEM_CAUSES.has(flow.reason) ? "not_a_code_problem" : "runs_are_fine";
53+
}
54+
return "failures_elevated";
55+
}

0 commit comments

Comments
 (0)