import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { Prisma } from "@agentlens/database"; // Types interface DecisionPointPayload { id: string; type: "TOOL_SELECTION" | "ROUTING" | "RETRY" | "ESCALATION" | "MEMORY_RETRIEVAL" | "PLANNING" | "CUSTOM"; reasoning?: string; chosen: Prisma.JsonValue; alternatives: Prisma.JsonValue[]; contextSnapshot?: Prisma.JsonValue; durationMs?: number; costUsd?: number; parentSpanId?: string; timestamp: string; } interface SpanPayload { id: string; parentSpanId?: string; name: string; type: "LLM_CALL" | "TOOL_CALL" | "MEMORY_OP" | "CHAIN" | "AGENT" | "CUSTOM"; input?: Prisma.JsonValue; output?: Prisma.JsonValue; tokenCount?: number; costUsd?: number; durationMs?: number; status: "RUNNING" | "COMPLETED" | "ERROR"; statusMessage?: string; startedAt: string; endedAt?: string; metadata?: Prisma.JsonValue; } interface EventPayload { id: string; spanId?: string; type: "ERROR" | "RETRY" | "FALLBACK" | "CONTEXT_OVERFLOW" | "USER_FEEDBACK" | "CUSTOM"; name: string; metadata?: Prisma.JsonValue; timestamp: string; } interface TracePayload { id: string; name: string; sessionId?: string; status: "RUNNING" | "COMPLETED" | "ERROR"; tags: string[]; metadata?: Prisma.JsonValue; totalCost?: number; totalTokens?: number; totalDuration?: number; startedAt: string; endedAt?: string; decisionPoints: DecisionPointPayload[]; spans: SpanPayload[]; events: EventPayload[]; } interface BatchTracesRequest { traces: TracePayload[]; } // POST /api/traces — Batch ingest traces from SDK export async function POST(request: NextRequest) { try { // Validate Authorization header const authHeader = request.headers.get("authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) { return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 }); } const apiKey = authHeader.slice(7); if (!apiKey) { return NextResponse.json({ error: "Missing API key in Authorization header" }, { status: 401 }); } // Parse and validate request body const body: BatchTracesRequest = await request.json(); if (!body.traces || !Array.isArray(body.traces)) { return NextResponse.json({ error: "Request body must contain a 'traces' array" }, { status: 400 }); } if (body.traces.length === 0) { return NextResponse.json({ error: "Traces array cannot be empty" }, { status: 400 }); } // Validate each trace payload for (const trace of body.traces) { if (!trace.id || typeof trace.id !== "string") { return NextResponse.json({ error: "Each trace must have a valid 'id' (string)" }, { status: 400 }); } if (!trace.name || typeof trace.name !== "string") { return NextResponse.json({ error: "Each trace must have a valid 'name' (string)" }, { status: 400 }); } if (!trace.startedAt || typeof trace.startedAt !== "string") { return NextResponse.json({ error: "Each trace must have a valid 'startedAt' (ISO date string)" }, { status: 400 }); } if (!["RUNNING", "COMPLETED", "ERROR"].includes(trace.status)) { return NextResponse.json({ error: `Invalid trace status: ${trace.status}` }, { status: 400 }); } if (!Array.isArray(trace.tags)) { return NextResponse.json({ error: "Trace tags must be an array" }, { status: 400 }); } if (!Array.isArray(trace.decisionPoints)) { return NextResponse.json({ error: "Trace decisionPoints must be an array" }, { status: 400 }); } if (!Array.isArray(trace.spans)) { return NextResponse.json({ error: "Trace spans must be an array" }, { status: 400 }); } if (!Array.isArray(trace.events)) { return NextResponse.json({ error: "Trace events must be an array" }, { status: 400 }); } // Validate decision points for (const dp of trace.decisionPoints) { if (!dp.id || typeof dp.id !== "string") { return NextResponse.json({ error: "Each decision point must have a valid 'id' (string)" }, { status: 400 }); } if (!["TOOL_SELECTION", "ROUTING", "RETRY", "ESCALATION", "MEMORY_RETRIEVAL", "PLANNING", "CUSTOM"].includes(dp.type)) { return NextResponse.json({ error: `Invalid decision point type: ${dp.type}` }, { status: 400 }); } if (!dp.timestamp || typeof dp.timestamp !== "string") { return NextResponse.json({ error: "Each decision point must have a valid 'timestamp' (ISO date string)" }, { status: 400 }); } if (!Array.isArray(dp.alternatives)) { return NextResponse.json({ error: "Decision point alternatives must be an array" }, { status: 400 }); } } // Validate spans for (const span of trace.spans) { if (!span.id || typeof span.id !== "string") { return NextResponse.json({ error: "Each span must have a valid 'id' (string)" }, { status: 400 }); } if (!span.name || typeof span.name !== "string") { return NextResponse.json({ error: "Each span must have a valid 'name' (string)" }, { status: 400 }); } if (!["LLM_CALL", "TOOL_CALL", "MEMORY_OP", "CHAIN", "AGENT", "CUSTOM"].includes(span.type)) { return NextResponse.json({ error: `Invalid span type: ${span.type}` }, { status: 400 }); } if (!span.startedAt || typeof span.startedAt !== "string") { return NextResponse.json({ error: "Each span must have a valid 'startedAt' (ISO date string)" }, { status: 400 }); } if (!["RUNNING", "COMPLETED", "ERROR"].includes(span.status)) { return NextResponse.json({ error: `Invalid span status: ${span.status}` }, { status: 400 }); } } // Validate events for (const event of trace.events) { if (!event.id || typeof event.id !== "string") { return NextResponse.json({ error: "Each event must have a valid 'id' (string)" }, { status: 400 }); } if (!event.name || typeof event.name !== "string") { return NextResponse.json({ error: "Each event must have a valid 'name' (string)" }, { status: 400 }); } if (!["ERROR", "RETRY", "FALLBACK", "CONTEXT_OVERFLOW", "USER_FEEDBACK", "CUSTOM"].includes(event.type)) { return NextResponse.json({ error: `Invalid event type: ${event.type}` }, { status: 400 }); } if (!event.timestamp || typeof event.timestamp !== "string") { return NextResponse.json({ error: "Each event must have a valid 'timestamp' (ISO date string)" }, { status: 400 }); } } } // Insert traces using transaction const result = await prisma.$transaction( body.traces.map((trace) => prisma.trace.create({ data: { id: trace.id, name: trace.name, sessionId: trace.sessionId, status: trace.status, tags: trace.tags, metadata: trace.metadata as Prisma.InputJsonValue, totalCost: trace.totalCost, totalTokens: trace.totalTokens, totalDuration: trace.totalDuration, startedAt: new Date(trace.startedAt), endedAt: trace.endedAt ? new Date(trace.endedAt) : null, decisionPoints: { create: trace.decisionPoints.map((dp) => ({ id: dp.id, type: dp.type, reasoning: dp.reasoning, chosen: dp.chosen as Prisma.InputJsonValue, alternatives: dp.alternatives as Prisma.InputJsonValue[], contextSnapshot: dp.contextSnapshot as Prisma.InputJsonValue | undefined, durationMs: dp.durationMs, costUsd: dp.costUsd, parentSpanId: dp.parentSpanId, timestamp: new Date(dp.timestamp), })), }, spans: { create: trace.spans.map((span) => ({ id: span.id, parentSpanId: span.parentSpanId, name: span.name, type: span.type, input: span.input as Prisma.InputJsonValue | undefined, output: span.output as Prisma.InputJsonValue | undefined, tokenCount: span.tokenCount, costUsd: span.costUsd, durationMs: span.durationMs, status: span.status, statusMessage: span.statusMessage, startedAt: new Date(span.startedAt), endedAt: span.endedAt ? new Date(span.endedAt) : null, metadata: span.metadata as Prisma.InputJsonValue | undefined, })), }, events: { create: trace.events.map((event) => ({ id: event.id, spanId: event.spanId, type: event.type, name: event.name, metadata: event.metadata as Prisma.InputJsonValue | undefined, timestamp: new Date(event.timestamp), })), }, }, }) ) ); return NextResponse.json({ success: true, count: result.length }, { status: 200 }); } catch (error) { if (error instanceof SyntaxError) { return NextResponse.json({ error: "Invalid JSON in request body" }, { status: 400 }); } // Handle unique constraint violations if (error instanceof Error && error.message.includes("Unique constraint")) { return NextResponse.json({ error: "Duplicate trace ID detected" }, { status: 409 }); } console.error("Error processing traces:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } // GET /api/traces — List traces with pagination export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); const page = parseInt(searchParams.get("page") ?? "1", 10); const limit = parseInt(searchParams.get("limit") ?? "20", 10); const status = searchParams.get("status"); const search = searchParams.get("search"); const sessionId = searchParams.get("sessionId"); // Validate pagination parameters if (isNaN(page) || page < 1) { return NextResponse.json({ error: "Invalid page parameter. Must be a positive integer." }, { status: 400 }); } if (isNaN(limit) || limit < 1 || limit > 100) { return NextResponse.json({ error: "Invalid limit parameter. Must be between 1 and 100." }, { status: 400 }); } // Validate status parameter if provided const validStatuses = ["RUNNING", "COMPLETED", "ERROR"]; if (status && !validStatuses.includes(status)) { return NextResponse.json({ error: `Invalid status. Must be one of: ${validStatuses.join(", ")}` }, { status: 400 }); } // Build where clause const where: Record = {}; if (status) { where.status = status; } if (search) { where.name = { contains: search, mode: "insensitive", }; } if (sessionId) { where.sessionId = sessionId; } // Count total traces const total = await prisma.trace.count({ where }); // Calculate pagination const skip = (page - 1) * limit; const totalPages = Math.ceil(total / limit); // Fetch traces with pagination const traces = await prisma.trace.findMany({ where, include: { _count: { select: { decisionPoints: true, spans: true, events: true, }, }, }, orderBy: { startedAt: "desc", }, skip, take: limit, }); return NextResponse.json({ traces, total, page, limit, totalPages, }, { status: 200 }); } catch (error) { console.error("Error listing traces:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } }