diff --git a/app/api/shape/route.ts b/app/api/shape/route.ts index 25c392a..e4f7dec 100644 --- a/app/api/shape/route.ts +++ b/app/api/shape/route.ts @@ -1,11 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { shape } from "@/lib/shape"; +import { reportShapeError } from "@/lib/monitoring"; import type { ShapeEngine, ShapeProfile } from "@/lib/types"; const VALID_PROFILES = ["narrative_segment_v0", "concept_blob_v0"]; const VALID_ENGINES = ["openai", "local"]; export async function POST(request: NextRequest) { + let engineForReport: ShapeEngine | "unknown" = "unknown"; + let profileForReport: ShapeProfile | "unknown" | "default" = "unknown"; try { const body = await request.json(); const text = body?.text; @@ -27,11 +30,17 @@ export async function POST(request: NextRequest) { engine && VALID_ENGINES.includes(engine) ? (engine as ShapeEngine) : "openai"; + engineForReport = engineOverride; + profileForReport = profileOverride ?? "default"; const result = await shape(text, profileOverride, engineOverride); return NextResponse.json(result); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Unknown error"; + await reportShapeError(err, "POST /api/shape", { + engine: engineForReport, + profile: profileForReport, + }); return NextResponse.json({ error: message }, { status: 500 }); } } diff --git a/lib/engine.ts b/lib/engine.ts index b0f769e..bd940ec 100644 --- a/lib/engine.ts +++ b/lib/engine.ts @@ -1,6 +1,7 @@ import { buildSystemPrompt } from "./prompt"; import { runLocalShape } from "./local-engine"; import { runOpenAIShapePrompt } from "./model"; +import { reportShapeError } from "./monitoring"; import type { ShapeEngine, ShapeProfile } from "./types"; export async function runShapeEngine({ @@ -21,7 +22,12 @@ export async function runShapeEngine({ try { return JSON.parse(raw); - } catch { + } catch (err) { + await reportShapeError(err, "runShapeEngine.parseModelJson", { + engine, + profile, + raw_preview: raw.slice(0, 200), + }); throw new Error("Model returned invalid JSON"); } } diff --git a/lib/monitoring.ts b/lib/monitoring.ts new file mode 100644 index 0000000..b16a67f --- /dev/null +++ b/lib/monitoring.ts @@ -0,0 +1,68 @@ +// Production error monitoring for Shape's critical paths. +// +// When SHAPE_MONITORING_DSN is configured, server-side errors in the +// Shape API route and engine are reported to the monitoring backend +// (Sentry-compatible payload). When the DSN is unset, this module +// is a no-op so local development and unauthenticated previews don't +// emit telemetry. +// +// Use reportShapeError(err, context, extra?) at every catch site that +// sits on a request path. Without it, model failures, JSON parse +// errors, and provider outages disappear into a 500 with no alerting. + +const DSN = process.env.SHAPE_MONITORING_DSN || ""; +const RELEASE = process.env.SHAPE_RELEASE || "dev"; +const ENVIRONMENT = process.env.SHAPE_ENVIRONMENT || process.env.NODE_ENV || "unknown"; + +export function isMonitoringEnabled(): boolean { + return DSN !== ""; +} + +export interface ShapeErrorContext { + context: string; + release: string; + environment: string; + message: string; + stack?: string; + timestamp: string; + [key: string]: unknown; +} + +/** + * Send a structured error report to the monitoring backend. + * + * Fire-and-forget. Failures inside the monitor itself MUST NOT crash the + * caller — a request path that depends on its own alerting being healthy + * is its own outage source. + */ +export async function reportShapeError( + error: unknown, + context: string, + additionalData?: Record, +): Promise { + if (!isMonitoringEnabled()) { + return; + } + const payload: ShapeErrorContext = { + context, + release: RELEASE, + environment: ENVIRONMENT, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + ...(additionalData ?? {}), + }; + try { + await fetch(DSN, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + keepalive: true, + }); + } catch { + // Monitoring egress failures should not break the request path. + // The local console line below survives regardless of DSN state + // so a developer tailing logs still sees the original error. + console.error(`[shape:monitor:dropped] ${context}:`, error); + } +}