Skip to content

Commit 1166d82

Browse files
feat(logs): add Logs block for querying execution logs from workflows (#4442)
* feat(logs): add Logs block for querying execution logs from workflows * fix(logs): guard transformResponse on non-2xx and correct executionMetadata description - Add response.ok check in all three logs tools' transformResponse so a 4xx/5xx body cannot be silently treated as a success payload (defense in depth; the executor already throws on non-2xx before transform runs). - Drop totalTokens from executionMetadata description in block and tool outputs since the snapshot route does not emit it.
1 parent 9eeb1b2 commit 1166d82

10 files changed

Lines changed: 561 additions & 10 deletions

File tree

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@ import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { getLogDetailContract } from '@/lib/api/contracts/logs'
44
import { parseRequest } from '@/lib/api/server'
5-
import { getSession } from '@/lib/auth'
5+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
66
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
77
import { fetchLogDetail } from '@/lib/logs/fetch-log-detail'
88

99
const logger = createLogger('LogDetailsByIdAPI')
1010

1111
export const GET = withRouteHandler(
1212
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
13-
const session = await getSession()
14-
if (!session?.user?.id) {
15-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
13+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
14+
if (!authResult.success || !authResult.userId) {
15+
return NextResponse.json(
16+
{ error: authResult.error || 'Authentication required' },
17+
{ status: 401 }
18+
)
1619
}
1720

1821
const parsed = await parseRequest(getLogDetailContract, request, context)
@@ -22,7 +25,7 @@ export const GET = withRouteHandler(
2225
const { workspaceId } = parsed.data.query
2326

2427
const data = await fetchLogDetail({
25-
userId: session.user.id,
28+
userId: authResult.userId,
2629
workspaceId,
2730
lookupColumn: 'id',
2831
lookupValue: id,

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import type { NextRequest } from 'next/server'
2929
import { NextResponse } from 'next/server'
3030
import { listLogsContract, type WorkflowLogSummary } from '@/lib/api/contracts/logs'
3131
import { parseRequest } from '@/lib/api/server'
32-
import { getSession } from '@/lib/auth'
32+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
3333
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
3434
import { buildFilterConditions } from '@/lib/logs/filters'
3535

@@ -58,11 +58,14 @@ function decodeCursor(cursor: string): CursorData | null {
5858
}
5959

6060
export const GET = withRouteHandler(async (request: NextRequest) => {
61-
const session = await getSession()
62-
if (!session?.user?.id) {
63-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
61+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
62+
if (!authResult.success || !authResult.userId) {
63+
return NextResponse.json(
64+
{ error: authResult.error || 'Authentication required' },
65+
{ status: 401 }
66+
)
6467
}
65-
const userId = session.user.id
68+
const userId = authResult.userId
6669

6770
const parsed = await parseRequest(listLogsContract, request, {})
6871
if (!parsed.success) return parsed.response

apps/sim/blocks/blocks/logs.ts

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { Library } from '@/components/emcn/icons'
2+
import type { BlockConfig } from '@/blocks/types'
3+
4+
export const LogsBlock: BlockConfig = {
5+
type: 'logs',
6+
name: 'Logs',
7+
description: 'Query workflow execution logs',
8+
longDescription:
9+
'Search workflow execution logs in the current workspace, fetch a single log by id, or load full execution details with the per-block state snapshot.',
10+
bgColor: '#EAB308',
11+
bestPractices: `
12+
- The block always operates on the current workspace; you cannot query other workspaces.
13+
- 'Query Logs' returns summary rows. To get a full log entry (executionData, files), use 'Get Log by ID' on a row's id.
14+
- Use 'Get Execution Details' (with an executionId) to inspect per-block state for a single run.
15+
- Pagination is cursor-based: pass the previous response's nextCursor as Cursor to fetch the next page.
16+
`,
17+
icon: Library,
18+
category: 'blocks',
19+
docsLink: 'https://docs.sim.ai/api-reference/logs/getExecutionDetails',
20+
subBlocks: [
21+
{
22+
id: 'operation',
23+
title: 'Operation',
24+
type: 'dropdown',
25+
options: [
26+
{ label: 'Query Logs', id: 'query' },
27+
{ label: 'Get Log by ID', id: 'get_log' },
28+
{ label: 'Get Execution Details', id: 'get_execution' },
29+
],
30+
placeholder: 'Select operation',
31+
value: () => 'query',
32+
},
33+
{
34+
id: 'workflowIds',
35+
title: 'Workflow IDs',
36+
type: 'short-input',
37+
placeholder: 'Comma-separated workflow IDs',
38+
condition: { field: 'operation', value: 'query' },
39+
},
40+
{
41+
id: 'executionId',
42+
title: 'Execution ID',
43+
type: 'short-input',
44+
placeholder: 'Filter by a single execution ID',
45+
condition: { field: 'operation', value: 'query' },
46+
},
47+
{
48+
id: 'level',
49+
title: 'Level',
50+
type: 'dropdown',
51+
options: [
52+
{ label: 'All', id: 'all' },
53+
{ label: 'Info', id: 'info' },
54+
{ label: 'Error', id: 'error' },
55+
{ label: 'Running', id: 'running' },
56+
{ label: 'Pending', id: 'pending' },
57+
],
58+
value: () => 'all',
59+
condition: { field: 'operation', value: 'query' },
60+
},
61+
{
62+
id: 'triggers',
63+
title: 'Triggers',
64+
type: 'short-input',
65+
placeholder: 'api,webhook,schedule,manual,chat,mothership',
66+
condition: { field: 'operation', value: 'query' },
67+
},
68+
{
69+
id: 'limit',
70+
title: 'Limit',
71+
type: 'short-input',
72+
placeholder: '100 (max 200)',
73+
condition: { field: 'operation', value: 'query' },
74+
},
75+
{
76+
id: 'startDate',
77+
title: 'Start Date',
78+
type: 'short-input',
79+
placeholder: 'ISO 8601 timestamp',
80+
mode: 'advanced',
81+
wandConfig: {
82+
enabled: true,
83+
prompt:
84+
'Generate an ISO 8601 timestamp from the user description. Return ONLY the timestamp string.',
85+
generationType: 'timestamp',
86+
},
87+
condition: { field: 'operation', value: 'query' },
88+
},
89+
{
90+
id: 'endDate',
91+
title: 'End Date',
92+
type: 'short-input',
93+
placeholder: 'ISO 8601 timestamp',
94+
mode: 'advanced',
95+
wandConfig: {
96+
enabled: true,
97+
prompt:
98+
'Generate an ISO 8601 timestamp from the user description. Return ONLY the timestamp string.',
99+
generationType: 'timestamp',
100+
},
101+
condition: { field: 'operation', value: 'query' },
102+
},
103+
{
104+
id: 'search',
105+
title: 'Search',
106+
type: 'short-input',
107+
placeholder: 'Free-text search',
108+
mode: 'advanced',
109+
condition: { field: 'operation', value: 'query' },
110+
},
111+
{
112+
id: 'sortBy',
113+
title: 'Sort By',
114+
type: 'dropdown',
115+
options: [
116+
{ label: 'Date', id: 'date' },
117+
{ label: 'Duration', id: 'duration' },
118+
{ label: 'Cost', id: 'cost' },
119+
{ label: 'Status', id: 'status' },
120+
],
121+
value: () => 'date',
122+
mode: 'advanced',
123+
condition: { field: 'operation', value: 'query' },
124+
},
125+
{
126+
id: 'sortOrder',
127+
title: 'Sort Order',
128+
type: 'dropdown',
129+
options: [
130+
{ label: 'Descending', id: 'desc' },
131+
{ label: 'Ascending', id: 'asc' },
132+
],
133+
value: () => 'desc',
134+
mode: 'advanced',
135+
condition: { field: 'operation', value: 'query' },
136+
},
137+
{
138+
id: 'cursor',
139+
title: 'Cursor',
140+
type: 'short-input',
141+
placeholder: 'nextCursor from a previous response',
142+
mode: 'advanced',
143+
condition: { field: 'operation', value: 'query' },
144+
},
145+
{
146+
id: 'logId',
147+
title: 'Log ID',
148+
type: 'short-input',
149+
placeholder: 'Log entry ID',
150+
condition: { field: 'operation', value: 'get_log' },
151+
required: true,
152+
},
153+
{
154+
id: 'executionIdLookup',
155+
title: 'Execution ID',
156+
type: 'short-input',
157+
placeholder: 'Execution ID',
158+
condition: { field: 'operation', value: 'get_execution' },
159+
required: true,
160+
},
161+
],
162+
tools: {
163+
access: ['logs_query', 'logs_get', 'logs_get_execution'],
164+
config: {
165+
tool: (params: Record<string, any>) => {
166+
const operation = params.operation || 'query'
167+
if (operation === 'get_log') return 'logs_get'
168+
if (operation === 'get_execution') return 'logs_get_execution'
169+
return 'logs_query'
170+
},
171+
params: (params: Record<string, any>) => {
172+
const operation = params.operation || 'query'
173+
174+
if (operation === 'get_log') {
175+
if (!params.logId) {
176+
throw new Error('Logs Block Error: Log ID is required for get_log operation')
177+
}
178+
return { id: params.logId }
179+
}
180+
181+
if (operation === 'get_execution') {
182+
if (!params.executionIdLookup) {
183+
throw new Error(
184+
'Logs Block Error: Execution ID is required for get_execution operation'
185+
)
186+
}
187+
return { executionId: params.executionIdLookup }
188+
}
189+
190+
const rawLimit =
191+
params.limit !== undefined && params.limit !== null && params.limit !== ''
192+
? Number(params.limit)
193+
: undefined
194+
const limit = Number.isFinite(rawLimit) ? rawLimit : undefined
195+
196+
return {
197+
workflowIds: params.workflowIds || undefined,
198+
executionId: params.executionId || undefined,
199+
level: params.level && params.level !== 'all' ? params.level : undefined,
200+
triggers: params.triggers || undefined,
201+
limit,
202+
startDate: params.startDate || undefined,
203+
endDate: params.endDate || undefined,
204+
search: params.search || undefined,
205+
cursor: params.cursor || undefined,
206+
sortBy: params.sortBy || undefined,
207+
sortOrder: params.sortOrder || undefined,
208+
}
209+
},
210+
},
211+
},
212+
inputs: {
213+
operation: { type: 'string', description: 'Operation to perform' },
214+
workflowIds: { type: 'string', description: 'Comma-separated workflow IDs' },
215+
executionId: { type: 'string', description: 'Execution ID filter (query operation)' },
216+
level: { type: 'string', description: 'Log level filter' },
217+
triggers: { type: 'string', description: 'Comma-separated triggers' },
218+
limit: { type: 'number', description: 'Max logs to return (default 100, max 200)' },
219+
startDate: { type: 'string', description: 'ISO 8601 lower bound' },
220+
endDate: { type: 'string', description: 'ISO 8601 upper bound' },
221+
search: { type: 'string', description: 'Free-text search term' },
222+
sortBy: { type: 'string', description: "'date' | 'duration' | 'cost' | 'status'" },
223+
sortOrder: { type: 'string', description: "'desc' | 'asc'" },
224+
cursor: { type: 'string', description: 'Pagination cursor' },
225+
logId: { type: 'string', description: 'Log entry ID (get_log operation)' },
226+
executionIdLookup: {
227+
type: 'string',
228+
description: 'Execution ID (get_execution operation)',
229+
},
230+
},
231+
outputs: {
232+
logs: { type: 'json', description: 'Array of log summary entries (query operation)' },
233+
nextCursor: {
234+
type: 'string',
235+
description: 'Cursor for next page; null when no more results (query operation)',
236+
},
237+
log: { type: 'json', description: 'Full log entry (get_log operation)' },
238+
executionId: { type: 'string', description: 'Execution ID (get_execution operation)' },
239+
workflowId: { type: 'string', description: 'Workflow ID (get_execution operation)' },
240+
workflowState: {
241+
type: 'json',
242+
description: 'Per-block state snapshot (get_execution operation)',
243+
},
244+
childWorkflowSnapshots: {
245+
type: 'json',
246+
description: 'Snapshots for child workflows (get_execution operation)',
247+
},
248+
executionMetadata: {
249+
type: 'json',
250+
description: 'Trigger, timestamps, totalDurationMs, cost (get_execution operation)',
251+
},
252+
},
253+
}

apps/sim/blocks/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ import { LemlistBlock } from '@/blocks/blocks/lemlist'
113113
import { LinearBlock, LinearV2Block } from '@/blocks/blocks/linear'
114114
import { LinkedInBlock } from '@/blocks/blocks/linkedin'
115115
import { LinkupBlock } from '@/blocks/blocks/linkup'
116+
import { LogsBlock } from '@/blocks/blocks/logs'
116117
import { LoopsBlock } from '@/blocks/blocks/loops'
117118
import { LumaBlock } from '@/blocks/blocks/luma'
118119
import { MailchimpBlock } from '@/blocks/blocks/mailchimp'
@@ -361,6 +362,7 @@ export const registry: Record<string, BlockConfig> = {
361362
linear_v2: LinearV2Block,
362363
linkedin: LinkedInBlock,
363364
linkup: LinkupBlock,
365+
logs: LogsBlock,
364366
loops: LoopsBlock,
365367
luma: LumaBlock,
366368
mailchimp: MailchimpBlock,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { LogsGetExecutionParams, LogsGetExecutionResponse } from '@/tools/logs/types'
2+
import type { ToolConfig } from '@/tools/types'
3+
4+
export const logsGetExecutionTool: ToolConfig<LogsGetExecutionParams, LogsGetExecutionResponse> = {
5+
id: 'logs_get_execution',
6+
name: 'Get Execution Details',
7+
description:
8+
'Fetch full execution details for a workflow run, including the per-block state snapshot.',
9+
version: '1.0.0',
10+
11+
params: {
12+
executionId: {
13+
type: 'string',
14+
required: true,
15+
visibility: 'user-or-llm',
16+
description: 'Execution ID returned by a workflow run',
17+
},
18+
},
19+
20+
request: {
21+
url: (params) => `/api/logs/execution/${encodeURIComponent(params.executionId)}`,
22+
method: 'GET',
23+
headers: () => ({
24+
'Content-Type': 'application/json',
25+
}),
26+
},
27+
28+
transformResponse: async (response): Promise<LogsGetExecutionResponse> => {
29+
const data = await response.json()
30+
if (!response.ok) {
31+
throw new Error(data?.error || `Request failed with status ${response.status}`)
32+
}
33+
return {
34+
success: true,
35+
output: data,
36+
}
37+
},
38+
39+
outputs: {
40+
executionId: { type: 'string', description: 'Execution ID' },
41+
workflowId: { type: 'string', description: 'Workflow ID this execution belongs to' },
42+
workflowState: { type: 'json', description: 'Per-block state snapshot for the execution' },
43+
childWorkflowSnapshots: {
44+
type: 'json',
45+
description: 'Snapshots for any child workflows invoked during the run',
46+
optional: true,
47+
},
48+
executionMetadata: {
49+
type: 'json',
50+
description: 'Trigger, timestamps, totalDurationMs, and cost for the run',
51+
},
52+
},
53+
}

0 commit comments

Comments
 (0)