Skip to content

Commit afef76d

Browse files
committed
improvement(logs): obj storage backed tracespans
1 parent 53eaa60 commit afef76d

40 files changed

Lines changed: 19650 additions & 699 deletions

File tree

apps/sim/app/api/logs/execution/[executionId]/route.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { executionIdParamsSchema } from '@/lib/api/contracts/logs'
1313
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
1414
import { generateRequestId } from '@/lib/core/utils/request'
1515
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
16+
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
1617
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
1718

1819
const logger = createLogger('LogsByExecutionIdAPI')
@@ -39,13 +40,14 @@ export const GET = withRouteHandler(
3940
.select({
4041
id: workflowExecutionLogs.id,
4142
workflowId: workflowExecutionLogs.workflowId,
43+
workspaceId: workflowExecutionLogs.workspaceId,
4244
executionId: workflowExecutionLogs.executionId,
4345
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
4446
trigger: workflowExecutionLogs.trigger,
4547
startedAt: workflowExecutionLogs.startedAt,
4648
endedAt: workflowExecutionLogs.endedAt,
4749
totalDurationMs: workflowExecutionLogs.totalDurationMs,
48-
cost: workflowExecutionLogs.cost,
50+
costTotal: workflowExecutionLogs.costTotal,
4951
executionData: workflowExecutionLogs.executionData,
5052
})
5153
.from(workflowExecutionLogs)
@@ -119,7 +121,14 @@ export const GET = withRouteHandler(
119121
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
120122
}
121123

122-
const executionData = workflowLog.executionData as WorkflowExecutionLog['executionData']
124+
const executionData = (await materializeExecutionData(
125+
workflowLog.executionData as Record<string, unknown> | null,
126+
{
127+
workspaceId: workflowLog.workspaceId,
128+
workflowId: workflowLog.workflowId,
129+
executionId: workflowLog.executionId,
130+
}
131+
)) as WorkflowExecutionLog['executionData']
123132
const traceSpans = (executionData?.traceSpans as TraceSpan[]) || []
124133
const childSnapshotIds = new Set<string>()
125134
const collectSnapshotIds = (spans: TraceSpan[]) => {
@@ -163,7 +172,7 @@ export const GET = withRouteHandler(
163172
startedAt: workflowLog.startedAt.toISOString(),
164173
endedAt: workflowLog.endedAt?.toISOString(),
165174
totalDurationMs: workflowLog.totalDurationMs,
166-
cost: workflowLog.cost || null,
175+
cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null,
167176
},
168177
}
169178

apps/sim/app/api/logs/export/route.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { createLogger } from '@sim/logger'
44
import { and, desc, eq, sql } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { getSession } from '@/lib/auth'
7+
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
78
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
810
import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters'
911
import { expandFolderIdsWithDescendants } from '@/lib/logs/folder-expansion'
1012

@@ -41,7 +43,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4143
startedAt: workflowExecutionLogs.startedAt,
4244
endedAt: workflowExecutionLogs.endedAt,
4345
totalDurationMs: workflowExecutionLogs.totalDurationMs,
44-
cost: workflowExecutionLogs.cost,
46+
costTotal: workflowExecutionLogs.costTotal,
4547
executionData: workflowExecutionLogs.executionData,
4648
workflowName: sql<string>`COALESCE(${workflow.name}, 'Deleted Workflow')`,
4749
}
@@ -96,28 +98,41 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
9698

9799
if (!rows.length) break
98100

99-
for (const r of rows as any[]) {
101+
// Heavy execution data may live in object storage; materialize per
102+
// row with bounded concurrency so a 1000-row page doesn't fan out
103+
// into 1000 simultaneous reads.
104+
const materialized = await mapWithConcurrency(
105+
rows as any[],
106+
MATERIALIZE_CONCURRENCY,
107+
(r) =>
108+
materializeExecutionData(r.executionData as Record<string, unknown> | null, {
109+
workspaceId: params.workspaceId,
110+
workflowId: r.workflowId,
111+
executionId: r.executionId,
112+
})
113+
)
114+
115+
for (let j = 0; j < rows.length; j++) {
116+
const r = rows[j] as any
117+
const ed = materialized[j] as Record<string, any>
100118
let message = ''
101119
let traces: any = null
102-
try {
103-
const ed = (r as any).executionData
104-
if (ed) {
105-
if (ed.finalOutput)
106-
message =
107-
typeof ed.finalOutput === 'string'
108-
? ed.finalOutput
109-
: JSON.stringify(ed.finalOutput)
110-
if (ed.message) message = ed.message
111-
if (ed.traceSpans) traces = ed.traceSpans
112-
}
113-
} catch {}
120+
if (ed) {
121+
if (ed.finalOutput)
122+
message =
123+
typeof ed.finalOutput === 'string'
124+
? ed.finalOutput
125+
: JSON.stringify(ed.finalOutput)
126+
if (ed.message) message = ed.message
127+
if (ed.traceSpans) traces = ed.traceSpans
128+
}
114129
const line = [
115130
escapeCsv(r.startedAt?.toISOString?.() || r.startedAt),
116131
escapeCsv(r.level),
117132
escapeCsv(r.workflowName),
118133
escapeCsv(r.trigger),
119134
escapeCsv(r.totalDurationMs ?? ''),
120-
escapeCsv(r.cost?.total ?? r.cost?.value?.total ?? ''),
135+
escapeCsv(r.costTotal ?? ''),
121136
escapeCsv(r.workflowId ?? ''),
122137
escapeCsv(r.executionId ?? ''),
123138
escapeCsv(message),

apps/sim/app/api/logs/route.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
8181
case 'duration':
8282
return sql`${workflowExecutionLogs.totalDurationMs}`
8383
case 'cost':
84-
return sql`(${workflowExecutionLogs.cost}->>'total')::numeric`
84+
// Indexed projection of the usage_log ledger (dollars); no live aggregation.
85+
return sql`${workflowExecutionLogs.costTotal}`
8586
case 'status':
8687
return sql`${workflowExecutionLogs.status}`
8788
default:
@@ -201,7 +202,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
201202
startedAt: workflowExecutionLogs.startedAt,
202203
endedAt: workflowExecutionLogs.endedAt,
203204
totalDurationMs: workflowExecutionLogs.totalDurationMs,
204-
cost: workflowExecutionLogs.cost,
205+
costTotal: workflowExecutionLogs.costTotal,
205206
createdAt: workflowExecutionLogs.createdAt,
206207
workflowName: workflow.name,
207208
workflowDescription: workflow.description,
@@ -379,7 +380,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
379380
}
380381
: null,
381382
jobTitle: null,
382-
cost: (log.cost as WorkflowLogSummary['cost']) ?? null,
383+
// List cost is the cost_total projection (faithful ledger sum). Null until
384+
// completion (running) or until the one-time legacy backfill populates it.
385+
cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
383386
pauseSummary: {
384387
status: log.pausedStatus ?? null,
385388
total: totalPauseCount,

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server'
77
import { v1GetLogContract } from '@/lib/api/contracts/v1/logs'
88
import { parseRequest } from '@/lib/api/server'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
1011
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
1112
import {
1213
checkRateLimit,
@@ -50,7 +51,7 @@ export const GET = withRouteHandler(
5051
endedAt: workflowExecutionLogs.endedAt,
5152
totalDurationMs: workflowExecutionLogs.totalDurationMs,
5253
executionData: workflowExecutionLogs.executionData,
53-
cost: workflowExecutionLogs.cost,
54+
costTotal: workflowExecutionLogs.costTotal,
5455
files: workflowExecutionLogs.files,
5556
createdAt: workflowExecutionLogs.createdAt,
5657
workflowName: workflow.name,
@@ -101,8 +102,15 @@ export const GET = withRouteHandler(
101102
totalDurationMs: log.totalDurationMs,
102103
files: log.files || undefined,
103104
workflow: workflowSummary,
104-
executionData: log.executionData as any,
105-
cost: log.cost as any,
105+
executionData: (await materializeExecutionData(
106+
log.executionData as Record<string, unknown> | null,
107+
{
108+
workspaceId: log.workspaceId,
109+
workflowId: log.workflowId,
110+
executionId: log.executionId,
111+
}
112+
)) as any,
113+
cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
106114
createdAt: log.createdAt.toISOString(),
107115
}
108116

apps/sim/app/api/v1/logs/executions/[executionId]/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ export const GET = withRouteHandler(
7070
startedAt: workflowLog.startedAt.toISOString(),
7171
endedAt: workflowLog.endedAt?.toISOString(),
7272
totalDurationMs: workflowLog.totalDurationMs,
73-
cost: workflowLog.cost || null,
73+
// Sourced from the cost_total projection of the usage_log ledger
74+
// (the deprecated cost jsonb column was dropped).
75+
cost: workflowLog.costTotal != null ? { total: Number(workflowLog.costTotal) } : null,
7476
},
7577
}
7678

apps/sim/app/api/v1/logs/filters.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,18 +84,19 @@ export function buildLogFilters(filters: LogFilters): SQL<unknown> {
8484
conditions.push(lte(workflowExecutionLogs.totalDurationMs, filters.maxDurationMs))
8585
}
8686

87-
// Cost filters
87+
// Cost filters — indexed projection of the usage_log ledger (dollars).
8888
if (filters.minCost !== undefined) {
89-
conditions.push(sql`(${workflowExecutionLogs.cost}->>'total')::numeric >= ${filters.minCost}`)
89+
conditions.push(sql`${workflowExecutionLogs.costTotal} >= ${filters.minCost}`)
9090
}
9191

9292
if (filters.maxCost !== undefined) {
93-
conditions.push(sql`(${workflowExecutionLogs.cost}->>'total')::numeric <= ${filters.maxCost}`)
93+
conditions.push(sql`${workflowExecutionLogs.costTotal} <= ${filters.maxCost}`)
9494
}
9595

96-
// Model filter
96+
// Model filter — uses the models_used projection (includes zero-cost/BYOK
97+
// models, which the usage_log ledger drops), preserving prior behavior.
9798
if (filters.model) {
98-
conditions.push(sql`${workflowExecutionLogs.cost}->>'models' ? ${filters.model}`)
99+
conditions.push(sql`${workflowExecutionLogs.modelsUsed} @> ARRAY[${filters.model}]::text[]`)
99100
}
100101

101102
// Combine all conditions with AND

apps/sim/app/api/v1/logs/route.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { and, eq, sql } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { v1ListLogsContract } from '@/lib/api/contracts/v1/logs'
88
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
9+
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
910
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
1012
import { buildLogFilters, getOrderBy } from '@/app/api/v1/logs/filters'
1113
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
1214
import {
@@ -103,14 +105,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
103105
.select({
104106
id: workflowExecutionLogs.id,
105107
workflowId: workflowExecutionLogs.workflowId,
108+
workspaceId: workflowExecutionLogs.workspaceId,
106109
executionId: workflowExecutionLogs.executionId,
107110
deploymentVersionId: workflowExecutionLogs.deploymentVersionId,
108111
level: workflowExecutionLogs.level,
109112
trigger: workflowExecutionLogs.trigger,
110113
startedAt: workflowExecutionLogs.startedAt,
111114
endedAt: workflowExecutionLogs.endedAt,
112115
totalDurationMs: workflowExecutionLogs.totalDurationMs,
113-
cost: workflowExecutionLogs.cost,
116+
costTotal: workflowExecutionLogs.costTotal,
114117
files: workflowExecutionLogs.files,
115118
executionData: params.details === 'full' ? workflowExecutionLogs.executionData : sql`null`,
116119
workflowName: workflow.name,
@@ -144,7 +147,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
144147
})
145148
}
146149

147-
const formattedLogs = data.map((log) => {
150+
// Only materialize externalized execution data when the response actually
151+
// needs it (details=full + finalOutput/traceSpans requested).
152+
const needsMaterialize =
153+
params.details === 'full' && (params.includeFinalOutput || params.includeTraceSpans)
154+
155+
const formattedLogs = await mapWithConcurrency(data, MATERIALIZE_CONCURRENCY, async (log) => {
148156
const result: any = {
149157
id: log.id,
150158
workflowId: log.workflowId,
@@ -155,7 +163,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
155163
startedAt: log.startedAt.toISOString(),
156164
endedAt: log.endedAt?.toISOString() || null,
157165
totalDurationMs: log.totalDurationMs,
158-
cost: log.cost ? { total: (log.cost as any).total } : null,
166+
cost: log.costTotal != null ? { total: Number(log.costTotal) } : null,
159167
files: log.files || null,
160168
}
161169

@@ -167,12 +175,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
167175
deleted: !log.workflowName,
168176
}
169177

170-
if (log.cost) {
171-
result.cost = log.cost
172-
}
173-
174-
if (log.executionData) {
175-
const execData = log.executionData as any
178+
if (needsMaterialize && log.executionData) {
179+
const execData = (await materializeExecutionData(
180+
log.executionData as Record<string, unknown> | null,
181+
{
182+
workspaceId: log.workspaceId,
183+
workflowId: log.workflowId,
184+
executionId: log.executionId,
185+
}
186+
)) as any
176187
if (params.includeFinalOutput && execData.finalOutput) {
177188
result.finalOutput = execData.finalOutput
178189
}

apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from '@/lib/api/contracts/workflows'
1010
import { parseRequest } from '@/lib/api/server'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12+
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
1213
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
1314
import type { PausePoint } from '@/executor/types'
1415

@@ -117,14 +118,15 @@ export const GET = withRouteHandler(
117118
.select({
118119
executionId: workflowExecutionLogs.executionId,
119120
workflowId: workflowExecutionLogs.workflowId,
121+
workspaceId: workflowExecutionLogs.workspaceId,
120122
status: workflowExecutionLogs.status,
121123
level: workflowExecutionLogs.level,
122124
trigger: workflowExecutionLogs.trigger,
123125
startedAt: workflowExecutionLogs.startedAt,
124126
endedAt: workflowExecutionLogs.endedAt,
125127
totalDurationMs: workflowExecutionLogs.totalDurationMs,
126128
executionData: workflowExecutionLogs.executionData,
127-
cost: workflowExecutionLogs.cost,
129+
costTotal: workflowExecutionLogs.costTotal,
128130
})
129131
.from(workflowExecutionLogs)
130132
.where(
@@ -177,13 +179,20 @@ export const GET = withRouteHandler(
177179
}
178180
}
179181

180-
const cost = logRow.cost
181-
? { total: Number((logRow.cost as { total?: number }).total ?? 0) }
182-
: null
182+
const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null
183183

184-
const error = status === 'failed' ? extractError(logRow.executionData) : null
184+
// Heavy execution data may live in object storage; resolve the pointer
185+
// before reading error / finalOutput / traceSpans (no-op for inline rows).
186+
const executionData = (await materializeExecutionData(
187+
logRow.executionData as Record<string, unknown> | null,
188+
{
189+
workspaceId: logRow.workspaceId,
190+
workflowId: logRow.workflowId,
191+
executionId: logRow.executionId,
192+
}
193+
)) as ExecutionDataShape | undefined
185194

186-
const executionData = logRow.executionData as ExecutionDataShape | undefined
195+
const error = status === 'failed' ? extractError(executionData) : null
187196

188197
const finalOutput =
189198
includeOutput && status === 'completed' && executionData

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ const MIN_BAR_PCT = 0.5
5555

5656
interface TraceViewProps {
5757
traceSpans: TraceSpan[]
58+
/**
59+
* Authoritative, multiplier-inclusive run cost (dollars) from the persisted
60+
* execution log. When provided it drives the header credit chip so the Trace
61+
* tab and the Overview cost breakdown can never show different totals. Falls
62+
* back to the root span's own cost only when absent (e.g. live previews).
63+
*/
64+
runCostDollars?: number
5865
}
5966

6067
interface FlatSpanEntry {
@@ -812,7 +819,7 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
812819
* in a way that mirrors the executor's internal structure so investigators can
813820
* follow block-by-block and segment-by-segment what happened and why.
814821
*/
815-
export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps) {
822+
export const TraceView = memo(function TraceView({ traceSpans, runCostDollars }: TraceViewProps) {
816823
const treeRef = useRef<HTMLDivElement>(null)
817824
const [searchQuery, setSearchQuery] = useState('')
818825
const [treePaneWidth, setTreePaneWidth] = useState(DEFAULT_TREE_PANE_WIDTH)
@@ -1021,7 +1028,7 @@ export const TraceView = memo(function TraceView({ traceSpans }: TraceViewProps)
10211028
{blockCount} {blockCount === 1 ? 'span' : 'spans'}
10221029
</span>
10231030
{(() => {
1024-
const rootCost = formatCostAmount(normalizedSpans[0]?.cost?.total)
1031+
const rootCost = formatCostAmount(runCostDollars ?? normalizedSpans[0]?.cost?.total)
10251032
return rootCost ? (
10261033
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption tabular-nums'>
10271034
{rootCost}

0 commit comments

Comments
 (0)