feat: Python SDK real implementation + API ingestion routes
- SDK: client with BatchTransport, trace decorator/context manager, log_decision, thread-local context stack, nested trace→span support - API: POST /api/traces (batch ingest), GET /api/traces (paginated list), GET /api/traces/[id] (full trace with relations), GET /api/health - Tests: 8 unit tests for SDK (all passing) - Transport: thread-safe buffer with background flush thread
This commit is contained in:
324
apps/web/src/app/api/traces/route.ts
Normal file
324
apps/web/src/app/api/traces/route.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
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<string, unknown> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user