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:
Vectry
2026-02-09 23:25:34 +00:00
parent 9264866d1f
commit 3fe9013838
12 changed files with 1144 additions and 133 deletions

View 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 });
}
}