Skip to content

Commit a2627cc

Browse files
improvement(logs): expose trace spans on log detail
1 parent e1a8a24 commit a2627cc

5 files changed

Lines changed: 148 additions & 8 deletions

File tree

apps/docs/openapi-v2-logs.json

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@
291291
"get": {
292292
"operationId": "getLog",
293293
"summary": "Get Log",
294-
"description": "Retrieve a single log entry by its ID, including the workflow metadata captured at execution time, the materialized execution data, and the cost summary. Returns `{ data }`.",
294+
"description": "Retrieve a single log entry by its ID, including workflow metadata, materialized execution data, a top-level `traceSpans` array, and the cost summary. Returns `{ data }`.",
295295
"tags": ["Logs"],
296296
"x-codeSamples": [
297297
{
@@ -315,7 +315,7 @@
315315
],
316316
"responses": {
317317
"200": {
318-
"description": "The requested log entry with full execution data and cost summary.",
318+
"description": "The requested log entry with full execution data, trace spans, and cost summary.",
319319
"headers": {
320320
"X-RateLimit-Limit": {
321321
"$ref": "#/components/headers/X-RateLimit-Limit"
@@ -366,6 +366,7 @@
366366
"result": "Hello, world!"
367367
}
368368
},
369+
"traceSpans": [],
369370
"cost": {
370371
"total": 0.0032
371372
},
@@ -733,7 +734,7 @@
733734
},
734735
"LogDetail": {
735736
"type": "object",
736-
"description": "Detailed log entry with full workflow metadata, materialized execution data, and cost summary.",
737+
"description": "Detailed log entry with full workflow metadata, materialized execution data, top-level trace spans, and cost summary.",
737738
"required": [
738739
"id",
739740
"workflowId",
@@ -746,6 +747,7 @@
746747
"files",
747748
"workflow",
748749
"executionData",
750+
"traceSpans",
749751
"cost",
750752
"createdAt"
751753
],
@@ -823,6 +825,14 @@
823825
}
824826
}
825827
},
828+
"traceSpans": {
829+
"type": "array",
830+
"description": "Materialized block-level execution trace spans with timing, inputs, and outputs. Empty when the run has no spans.",
831+
"items": {
832+
"type": "object",
833+
"additionalProperties": true
834+
}
835+
},
826836
"cost": {
827837
"$ref": "#/components/schemas/Cost"
828838
},
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
6+
import { NextRequest } from 'next/server'
7+
import { beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const {
10+
mockCheckRateLimit,
11+
mockResolveWorkspaceAccess,
12+
mockLoadActiveFolderPathIndex,
13+
mockMaterializeExecutionData,
14+
} = vi.hoisted(() => ({
15+
mockCheckRateLimit: vi.fn(),
16+
mockResolveWorkspaceAccess: vi.fn(),
17+
mockLoadActiveFolderPathIndex: vi.fn(),
18+
mockMaterializeExecutionData: vi.fn(),
19+
}))
20+
21+
vi.mock('@/app/api/v1/middleware', () => ({
22+
checkRateLimit: mockCheckRateLimit,
23+
resolveWorkspaceAccess: mockResolveWorkspaceAccess,
24+
}))
25+
26+
vi.mock('@/app/api/v2/lib/gate', () => ({
27+
v2ApiGateError: vi.fn().mockResolvedValue(null),
28+
}))
29+
30+
vi.mock('@/lib/folders/queries', () => ({
31+
loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex,
32+
}))
33+
34+
vi.mock('@/lib/logs/execution/trace-store', () => ({
35+
materializeExecutionData: mockMaterializeExecutionData,
36+
}))
37+
38+
import { GET } from '@/app/api/v2/logs/[id]/route'
39+
40+
const RATE_LIMIT_OK = {
41+
allowed: true,
42+
userId: 'user-1',
43+
keyType: 'workspace',
44+
limit: 100,
45+
remaining: 99,
46+
resetAt: new Date('2024-01-01T01:00:00Z'),
47+
}
48+
49+
const LOG_ROW = {
50+
id: 'log-1',
51+
workflowId: 'workflow-1',
52+
workspaceId: 'workspace-1',
53+
executionId: 'execution-1',
54+
level: 'info',
55+
trigger: 'api',
56+
startedAt: new Date('2024-01-01T00:00:00Z'),
57+
endedAt: new Date('2024-01-01T00:00:01Z'),
58+
totalDurationMs: 1000,
59+
executionData: { stored: true },
60+
costTotal: '0.01',
61+
files: null,
62+
createdAt: new Date('2024-01-01T00:00:00Z'),
63+
workflowName: 'Support Agent',
64+
workflowDescription: 'Handles support requests',
65+
workflowFolderId: null,
66+
workflowUserId: 'user-1',
67+
workflowWorkspaceId: 'workspace-1',
68+
workflowCreatedAt: new Date('2023-12-01T00:00:00Z'),
69+
workflowUpdatedAt: new Date('2023-12-02T00:00:00Z'),
70+
workflowArchivedAt: null,
71+
}
72+
73+
const routeContext = () => ({ params: Promise.resolve({ id: 'log-1' }) })
74+
75+
function callGet() {
76+
return GET(new NextRequest('http://localhost:3000/api/v2/logs/log-1'), routeContext())
77+
}
78+
79+
describe('GET /api/v2/logs/[id]', () => {
80+
beforeEach(() => {
81+
vi.clearAllMocks()
82+
resetDbChainMock()
83+
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
84+
mockResolveWorkspaceAccess.mockResolvedValue(null)
85+
mockLoadActiveFolderPathIndex.mockResolvedValue({ pathById: new Map() })
86+
dbChainMockFns.limit.mockResolvedValue([LOG_ROW])
87+
})
88+
89+
it('returns materialized trace spans as a first-class log detail field', async () => {
90+
const traceSpans = [
91+
{
92+
id: 'span-1',
93+
name: 'Agent',
94+
type: 'agent',
95+
durationMs: 1000,
96+
status: 'success',
97+
output: { answer: 'done' },
98+
},
99+
]
100+
mockMaterializeExecutionData.mockResolvedValue({
101+
traceSpans,
102+
finalOutput: { answer: 'done' },
103+
})
104+
105+
const response = await callGet()
106+
const body = await response.json()
107+
108+
expect(response.status).toBe(200)
109+
expect(body.data.traceSpans).toEqual(traceSpans)
110+
expect(body.data.executionData.traceSpans).toEqual(traceSpans)
111+
})
112+
113+
it('returns an empty trace span array when the execution has no spans', async () => {
114+
mockMaterializeExecutionData.mockResolvedValue({ finalOutput: { answer: 'done' } })
115+
116+
const response = await callGet()
117+
const body = await response.json()
118+
119+
expect(response.status).toBe(200)
120+
expect(body.data.traceSpans).toEqual([])
121+
})
122+
})

apps/sim/app/api/v2/logs/[id]/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getErrorMessage } from '@sim/utils/errors'
55
import { generateId } from '@sim/utils/id'
66
import { eq } from 'drizzle-orm'
77
import type { NextRequest } from 'next/server'
8+
import { traceSpansSchema } from '@/lib/api/contracts/logs'
89
import { type V2LogDetail, v2GetLogContract } from '@/lib/api/contracts/v2/logs'
910
import { parseRequest } from '@/lib/api/server'
1011
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -80,6 +81,7 @@ export const GET = withRouteHandler(
8081
log.executionData as Record<string, unknown> | null,
8182
{ workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId }
8283
)
84+
const traceSpans = traceSpansSchema.parse(executionData.traceSpans ?? [])
8385

8486
const detail: V2LogDetail = {
8587
id: log.id,
@@ -105,6 +107,7 @@ export const GET = withRouteHandler(
105107
deleted: !log.workflowName || log.workflowArchivedAt !== null,
106108
},
107109
executionData,
110+
traceSpans,
108111
cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
109112
createdAt: log.createdAt.toISOString(),
110113
}

apps/sim/lib/api/contracts/logs.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ const toolCallSchema = z
161161
})
162162
.passthrough()
163163

164-
type TraceSpan = {
164+
export type LogTraceSpan = {
165165
id: string
166166
name: string
167167
type: string
@@ -176,10 +176,10 @@ type TraceSpan = {
176176
tokens?: number | { total?: number; input?: number; output?: number }
177177
relativeStartMs?: number
178178
toolCalls?: Array<z.output<typeof toolCallSchema>>
179-
children?: TraceSpan[]
179+
children?: LogTraceSpan[]
180180
}
181181

182-
const traceSpanSchema: z.ZodType<TraceSpan> = z.lazy(() =>
182+
export const traceSpanSchema: z.ZodType<LogTraceSpan> = z.lazy(() =>
183183
z
184184
.object({
185185
id: z.string(),
@@ -212,11 +212,13 @@ const traceSpanSchema: z.ZodType<TraceSpan> = z.lazy(() =>
212212
.passthrough()
213213
)
214214

215+
export const traceSpansSchema = z.array(traceSpanSchema)
216+
215217
const executionDataDetailSchema = z
216218
.object({
217219
totalDuration: z.number().nullable().optional(),
218220
enhanced: z.literal(true).optional(),
219-
traceSpans: z.array(traceSpanSchema).optional(),
221+
traceSpans: traceSpansSchema.optional(),
220222
blockExecutions: z.array(blockExecutionSchema).optional(),
221223
finalOutput: z.unknown().optional(),
222224
workflowInput: z.unknown().optional(),

apps/sim/lib/api/contracts/v2/logs.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod'
2+
import { traceSpansSchema } from '@/lib/api/contracts/logs'
23
import { defineRouteContract } from '@/lib/api/contracts/types'
34
import {
45
v1ExecutionParamsSchema,
@@ -47,7 +48,7 @@ export const v2LogListItemSchema = z.object({
4748
/** Present only when `details=full` and `includeFinalOutput=true`. */
4849
finalOutput: z.unknown().optional(),
4950
/** Present only when `details=full` and `includeTraceSpans=true`. */
50-
traceSpans: z.unknown().optional(),
51+
traceSpans: traceSpansSchema.optional(),
5152
})
5253

5354
export type V2LogListItem = z.output<typeof v2LogListItemSchema>
@@ -75,6 +76,8 @@ export const v2LogDetailSchema = z.object({
7576
}),
7677
/** Materialized execution trace (block states, trace spans). */
7778
executionData: z.unknown(),
79+
/** Materialized block-level execution trace spans. */
80+
traceSpans: traceSpansSchema,
7881
cost: v2LogCostSchema,
7982
createdAt: z.string(),
8083
})

0 commit comments

Comments
 (0)