From 338d695bfd1aee68f16ec22d0091e2109f1864ab Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 4 May 2026 18:33:50 -0700 Subject: [PATCH 1/2] feat(logs): add Logs block for querying execution logs from workflows --- apps/sim/app/api/logs/[id]/route.ts | 15 +- apps/sim/app/api/logs/route.ts | 13 +- apps/sim/blocks/blocks/logs.ts | 232 +++++++++++++++++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/tools/logs/get_execution.ts | 50 ++++++ apps/sim/tools/logs/get_log.ts | 40 +++++ apps/sim/tools/logs/index.ts | 3 + apps/sim/tools/logs/query.ts | 119 ++++++++++++++ apps/sim/tools/logs/types.ts | 45 ++++++ apps/sim/tools/registry.ts | 4 + 10 files changed, 512 insertions(+), 11 deletions(-) create mode 100644 apps/sim/blocks/blocks/logs.ts create mode 100644 apps/sim/tools/logs/get_execution.ts create mode 100644 apps/sim/tools/logs/get_log.ts create mode 100644 apps/sim/tools/logs/index.ts create mode 100644 apps/sim/tools/logs/query.ts create mode 100644 apps/sim/tools/logs/types.ts diff --git a/apps/sim/app/api/logs/[id]/route.ts b/apps/sim/app/api/logs/[id]/route.ts index 575b0867b1a..08c9b18519b 100644 --- a/apps/sim/app/api/logs/[id]/route.ts +++ b/apps/sim/app/api/logs/[id]/route.ts @@ -10,7 +10,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { logIdParamsSchema } from '@/lib/api/contracts/logs' -import { getSession } from '@/lib/auth' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -19,17 +19,20 @@ const logger = createLogger('LogDetailsByIdAPI') export const revalidate = 0 export const GET = withRouteHandler( - async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { + async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() try { - const session = await getSession() - if (!session?.user?.id) { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized log details access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json( + { error: authResult.error || 'Authentication required' }, + { status: 401 } + ) } - const userId = session.user.id + const userId = authResult.userId const { id } = logIdParamsSchema.parse(await params) const rows = await db diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index 27b071be0f3..5c91e26035b 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -27,7 +27,7 @@ import { import { type NextRequest, NextResponse } from 'next/server' import { listLogsQuerySchema } from '@/lib/api/contracts/logs' import { isZodError } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { buildFilterConditions } from '@/lib/logs/filters' @@ -40,13 +40,16 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() try { - const session = await getSession() - if (!session?.user?.id) { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized logs access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json( + { error: authResult.error || 'Authentication required' }, + { status: 401 } + ) } - const userId = session.user.id + const userId = authResult.userId try { const { searchParams } = new URL(request.url) diff --git a/apps/sim/blocks/blocks/logs.ts b/apps/sim/blocks/blocks/logs.ts new file mode 100644 index 00000000000..1a3c66427fb --- /dev/null +++ b/apps/sim/blocks/blocks/logs.ts @@ -0,0 +1,232 @@ +import { Library } from '@/components/emcn/icons' +import type { BlockConfig } from '@/blocks/types' + +export const LogsBlock: BlockConfig = { + type: 'logs', + name: 'Logs', + description: 'Query workflow execution logs', + longDescription: + '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.', + bgColor: '#EAB308', + bestPractices: ` + - The block always operates on the current workspace; you cannot query other workspaces. + - 'Query Logs' returns metadata only by default. Switch the Detail Level to 'Full' only when you specifically need executionData and trace spans — those payloads can be large. + - Use 'Get Execution Details' (with an executionId) to inspect per-block state for a single run. + `, + icon: Library, + category: 'blocks', + docsLink: 'https://docs.sim.ai/api-reference/logs/getExecutionDetails', + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Query Logs', id: 'query' }, + { label: 'Get Log by ID', id: 'get_log' }, + { label: 'Get Execution Details', id: 'get_execution' }, + ], + placeholder: 'Select operation', + value: () => 'query', + }, + { + id: 'workflowIds', + title: 'Workflow IDs', + type: 'short-input', + placeholder: 'Comma-separated workflow IDs', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'executionId', + title: 'Execution ID', + type: 'short-input', + placeholder: 'Filter by a single execution ID', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'level', + title: 'Level', + type: 'dropdown', + options: [ + { label: 'All', id: 'all' }, + { label: 'Info', id: 'info' }, + { label: 'Error', id: 'error' }, + { label: 'Running', id: 'running' }, + { label: 'Pending', id: 'pending' }, + ], + value: () => 'all', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'triggers', + title: 'Triggers', + type: 'short-input', + placeholder: 'api,webhook,schedule,manual,chat,mothership', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'startDate', + title: 'Start Date', + type: 'short-input', + placeholder: 'ISO 8601 timestamp', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp from the user description. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'endDate', + title: 'End Date', + type: 'short-input', + placeholder: 'ISO 8601 timestamp', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp from the user description. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'search', + title: 'Search', + type: 'short-input', + placeholder: 'Free-text search', + mode: 'advanced', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'details', + title: 'Detail Level', + type: 'dropdown', + options: [ + { label: 'Basic', id: 'basic' }, + { label: 'Full (includes executionData)', id: 'full' }, + ], + value: () => 'basic', + mode: 'advanced', + condition: { field: 'operation', value: 'query' }, + }, + { + id: 'logId', + title: 'Log ID', + type: 'short-input', + placeholder: 'Log entry ID', + condition: { field: 'operation', value: 'get_log' }, + required: true, + }, + { + id: 'executionIdLookup', + title: 'Execution ID', + type: 'short-input', + placeholder: 'Execution ID', + condition: { field: 'operation', value: 'get_execution' }, + required: true, + }, + ], + tools: { + access: ['logs_query', 'logs_get', 'logs_get_execution'], + config: { + tool: (params: Record) => { + const operation = params.operation || 'query' + if (operation === 'get_log') return 'logs_get' + if (operation === 'get_execution') return 'logs_get_execution' + return 'logs_query' + }, + params: (params: Record) => { + const operation = params.operation || 'query' + + if (operation === 'get_log') { + if (!params.logId) { + throw new Error('Logs Block Error: Log ID is required for get_log operation') + } + return { id: params.logId } + } + + if (operation === 'get_execution') { + if (!params.executionIdLookup) { + throw new Error( + 'Logs Block Error: Execution ID is required for get_execution operation' + ) + } + return { executionId: params.executionIdLookup } + } + + const rawLimit = + params.limit !== undefined && params.limit !== null && params.limit !== '' + ? Number(params.limit) + : undefined + const userLimit = Number.isFinite(rawLimit) ? rawLimit : undefined + const isFullDetails = params.details === 'full' + const FULL_DETAILS_MAX = 10 + const limit = isFullDetails + ? Math.min(userLimit ?? FULL_DETAILS_MAX, FULL_DETAILS_MAX) + : userLimit + + return { + workflowIds: params.workflowIds || undefined, + executionId: params.executionId || undefined, + level: params.level && params.level !== 'all' ? params.level : undefined, + triggers: params.triggers || undefined, + limit, + startDate: params.startDate || undefined, + endDate: params.endDate || undefined, + search: params.search || undefined, + details: params.details || undefined, + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + workflowIds: { type: 'string', description: 'Comma-separated workflow IDs' }, + executionId: { type: 'string', description: 'Execution ID filter (query operation)' }, + level: { type: 'string', description: 'Log level filter' }, + triggers: { type: 'string', description: 'Comma-separated triggers' }, + limit: { type: 'number', description: 'Max logs to return' }, + startDate: { type: 'string', description: 'ISO 8601 lower bound' }, + endDate: { type: 'string', description: 'ISO 8601 upper bound' }, + search: { type: 'string', description: 'Free-text search term' }, + details: { type: 'string', description: "'basic' or 'full'" }, + logId: { type: 'string', description: 'Log entry ID (get_log operation)' }, + executionIdLookup: { + type: 'string', + description: 'Execution ID (get_execution operation)', + }, + }, + outputs: { + logs: { type: 'json', description: 'Array of log entries (query operation)' }, + total: { type: 'number', description: 'Total matching logs (query operation)' }, + page: { type: 'number', description: 'Current page (query operation)' }, + pageSize: { type: 'number', description: 'Page size (query operation)' }, + totalPages: { type: 'number', description: 'Total pages (query operation)' }, + log: { type: 'json', description: 'Single log entry (get_log operation)' }, + executionId: { type: 'string', description: 'Execution ID (get_execution operation)' }, + workflowId: { type: 'string', description: 'Workflow ID (get_execution operation)' }, + workflowState: { + type: 'json', + description: 'Per-block state snapshot (get_execution operation)', + }, + childWorkflowSnapshots: { + type: 'json', + description: 'Snapshots for child workflows (get_execution operation)', + }, + executionMetadata: { + type: 'json', + description: + 'Trigger, timestamps, totalDurationMs, cost, totalTokens (get_execution operation)', + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index e7ca943af3c..aacf6d49431 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -113,6 +113,7 @@ import { LemlistBlock } from '@/blocks/blocks/lemlist' import { LinearBlock, LinearV2Block } from '@/blocks/blocks/linear' import { LinkedInBlock } from '@/blocks/blocks/linkedin' import { LinkupBlock } from '@/blocks/blocks/linkup' +import { LogsBlock } from '@/blocks/blocks/logs' import { LoopsBlock } from '@/blocks/blocks/loops' import { LumaBlock } from '@/blocks/blocks/luma' import { MailchimpBlock } from '@/blocks/blocks/mailchimp' @@ -361,6 +362,7 @@ export const registry: Record = { linear_v2: LinearV2Block, linkedin: LinkedInBlock, linkup: LinkupBlock, + logs: LogsBlock, loops: LoopsBlock, luma: LumaBlock, mailchimp: MailchimpBlock, diff --git a/apps/sim/tools/logs/get_execution.ts b/apps/sim/tools/logs/get_execution.ts new file mode 100644 index 00000000000..00cbd7d79f1 --- /dev/null +++ b/apps/sim/tools/logs/get_execution.ts @@ -0,0 +1,50 @@ +import type { LogsGetExecutionParams, LogsGetExecutionResponse } from '@/tools/logs/types' +import type { ToolConfig } from '@/tools/types' + +export const logsGetExecutionTool: ToolConfig = { + id: 'logs_get_execution', + name: 'Get Execution Details', + description: + 'Fetch full execution details for a workflow run, including the per-block state snapshot.', + version: '1.0.0', + + params: { + executionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Execution ID returned by a workflow run', + }, + }, + + request: { + url: (params) => `/api/logs/execution/${encodeURIComponent(params.executionId)}`, + method: 'GET', + headers: () => ({ + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response): Promise => { + const data = await response.json() + return { + success: true, + output: data, + } + }, + + outputs: { + executionId: { type: 'string', description: 'Execution ID' }, + workflowId: { type: 'string', description: 'Workflow ID this execution belongs to' }, + workflowState: { type: 'json', description: 'Per-block state snapshot for the execution' }, + childWorkflowSnapshots: { + type: 'json', + description: 'Snapshots for any child workflows invoked during the run', + optional: true, + }, + executionMetadata: { + type: 'json', + description: 'Trigger, timestamps, totalDurationMs, cost, and totalTokens for the run', + }, + }, +} diff --git a/apps/sim/tools/logs/get_log.ts b/apps/sim/tools/logs/get_log.ts new file mode 100644 index 00000000000..84702f341a4 --- /dev/null +++ b/apps/sim/tools/logs/get_log.ts @@ -0,0 +1,40 @@ +import type { LogsGetParams, LogsGetResponse } from '@/tools/logs/types' +import type { ToolConfig } from '@/tools/types' + +export const logsGetTool: ToolConfig = { + id: 'logs_get', + name: 'Get Log by ID', + description: 'Fetch a single workflow execution log entry by its log ID.', + version: '1.0.0', + + params: { + id: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Log entry ID', + }, + }, + + request: { + url: (params) => `/api/logs/${encodeURIComponent(params.id)}`, + method: 'GET', + headers: () => ({ + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response): Promise => { + const result = await response.json() + return { + success: true, + output: { + log: result.data, + }, + } + }, + + outputs: { + log: { type: 'json', description: 'Workflow execution log entry' }, + }, +} diff --git a/apps/sim/tools/logs/index.ts b/apps/sim/tools/logs/index.ts new file mode 100644 index 00000000000..109d223c8b8 --- /dev/null +++ b/apps/sim/tools/logs/index.ts @@ -0,0 +1,3 @@ +export { logsGetExecutionTool } from '@/tools/logs/get_execution' +export { logsGetTool } from '@/tools/logs/get_log' +export { logsQueryTool } from '@/tools/logs/query' diff --git a/apps/sim/tools/logs/query.ts b/apps/sim/tools/logs/query.ts new file mode 100644 index 00000000000..9a65f56f95a --- /dev/null +++ b/apps/sim/tools/logs/query.ts @@ -0,0 +1,119 @@ +import type { LogsQueryParams, LogsQueryResponse } from '@/tools/logs/types' +import type { ToolConfig } from '@/tools/types' + +export const logsQueryTool: ToolConfig = { + id: 'logs_query', + name: 'Query Logs', + description: 'Query workflow execution logs in the current workspace with filters.', + version: '1.0.0', + + params: { + workflowIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated workflow IDs to filter by', + }, + executionId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter logs to a single execution ID', + }, + level: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + "Log level filter: 'all', 'info', 'error', 'running', 'pending'. Comma-separated for multiple.", + }, + triggers: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated triggers (api, webhook, schedule, manual, chat, mothership)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Max logs to return (default 100, max enforced by API)', + }, + startDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ISO 8601 timestamp; only logs at or after this time', + }, + endDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ISO 8601 timestamp; only logs at or before this time', + }, + search: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Free-text search across log fields', + }, + details: { + type: 'string', + required: false, + visibility: 'user-only', + description: + "'basic' (default) returns metadata only; 'full' includes executionData and trace spans (can be large)", + }, + }, + + request: { + url: (params) => { + const workspaceId = params._context?.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required in execution context') + } + const qs = new URLSearchParams({ workspaceId }) + if (params.workflowIds) qs.set('workflowIds', params.workflowIds) + if (params.executionId) qs.set('executionId', params.executionId) + if (params.level && params.level !== 'all') qs.set('level', params.level) + if (params.triggers) qs.set('triggers', params.triggers) + if (params.startDate) qs.set('startDate', params.startDate) + if (params.endDate) qs.set('endDate', params.endDate) + if (params.search) qs.set('search', params.search) + if (params.details) qs.set('details', params.details) + if (params.limit !== undefined && params.limit !== null) { + qs.set('limit', String(params.limit)) + } + return `/api/logs?${qs.toString()}` + }, + method: 'GET', + headers: () => ({ + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response): Promise => { + const result = await response.json() + return { + success: true, + output: { + logs: result.data || [], + total: result.total ?? 0, + page: result.page ?? 1, + pageSize: result.pageSize ?? 0, + totalPages: result.totalPages ?? 0, + }, + } + }, + + outputs: { + logs: { + type: 'array', + description: 'Array of workflow execution log entries', + }, + total: { type: 'number', description: 'Total matching logs in the workspace' }, + page: { type: 'number', description: 'Current page number (1-indexed)' }, + pageSize: { type: 'number', description: 'Page size used for this query' }, + totalPages: { type: 'number', description: 'Total number of pages available' }, + }, +} diff --git a/apps/sim/tools/logs/types.ts b/apps/sim/tools/logs/types.ts new file mode 100644 index 00000000000..16839897fd3 --- /dev/null +++ b/apps/sim/tools/logs/types.ts @@ -0,0 +1,45 @@ +import type { ExecutionSnapshotData, WorkflowLogData } from '@/lib/api/contracts/logs' +import type { ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +export interface LogsQueryParams { + workflowIds?: string + executionId?: string + level?: string + triggers?: string + limit?: number + startDate?: string + endDate?: string + search?: string + details?: 'basic' | 'full' + _context?: WorkflowToolExecutionContext +} + +export interface LogsGetParams { + id: string + _context?: WorkflowToolExecutionContext +} + +export interface LogsGetExecutionParams { + executionId: string + _context?: WorkflowToolExecutionContext +} + +export interface LogsQueryResponse extends ToolResponse { + output: { + logs: WorkflowLogData[] + total: number + page: number + pageSize: number + totalPages: number + } +} + +export interface LogsGetResponse extends ToolResponse { + output: { + log: WorkflowLogData + } +} + +export interface LogsGetExecutionResponse extends ToolResponse { + output: ExecutionSnapshotData +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index ad7dd384867..9130ac52dee 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1568,6 +1568,7 @@ import { import { linkedInGetProfileTool, linkedInSharePostTool } from '@/tools/linkedin' import { linkupSearchTool } from '@/tools/linkup' import { llmChatTool } from '@/tools/llm' +import { logsGetExecutionTool, logsGetTool, logsQueryTool } from '@/tools/logs' import { loopsCreateContactPropertyTool, loopsCreateContactTool, @@ -3204,6 +3205,9 @@ export const tools: Record = { ketch_set_consent: ketchSetConsentTool, ketch_set_subscriptions: ketchSetSubscriptionsTool, linkup_search: linkupSearchTool, + logs_query: logsQueryTool, + logs_get: logsGetTool, + logs_get_execution: logsGetExecutionTool, loops_create_contact: loopsCreateContactTool, loops_create_contact_property: loopsCreateContactPropertyTool, loops_update_contact: loopsUpdateContactTool, From 164afe6480ffd1689aa3ad3e147639f0eaa9d0c2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 4 May 2026 18:48:25 -0700 Subject: [PATCH 2/2] 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. --- apps/sim/blocks/blocks/logs.ts | 3 +-- apps/sim/tools/logs/get_execution.ts | 5 ++++- apps/sim/tools/logs/get_log.ts | 3 +++ apps/sim/tools/logs/query.ts | 3 +++ 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/sim/blocks/blocks/logs.ts b/apps/sim/blocks/blocks/logs.ts index 2f6a60523cc..d7665089f98 100644 --- a/apps/sim/blocks/blocks/logs.ts +++ b/apps/sim/blocks/blocks/logs.ts @@ -247,8 +247,7 @@ export const LogsBlock: BlockConfig = { }, executionMetadata: { type: 'json', - description: - 'Trigger, timestamps, totalDurationMs, cost, totalTokens (get_execution operation)', + description: 'Trigger, timestamps, totalDurationMs, cost (get_execution operation)', }, }, } diff --git a/apps/sim/tools/logs/get_execution.ts b/apps/sim/tools/logs/get_execution.ts index 00cbd7d79f1..a62eef0525b 100644 --- a/apps/sim/tools/logs/get_execution.ts +++ b/apps/sim/tools/logs/get_execution.ts @@ -27,6 +27,9 @@ export const logsGetExecutionTool: ToolConfig => { const data = await response.json() + if (!response.ok) { + throw new Error(data?.error || `Request failed with status ${response.status}`) + } return { success: true, output: data, @@ -44,7 +47,7 @@ export const logsGetExecutionTool: ToolConfig = { transformResponse: async (response): Promise => { const result = await response.json() + if (!response.ok) { + throw new Error(result?.error || `Request failed with status ${response.status}`) + } return { success: true, output: { diff --git a/apps/sim/tools/logs/query.ts b/apps/sim/tools/logs/query.ts index 2df74298d31..8ea660ee29a 100644 --- a/apps/sim/tools/logs/query.ts +++ b/apps/sim/tools/logs/query.ts @@ -107,6 +107,9 @@ export const logsQueryTool: ToolConfig = { transformResponse: async (response): Promise => { const result = await response.json() + if (!response.ok) { + throw new Error(result?.error || `Request failed with status ${response.status}`) + } return { success: true, output: {