ML-163: implementation
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Production Error Tools
|
||||
*
|
||||
* Fetches and displays production error data from the /api/v1/errors JSON API.
|
||||
*
|
||||
* Credentials are read from environment variables:
|
||||
* PI_API_TOKEN — Bearer token for API auth
|
||||
* PI_SERVICE_FQDN_WEB — Production domain (e.g., https://musiclibrary.example.com)
|
||||
*
|
||||
* Tools:
|
||||
* fetch_production_errors — List/filter errors with pagination
|
||||
* fetch_production_error — Single error detail with occurrences and stacktraces
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
truncateTail,
|
||||
formatSize,
|
||||
DEFAULT_MAX_BYTES,
|
||||
DEFAULT_MAX_LINES,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ErrorListItem {
|
||||
id: number;
|
||||
kind: string;
|
||||
reason: string;
|
||||
source_line: string;
|
||||
source_function: string;
|
||||
status: string;
|
||||
fingerprint: string;
|
||||
last_occurrence_at: string;
|
||||
muted: boolean;
|
||||
inserted_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ErrorListResponse {
|
||||
errors: ErrorListItem[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
interface StacktraceLine {
|
||||
application: string;
|
||||
module: string;
|
||||
function: string;
|
||||
arity: number;
|
||||
file: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
interface Occurrence {
|
||||
id: number;
|
||||
reason: string;
|
||||
context: Record<string, unknown>;
|
||||
breadcrumbs: string[];
|
||||
stacktrace: { lines: StacktraceLine[] };
|
||||
error_id: number;
|
||||
inserted_at: string;
|
||||
}
|
||||
|
||||
interface ErrorDetail {
|
||||
id: number;
|
||||
kind: string;
|
||||
reason: string;
|
||||
source_line: string;
|
||||
source_function: string;
|
||||
status: string;
|
||||
fingerprint: string;
|
||||
last_occurrence_at: string;
|
||||
muted: boolean;
|
||||
inserted_at: string;
|
||||
updated_at: string;
|
||||
occurrence_count: number;
|
||||
first_occurrence_at: string | null;
|
||||
occurrences: Occurrence[];
|
||||
}
|
||||
|
||||
interface ErrorDetailResponse {
|
||||
error: ErrorDetail;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function resolveVar(name: string): string | undefined {
|
||||
return process.env[`PI_${name.toUpperCase()}`];
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
base: string,
|
||||
path: string,
|
||||
params?: Record<string, string>,
|
||||
): string {
|
||||
// Strip trailing slash and ensure protocol
|
||||
let url = base.replace(/\/+$/, "");
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
url = `${url}${path}`;
|
||||
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
const qs = searchParams.toString();
|
||||
if (qs) {
|
||||
url = `${url}?${qs}`;
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// ── Formatting ──────────────────────────────────────────────────────────────
|
||||
|
||||
function formatErrorListItem(error: ErrorListItem, index: number): string {
|
||||
const mutedLabel = error.muted ? " [MUTED]" : "";
|
||||
return [
|
||||
`#${index} [${error.status}]${mutedLabel} ${error.kind}: ${error.reason}`,
|
||||
` Source: ${error.source_line} — ${error.source_function}`,
|
||||
` Last occurrence: ${error.last_occurrence_at}`,
|
||||
` Fingerprint: ${error.fingerprint}`,
|
||||
` ID: ${error.id}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function formatErrorDetail(error: ErrorDetail): string {
|
||||
const lines: string[] = [];
|
||||
const sep = "─".repeat(44);
|
||||
|
||||
lines.push(`Error #${error.id}: ${error.kind}`);
|
||||
lines.push(sep);
|
||||
lines.push(`Reason: ${error.reason}`);
|
||||
lines.push(`Status: ${error.status} | Muted: ${error.muted}`);
|
||||
lines.push(`Source: ${error.source_line} — ${error.source_function}`);
|
||||
lines.push(`Fingerprint: ${error.fingerprint}`);
|
||||
lines.push(`First occurrence: ${error.first_occurrence_at ?? "N/A"}`);
|
||||
lines.push(`Last occurrence: ${error.last_occurrence_at}`);
|
||||
lines.push(`Total occurrences: ${error.occurrence_count}`);
|
||||
lines.push(`Created: ${error.inserted_at} | Updated: ${error.updated_at}`);
|
||||
|
||||
const occurrences = error.occurrences ?? [];
|
||||
lines.push("");
|
||||
lines.push(`Occurrences (${occurrences.length}):`);
|
||||
lines.push(sep);
|
||||
|
||||
for (let i = 0; i < occurrences.length; i++) {
|
||||
const occ = occurrences[i];
|
||||
lines.push(`#${i + 1} ${occ.inserted_at}`);
|
||||
lines.push(` Reason: ${occ.reason}`);
|
||||
|
||||
// Context as key-value pairs
|
||||
if (occ.context && Object.keys(occ.context).length > 0) {
|
||||
const ctxLines = Object.entries(occ.context).map(
|
||||
([k, v]) => ` ${k}: ${JSON.stringify(v)}`,
|
||||
);
|
||||
lines.push(` Context:`);
|
||||
lines.push(...ctxLines);
|
||||
}
|
||||
|
||||
// Breadcrumbs as bullet list
|
||||
if (occ.breadcrumbs && occ.breadcrumbs.length > 0) {
|
||||
lines.push(` Breadcrumbs:`);
|
||||
for (const crumb of occ.breadcrumbs) {
|
||||
lines.push(` • ${crumb}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Stacktrace
|
||||
if (occ.stacktrace?.lines && occ.stacktrace.lines.length > 0) {
|
||||
lines.push(` Stacktrace:`);
|
||||
for (const sl of occ.stacktrace.lines) {
|
||||
const app = sl.application || "—";
|
||||
const loc = sl.file
|
||||
? `${sl.file}${sl.line != null ? `:${sl.line}` : ""}`
|
||||
: "(nofile)";
|
||||
lines.push(
|
||||
` ${app} / ${sl.module}.${sl.function}/${sl.arity} ${loc}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (i < occurrences.length - 1) {
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function applyOutputTruncation(output: string): string {
|
||||
const truncation = truncateTail(output, {
|
||||
maxLines: DEFAULT_MAX_LINES,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
});
|
||||
|
||||
if (truncation.truncated) {
|
||||
return (
|
||||
truncation.content +
|
||||
`\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines ` +
|
||||
`(${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). ` +
|
||||
`Use narrower filters to reduce output.]`
|
||||
);
|
||||
}
|
||||
|
||||
return truncation.content;
|
||||
}
|
||||
|
||||
// ── HTTP helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchErrors(
|
||||
url: string,
|
||||
token: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ErrorListResponse> {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "(unable to read body)");
|
||||
const truncated = body.length > 500 ? body.slice(0, 500) + "..." : body;
|
||||
throw new Error(
|
||||
`API returned ${response.status} ${response.statusText}\n${truncated}`,
|
||||
);
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`Failed to parse API response: ${message}. Status: ${response.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
if (!obj.errors || !Array.isArray(obj.errors)) {
|
||||
throw new Error(
|
||||
`Unexpected API response: 'errors' field is missing or not an array. Got: ${
|
||||
obj.errors === null ? "null" : typeof obj.errors
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return data as ErrorListResponse;
|
||||
}
|
||||
|
||||
async function fetchError(
|
||||
url: string,
|
||||
token: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ErrorDetailResponse> {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "(unable to read body)");
|
||||
const truncated = body.length > 500 ? body.slice(0, 500) + "..." : body;
|
||||
throw new Error(
|
||||
`API returned ${response.status} ${response.statusText}\n${truncated}`,
|
||||
);
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`Failed to parse API response: ${message}. Status: ${response.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
if (!obj.error || typeof obj.error !== "object") {
|
||||
throw new Error(
|
||||
`Unexpected API response: 'error' field is missing or not an object. Got: ${
|
||||
obj.error === null ? "null" : typeof obj.error
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return data as ErrorDetailResponse;
|
||||
}
|
||||
|
||||
// ── Extension ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function prodErrorsExtension(pi: ExtensionAPI) {
|
||||
// ── fetch_production_errors tool ───────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "fetch_production_errors",
|
||||
label: "Fetch Production Errors",
|
||||
description:
|
||||
"Fetch production errors from the deployed application via the /api/v1/errors JSON API. " +
|
||||
"Use this tool when investigating what errors are occurring in production, " +
|
||||
"checking error frequency, browsing unresolved errors, or when the user asks " +
|
||||
"about production errors. Supports filtering by status, muted state, and " +
|
||||
"substring search on error reason. Output is truncated at 50KB / 2000 lines " +
|
||||
"— use narrower filters to reduce output if truncation occurs.",
|
||||
promptSnippet:
|
||||
"Fetch recent production errors from the errors API (params: status, muted, search, limit, offset)",
|
||||
promptGuidelines: [
|
||||
"Use fetch_production_errors when investigating what errors are occurring in production, checking error frequency, or browsing unresolved errors.",
|
||||
"Start with a small limit (e.g., 20) and filter by status or search before fetching large result sets. Use the 'search' parameter to find errors matching a specific reason or module.",
|
||||
"Use fetch_production_error when you need full details on a specific error, including stacktraces and context. Get the error ID from fetch_production_errors first.",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
status: Type.Optional(
|
||||
Type.Union([Type.Literal("resolved"), Type.Literal("unresolved")], {
|
||||
description:
|
||||
"Filter by error status: 'resolved' or 'unresolved'.",
|
||||
}),
|
||||
),
|
||||
muted: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Filter by muted state. true = only muted, false = only unmuted.",
|
||||
}),
|
||||
),
|
||||
search: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Substring match on error reason. Case-insensitive search to find specific errors.",
|
||||
}),
|
||||
),
|
||||
limit: Type.Optional(
|
||||
Type.Number({
|
||||
description:
|
||||
"Maximum number of errors to return. Default: 50. Use a smaller value to reduce output size.",
|
||||
}),
|
||||
),
|
||||
offset: Type.Optional(
|
||||
Type.Number({
|
||||
description:
|
||||
"Pagination offset. Number of errors to skip. Default: 0.",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
async execute(
|
||||
_toolCallId,
|
||||
params,
|
||||
signal,
|
||||
_onUpdate,
|
||||
_ctx,
|
||||
) {
|
||||
// Early abort check
|
||||
if (signal?.aborted) {
|
||||
return { content: [{ type: "text", text: "Cancelled" }] };
|
||||
}
|
||||
|
||||
// Validate credentials
|
||||
const token = resolveVar("api_token");
|
||||
const fqdn = resolveVar("service_fqdn_web");
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!token) missing.push("PI_API_TOKEN");
|
||||
if (!fqdn) missing.push("PI_SERVICE_FQDN_WEB");
|
||||
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Cannot fetch production errors: the following environment variables are not set:\n` +
|
||||
missing.map((v) => ` - ${v}`).join("\n") +
|
||||
`\n\nEnsure these are configured in your pi environment.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Build query params for non-nil values
|
||||
const queryParams: Record<string, string> = {};
|
||||
if (params.status !== undefined && params.status !== null) {
|
||||
queryParams.status = params.status;
|
||||
}
|
||||
if (params.muted !== undefined && params.muted !== null) {
|
||||
queryParams.muted = String(params.muted);
|
||||
}
|
||||
if (params.search !== undefined && params.search !== null && params.search !== "") {
|
||||
queryParams.search = params.search;
|
||||
}
|
||||
if (params.limit !== undefined && params.limit !== null) {
|
||||
queryParams.limit = String(params.limit);
|
||||
}
|
||||
if (params.offset !== undefined && params.offset !== null) {
|
||||
queryParams.offset = String(params.offset);
|
||||
}
|
||||
|
||||
const url = buildUrl(fqdn!, "/api/v1/errors", queryParams);
|
||||
|
||||
// Fetch
|
||||
let data: ErrorListResponse;
|
||||
try {
|
||||
data = await fetchErrors(url, token!, signal);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to fetch production errors: ${message}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle empty results
|
||||
if (data.errors.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No errors found matching the given filters." }],
|
||||
details: { total: data.total, count: 0, offset: data.offset, limit: data.limit },
|
||||
};
|
||||
}
|
||||
|
||||
// Format output
|
||||
const count = data.errors.length;
|
||||
const from = (data.offset ?? 0) + 1;
|
||||
const to = (data.offset ?? 0) + count;
|
||||
|
||||
const header = `Production Errors (total: ${data.total}, showing ${from}-${to})\n`;
|
||||
const body = data.errors
|
||||
.map((e, i) => formatErrorListItem(e, from + i))
|
||||
.join("\n\n");
|
||||
|
||||
const output = applyOutputTruncation(header + body);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
details: {
|
||||
total: data.total,
|
||||
count,
|
||||
offset: data.offset,
|
||||
limit: data.limit,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── fetch_production_error tool ────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "fetch_production_error",
|
||||
label: "Fetch Production Error Detail",
|
||||
description:
|
||||
"Fetch full details for a specific production error by ID, including all " +
|
||||
"occurrences, stacktraces, context, and breadcrumbs. Use this tool when you " +
|
||||
"need to investigate a specific error in depth. Get the error ID from " +
|
||||
"fetch_production_errors first. Output is truncated at 50KB / 2000 lines.",
|
||||
promptSnippet:
|
||||
"Fetch full details for a specific production error by ID (param: id)",
|
||||
promptGuidelines: [
|
||||
"Use fetch_production_error when you need full details on a specific error, including stacktraces, context, and breadcrumbs from every occurrence.",
|
||||
"Get the error ID from fetch_production_errors first. Pass it as the 'id' parameter. The output includes all occurrences and may be large — review it carefully before asking for more.",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.Number({
|
||||
description:
|
||||
"The error ID (integer) to fetch full details for. Get this from fetch_production_errors.",
|
||||
}),
|
||||
}),
|
||||
async execute(
|
||||
_toolCallId,
|
||||
params,
|
||||
signal,
|
||||
_onUpdate,
|
||||
_ctx,
|
||||
) {
|
||||
// Early abort check
|
||||
if (signal?.aborted) {
|
||||
return { content: [{ type: "text", text: "Cancelled" }] };
|
||||
}
|
||||
|
||||
// Validate credentials
|
||||
const token = resolveVar("api_token");
|
||||
const fqdn = resolveVar("service_fqdn_web");
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!token) missing.push("PI_API_TOKEN");
|
||||
if (!fqdn) missing.push("PI_SERVICE_FQDN_WEB");
|
||||
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`Cannot fetch production error: the following environment variables are not set:\n` +
|
||||
missing.map((v) => ` - ${v}`).join("\n") +
|
||||
`\n\nEnsure these are configured in your pi environment.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const url = buildUrl(fqdn!, `/api/v1/errors/${params.id}`);
|
||||
|
||||
// Fetch
|
||||
let data: ErrorDetailResponse;
|
||||
try {
|
||||
data = await fetchError(url, token!, signal);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Check if it looks like a 404
|
||||
if (message.includes("404")) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error with ID ${params.id} not found.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to fetch production error: ${message}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Format output
|
||||
const output = applyOutputTruncation(formatErrorDetail(data.error));
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: output }],
|
||||
details: {
|
||||
errorId: params.id,
|
||||
occurrenceCount: data.error.occurrence_count,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user