Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/report-health.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---

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.

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.
49 changes: 49 additions & 0 deletions apps/webapp/app/presenters/v3/reports/ReportPresenter.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Thin orchestrator: load -> interpret -> return the generic ReportViewModel. No SQL or render
* here — data access lives in each report's `load`, meaning in `interpret`, presentation in the
* renderers, and the catalog of reports in `report-registry.ts`.
*
* `call` takes a resolved AuthenticatedEnvironment and is transport-independent (Seam B, §7):
* any future surface (MCP Resource, etc.) is just another caller of this same method.
*/

import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { REPORT_REGISTRY } from "./report-registry";
import { type ReportViewModel } from "./report-view-model";

const DEFAULT_PERIOD = "1h";

/**
* Single-flight: collapse concurrent identical requests (same report/env/period) into one
* computation. A report fires up to ~9 ClickHouse queries and MCP/CLI clients easily call it
* several times at once — without this, N callers each launch the full query set and pile onto
* the per-project query-concurrency limit. Keyed per (key, env, period); entry drops on settle.
*/
const inFlight = new Map<string, Promise<ReportViewModel | undefined>>();

export class ReportPresenter {
async call({
environment,
key,
period = DEFAULT_PERIOD,
}: {
environment: AuthenticatedEnvironment;
key: string;
period?: string;
}): Promise<ReportViewModel | undefined> {
const loader = REPORT_REGISTRY[key];
if (!loader) return undefined;

const flightKey = `${key} ${environment.id} ${period}`;
const existing = inFlight.get(flightKey);
if (existing) return existing;

const promise = (async () => {
const input = await loader.load(environment, period);
return loader.interpret(input);
})().finally(() => inFlight.delete(flightKey));

inFlight.set(flightKey, promise);
return promise;
}
}
55 changes: 55 additions & 0 deletions apps/webapp/app/presenters/v3/reports/health/execution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* EXECUTION analyzer: "are the runs that DO start completing OK?" — failures + durations only.
* Its "read:" line answers whether the flow problem is a code problem.
*/

import { isOk, maxSeverity, type Finding, type Metric } from "../report-view-model";
import { HEALTH_THRESHOLDS, metricById, type HealthInput } from "./health-core";

export const EXECUTION_METRIC_IDS = ["failures", "dur_p95"];

export function interpretExecution(metrics: Metric[], input: HealthInput): Finding {
const exec = EXECUTION_METRIC_IDS.map((id) => metricById(metrics, id));
const severity = maxSeverity(...exec.map((m) => m.severity));
const failures = metricById(metrics, "failures");

if (isOk(severity)) {
return { type: "execution", severity, reason: "healthy", metricIds: EXECUTION_METRIC_IDS };
}

const reason = !isOk(failures.severity) ? "failures_up" : "slow_runs";
const recommendation =
reason === "failures_up"
? { code: "review_failing_tasks", link: "runs_failed" }
: { code: "review_slow_runs", link: "runs" };

// Lazy attribution when it owns >= minShare of failures.
let attribution: Finding["attribution"];
const fb = input.failureBreakdown;
if (fb && fb.share >= HEALTH_THRESHOLDS.attribution.minShare) {
attribution = { dim: "task", key: fb.task, share: fb.share, of: "failures" };
}

return {
type: "execution",
severity,
reason,
metricIds: EXECUTION_METRIC_IDS,
recommendation,
attribution,
};
}

/** Flow causes that provably CAN'T be user code, so a healthy execution reads "not a code problem".
* dequeue_stall is platform-side (capacity free but nothing dequeuing). Trigger spike/surge are
* excluded: a code path fanning out task.trigger can BE the cause, so we only state the runs that
* execute are fine — never the global "not a code problem". */
const NOT_A_CODE_PROBLEM_CAUSES = new Set(["dequeue_stall"]);

export function buildExecutionRead(execution: Finding, flow: Finding): string {
if (execution.reason === "unknown") return "data_stale";
if (isOk(execution.severity)) {
return NOT_A_CODE_PROBLEM_CAUSES.has(flow.reason) ? "not_a_code_problem" : "runs_are_fine";
}
return "failures_elevated";
}
Loading
Loading