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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "prod-errors",
|
||||||
|
"private": true,
|
||||||
|
"description": "Fetch production errors via the JSON API for LLM analysis"
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: ML-163
|
id: ML-163
|
||||||
title: Create pi tools to fetch production errors
|
title: Create pi tools to fetch production errors
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-05-04 08:08'
|
created_date: '2026-05-04 08:08'
|
||||||
updated_date: '2026-05-04 09:34'
|
updated_date: '2026-05-04 12:22'
|
||||||
labels:
|
labels:
|
||||||
- pi
|
- pi
|
||||||
- ready
|
- ready
|
||||||
@@ -348,3 +348,37 @@ To verify locally without the production server:
|
|||||||
4. Set `PI_SERVICE_FQDN_WEB=http://localhost:4000` and `PI_API_TOKEN` to match the local API token.
|
4. Set `PI_SERVICE_FQDN_WEB=http://localhost:4000` and `PI_API_TOKEN` to match the local API token.
|
||||||
5. Call the tools via the LLM.
|
5. Call the tools via the LLM.
|
||||||
<!-- SECTION:PLAN:END -->
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Created `fetch_production_errors` and `fetch_production_error` pi tools in a new `.pi/extensions/prod-errors/` extension. The tools call the `/api/v1/errors` JSON API (built in ML-162) with Bearer token authentication, using the same env-var pattern established by `fetch_production_logs` (ML-160).
|
||||||
|
|
||||||
|
### What changed
|
||||||
|
|
||||||
|
**New files:**
|
||||||
|
- `.pi/extensions/prod-errors/package.json` — Minimal extension package
|
||||||
|
- `.pi/extensions/prod-errors/index.ts` — ~330 lines: two tool registrations, HTTP helpers, formatting functions
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
- `docs/production-infrastructure.md` — Added pi coding agent tools section with env var documentation
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
- **`fetch_production_errors`** — Lists/filters production errors with `status`, `muted`, `search`, `limit`, `offset` params. Returns formatted error list with IDs, status, kind, reason, source info, fingerprint, and timestamps. 50KB/2000-line truncation limit.
|
||||||
|
- **`fetch_production_error`** — Gets full detail for a single error by ID, including all occurrences with stacktraces, context, and breadcrumbs. Same truncation limits.
|
||||||
|
|
||||||
|
### Key decisions
|
||||||
|
|
||||||
|
- Separate extension from `prod-logs` — keeps each extension focused (~330 lines vs mixing with 550-line Coolify code)
|
||||||
|
- `resolveVar()` reads `PI_API_TOKEN` and `PI_SERVICE_FQDN_WEB` using same pattern as `prod-logs` (`PI_COOLIFY_*`)
|
||||||
|
- Formatted human-readable text output (not raw JSON) — reduces token consumption, follows `fetch_production_logs` precedent
|
||||||
|
- Robust error handling: early abort, credential validation, HTTP error handling, JSON parse safety, response shape validation
|
||||||
|
- Uses TypeBox for parameter schemas matching ML-162 API design
|
||||||
|
|
||||||
|
### Test results
|
||||||
|
|
||||||
|
All 900 Elixir tests pass (no regressions). Pi tools verified by `/reload` in pi.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|||||||
@@ -229,6 +229,26 @@ and the offending batch is dropped. Tracks:
|
|||||||
- Oban Web (`/dev/oban`)
|
- Oban Web (`/dev/oban`)
|
||||||
- ErrorTracker (`/dev/errors`)
|
- ErrorTracker (`/dev/errors`)
|
||||||
|
|
||||||
|
### Pi coding agent tools
|
||||||
|
|
||||||
|
Pi extensions provide additional tools for production observability without manual SSH
|
||||||
|
or browser access. Each extension reads its own environment variables from the pi
|
||||||
|
runtime environment (not server-side config).
|
||||||
|
|
||||||
|
| Extension | Tools | Env vars |
|
||||||
|
|-----------|-------|----------|
|
||||||
|
| `prod-logs` | `fetch_production_logs` | `PI_COOLIFY_HOST`, `PI_COOLIFY_APP_UUID`, `PI_COOLIFY_TOKEN` |
|
||||||
|
| `prod-errors` | `fetch_production_errors`, `fetch_production_error` | `PI_API_TOKEN`, `PI_SERVICE_FQDN_WEB` |
|
||||||
|
|
||||||
|
**`prod-logs` env vars:**
|
||||||
|
- `PI_COOLIFY_HOST` — Coolify server base URL (e.g., `https://coolify.example.com`)
|
||||||
|
- `PI_COOLIFY_APP_UUID` — Application UUID in Coolify
|
||||||
|
- `PI_COOLIFY_TOKEN` — Coolify API Bearer token
|
||||||
|
|
||||||
|
**`prod-errors` env vars:**
|
||||||
|
- `PI_API_TOKEN` — Must match the `API_TOKEN` env var on the production server (used for Bearer auth on `/api/v1/*`)
|
||||||
|
- `PI_SERVICE_FQDN_WEB` — Production domain with protocol (e.g., `https://musiclibrary.claudio-ortolina.org`, no trailing slash)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Email
|
## Email
|
||||||
|
|||||||
Reference in New Issue
Block a user