ML-163: implementation

This commit is contained in:
Claudio Ortolina
2026-05-04 13:25:08 +01:00
parent 51bd24d5b4
commit 904f540031
4 changed files with 616 additions and 2 deletions
+555
View File
@@ -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,
},
};
},
});
}
+5
View File
@@ -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
title: Create pi tools to fetch production errors
status: To Do
status: Done
assignee: []
created_date: '2026-05-04 08:08'
updated_date: '2026-05-04 09:34'
updated_date: '2026-05-04 12:22'
labels:
- pi
- 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.
5. Call the tools via the LLM.
<!-- 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 -->
+20
View File
@@ -229,6 +229,26 @@ and the offending batch is dropped. Tracks:
- Oban Web (`/dev/oban`)
- 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