fix: trace ingest FK violation on tool-call decisions

Spans must be inserted before decision points due to
DecisionPoint.parentSpanId FK referencing Span.id. Switched from
nested Prisma create to interactive transaction with topological
span ordering. Also adds real MoonshotAI LLM test script.
This commit is contained in:
Vectry
2026-02-10 02:16:10 +00:00
parent 98bfa968ce
commit d91fdfc81a
2 changed files with 432 additions and 36 deletions

View File

@@ -63,6 +63,24 @@ interface BatchTracesRequest {
traces: TracePayload[];
}
function topologicalSortSpans(spans: SpanPayload[]): SpanPayload[] {
const byId = new Map(spans.map((s) => [s.id, s]));
const sorted: SpanPayload[] = [];
const visited = new Set<string>();
function visit(span: SpanPayload) {
if (visited.has(span.id)) return;
visited.add(span.id);
if (span.parentSpanId && byId.has(span.parentSpanId)) {
visit(byId.get(span.parentSpanId)!);
}
sorted.push(span);
}
for (const span of spans) visit(span);
return sorted;
}
// POST /api/traces — Batch ingest traces from SDK
export async function POST(request: NextRequest) {
try {
@@ -166,10 +184,15 @@ export async function POST(request: NextRequest) {
}
}
// Insert traces using transaction
const result = await prisma.$transaction(
body.traces.map((trace) =>
prisma.trace.create({
// Insert traces using interactive transaction to control insert order.
// Spans must be inserted before decision points due to the
// DecisionPoint.parentSpanId FK referencing Span.id.
const result = await prisma.$transaction(async (tx) => {
const created: string[] = [];
for (const trace of body.traces) {
// 1. Create the trace record (no nested relations)
await tx.trace.create({
data: {
id: trace.id,
name: trace.name,
@@ -182,23 +205,18 @@ export async function POST(request: NextRequest) {
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) => ({
},
});
// 2. Create spans FIRST (decision points may reference them via parentSpanId)
if (trace.spans.length > 0) {
// Sort spans so parents come before children
const spanOrder = topologicalSortSpans(trace.spans);
for (const span of spanOrder) {
await tx.span.create({
data: {
id: span.id,
traceId: trace.id,
parentSpanId: span.parentSpanId,
name: span.name,
type: span.type,
@@ -212,22 +230,53 @@ export async function POST(request: NextRequest) {
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),
})),
},
},
})
)
);
},
});
}
}
// 3. Create decision points AFTER spans exist
if (trace.decisionPoints.length > 0) {
// Collect valid span IDs so we can null-out invalid parentSpanId refs
const validSpanIds = new Set(trace.spans.map((s) => s.id));
await tx.decisionPoint.createMany({
data: trace.decisionPoints.map((dp) => ({
id: dp.id,
traceId: trace.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 && validSpanIds.has(dp.parentSpanId) ? dp.parentSpanId : null,
timestamp: new Date(dp.timestamp),
})),
});
}
// 4. Create events
if (trace.events.length > 0) {
await tx.event.createMany({
data: trace.events.map((event) => ({
id: event.id,
traceId: trace.id,
spanId: event.spanId,
type: event.type,
name: event.name,
metadata: event.metadata as Prisma.InputJsonValue | undefined,
timestamp: new Date(event.timestamp),
})),
});
}
created.push(trace.id);
}
return created;
});
return NextResponse.json({ success: true, count: result.length }, { status: 200 });
} catch (error) {