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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user