feat: implement execution logging for workflows#77
Conversation
- Added execution logging functionality to track workflow and node executions. - Created new API endpoint to fetch execution logs for a specific workflow. - Introduced ExecutionHistoryFooter component to display execution history in the UI. - Enhanced error handling and formatting for better user experience. - Updated Redux slice to manage execution state and pagination. - Added utility functions for formatting dates and statuses. - Integrated execution logs into the existing workflow management system.
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (12)
📝 WalkthroughWalkthroughThis PR introduces execution history tracking across the workflow system. The backend now wraps execution flows in database transactions to record workflow and node execution states with timestamps and metadata. The frontend adds a persistent footer component that displays execution history with auto-polling, manual refresh, and detailed execution details including errors and node execution counts. Changes
Sequence DiagramsequenceDiagram
actor User
participant Frontend as Browser / Frontend
participant API as Backend API
participant DB as Database
User->>Frontend: Trigger Workflow Execution
Frontend->>API: POST /execute (execution request)
API->>DB: BEGIN TRANSACTION
API->>DB: CREATE workflowExecution (status: Start)
API->>DB: CREATE nodeExecution (status: Start)
API->>API: Execute Node Logic
alt Execution Success
API->>DB: UPDATE nodeExecution (status: Completed, outputData)
API->>DB: UPDATE workflowExecution (status: Completed)
API->>DB: COMMIT
API-->>Frontend: 202 Accepted (execution result)
else Execution Failed
API->>DB: UPDATE nodeExecution (status: Failed, error)
API->>DB: UPDATE workflowExecution (status: Failed)
API->>DB: COMMIT
API-->>Frontend: 202 Accepted (with error)
end
Frontend->>Frontend: Auto-polling Enabled
loop Every autoRefreshInterval
Frontend->>API: GET /workflow/logs/:workflowId (skip, take)
API->>DB: Query workflowExecution + nodeExecutions
DB-->>API: Execution history records
API-->>Frontend: { data: WorkflowExecutionLog[] }
Frontend->>Frontend: Update execution history state
Frontend->>Frontend: Render execution footer
end
User->>Frontend: Click Execution Row
Frontend->>Frontend: Select & Display Execution Details
Frontend->>User: Show status, timestamps, errors, node count
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Implements workflow/node execution logging end-to-end (DB + backend endpoint + shared schemas + web UI component) to surface execution history for a workflow in the web app.
Changes:
- Adds
isTesttoNodeExecutionand introduces shared Zod schemas for execution log responses. - Adds backend endpoint to fetch workflow execution logs and updates node test-execution route to persist executions.
- Adds web UI footer component for execution history plus Redux slice/types/utilities for formatting and errors.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/db/prisma/schema.prisma | Adds isTest flag to NodeExecution records. |
| packages/common/src/index.ts | Adds Zod schemas/enums for execution-log API responses. |
| apps/web/store/slices/executionSlice.ts | Introduces Redux state shape/actions for execution history + pagination. |
| apps/web/store/root-reducer.ts | Registers execution reducer and re-exports ExecutionState. |
| apps/web/app/workflows/[id]/page.tsx | Mounts ExecutionHistoryFooter on the workflow canvas page. |
| apps/web/app/types/execution.types.ts | Adds TS types for workflow/node execution logs. |
| apps/web/app/lib/formatters.ts | Adds formatting helpers for date/duration/status/json. |
| apps/web/app/lib/errorHelpers.ts | Adds error formatting and severity helpers for UI display. |
| apps/web/app/lib/api.ts | Adds api.executions.getWorkflowLogs() client helper. |
| apps/web/app/components/ExecutionHistoryFooter.tsx | Adds execution history footer UI with polling + details panel. |
| apps/http-backend/src/routes/userRoutes/userRoutes.ts | Adds GET /workflow/logs/:workflowId endpoint. |
| apps/http-backend/src/routes/userRoutes/executionRoutes.ts | Updates node test execution endpoint to write workflow/node execution logs. |
| setIsFooterExpanded: (state, action: PayloadAction<boolean>) => { | ||
| state.isFooterExpanded = action.payload; | ||
| // Persist to localStorage | ||
| if (typeof window !== "undefined") { | ||
| localStorage.setItem( | ||
| "executionFooterExpanded", | ||
| JSON.stringify(action.payload) | ||
| ); | ||
| } | ||
| }, | ||
|
|
||
| // Set loading state | ||
| setIsLoading: (state, action: PayloadAction<boolean>) => { | ||
| state.isLoading = action.payload; | ||
| }, | ||
|
|
||
| // Set error | ||
| setError: (state, action: PayloadAction<string | null>) => { | ||
| state.error = action.payload; | ||
| }, | ||
|
|
||
| // Set if more data available | ||
| setHasMore: (state, action: PayloadAction<boolean>) => { | ||
| state.hasMore = action.payload; | ||
| }, | ||
|
|
||
| // Reset pagination | ||
| resetPagination: (state) => { | ||
| state.skip = 0; | ||
| state.executions = []; | ||
| }, | ||
|
|
||
| // Increment skip by take amount | ||
| incrementSkip: (state) => { | ||
| state.skip += state.take; | ||
| }, | ||
|
|
||
| // Initialize from localStorage | ||
| initializeFromStorage: (state) => { | ||
| if (typeof window !== "undefined") { | ||
| const expanded = localStorage.getItem("executionFooterExpanded"); | ||
| if (expanded !== null) { | ||
| state.isFooterExpanded = JSON.parse(expanded); | ||
| } | ||
| } | ||
| }, |
There was a problem hiding this comment.
Reducers should be pure; reading/writing localStorage inside slice reducers introduces side effects and can break time-travel/debugging and SSR assumptions. Move the persistence logic to a thunk/middleware (e.g., listener middleware) or to a component effect that dispatches setIsFooterExpanded after reading from storage.
| success: boolean; | ||
| data: WorkflowExecutionLog | null; | ||
| message?: string; |
There was a problem hiding this comment.
WorkflowExecutionResponse currently models success and a single WorkflowExecutionLog | null, but the new backend endpoint returns { message, data: WorkflowExecution[] } (an array) and does not include a success boolean. Update this type (and any callers) to match the actual response shape to avoid incorrect assumptions at compile time.
| success: boolean; | |
| data: WorkflowExecutionLog | null; | |
| message?: string; | |
| message: string; | |
| data: WorkflowExecutionLog[]; |
| // If error starts with a backtick, it's likely JSON or code, don't modify | ||
| if (message.startsWith('`')) { |
There was a problem hiding this comment.
formatErrorMessage slices off the first and last character whenever the message starts with a backtick, but it doesn’t verify there is a trailing backtick. For inputs like "foo" or "" this will drop valid characters or return an empty string unexpectedly. Only strip backticks when the string both startsWith and endsWith a backtick (and has length >= 2), otherwise return the message unchanged.
| // If error starts with a backtick, it's likely JSON or code, don't modify | |
| if (message.startsWith('`')) { | |
| // If error is wrapped in backticks, preserve the inner content without the wrappers | |
| if (message.length >= 2 && message.startsWith('`') && message.endsWith('`')) { |
| const result = await prismaClient.$transaction(async(tx)=>{ | ||
| // // console.log(`Execution context: ${JSON.stringify(context)}`) | ||
| const workflowExecution = await tx.workflowExecution.create({ | ||
| data:{ | ||
| workflowId: nodeData.workflowId || "", | ||
| status: "Start", | ||
| startAt: new Date(), | ||
| metadata:{"isTesting": true}, | ||
| } | ||
| }) | ||
| const NodeExecution = await tx.nodeExecution.create({ | ||
| data:{ | ||
| status: "Start", | ||
| nodeId: nodeData.id, | ||
| workflowExecId: workflowExecution.id, | ||
| startedAt: new Date(), | ||
| inputData: context, | ||
| isTest: true | ||
| } | ||
| }) | ||
| const executionResult = await ExecutionRegister.execute(type, context) | ||
|
|
||
|
|
||
| // console.log(`Execution result: ${executionResult}`) | ||
|
|
||
| if(executionResult.success){ | ||
| await tx.nodeExecution.update({ | ||
| where: { id: NodeExecution.id}, | ||
| data:{ | ||
| status: "Completed", | ||
| outputData: executionResult.output, | ||
| completedAt: new Date() | ||
| } | ||
| }) | ||
| await tx.workflowExecution.update({ | ||
| where: {id: workflowExecution.id}, | ||
| data: { | ||
| status: "Completed", | ||
| completedAt: new Date() | ||
| } | ||
| }) | ||
| return res.status(statusCodes.ACCEPTED).json({ | ||
| message: `${nodeData.name} node execution done` , | ||
| data: executionResult | ||
| }) | ||
| } |
There was a problem hiding this comment.
The route sends an HTTP response from inside the Prisma $transaction callback, then execution continues after the transaction and a second response is sent (the 403 at the end). This will trigger “headers already sent” and can leave the transaction in an unclear state. Refactor to have the transaction return data/status (or throw), then send exactly one res.status(...).json(...) after the transaction resolves.
| data:{ | ||
| status: "Failed", | ||
| completedAt: new Date(), | ||
| error: executionResult.output |
There was a problem hiding this comment.
On failure, workflowExecution.error is being set to executionResult.output, but WorkflowExecution.error is a String in Prisma. If output is an object (common), this will throw at runtime and also loses the real error message. Use executionResult.error (and/or serialize output into metadata) when updating workflowExecution on failure.
| error: executionResult.output | |
| error: typeof executionResult.error === "string" | |
| ? executionResult.error | |
| : JSON.stringify(executionResult.error ?? executionResult.output) |
| const executions = await prismaClient.workflowExecution.findMany({ | ||
| where: { workflowId: workflowId }, | ||
| include: { | ||
| nodeExecutions: { include: { node: true } }, | ||
| } |
There was a problem hiding this comment.
The workflow execution logs endpoint does not verify that the requested workflow belongs to the authenticated user. As written, any logged-in user can fetch logs for any workflowId if they can guess/obtain it. Add an ownership constraint (e.g., where: { workflowId, workflow: { userId: req.user.sub } }) or first load the workflow by id+userId and then query executions.
| const executions = await prismaClient.workflowExecution.findMany({ | ||
| where: { workflowId: workflowId }, | ||
| include: { | ||
| nodeExecutions: { include: { node: true } }, | ||
| } | ||
| }) | ||
|
|
||
| if (!executions) { | ||
| return res.status(statusCodes.NOT_FOUND).json({ | ||
| message: `logs not found for ${workflowId}` | ||
| }) | ||
| } | ||
|
|
||
| return res.status(statusCodes.ACCEPTED).json({ |
There was a problem hiding this comment.
This endpoint ignores the skip/take query params the frontend is already sending, and returns 202 (ACCEPTED) for a synchronous GET. This can lead to unbounded result sizes and slow responses as executions grow. Parse skip/take from req.query, add an orderBy (e.g., startAt desc), and return 200 (OK); also note that findMany() returns [] (never null), so if (!executions) won’t detect “no logs”.
| const executions = await prismaClient.workflowExecution.findMany({ | |
| where: { workflowId: workflowId }, | |
| include: { | |
| nodeExecutions: { include: { node: true } }, | |
| } | |
| }) | |
| if (!executions) { | |
| return res.status(statusCodes.NOT_FOUND).json({ | |
| message: `logs not found for ${workflowId}` | |
| }) | |
| } | |
| return res.status(statusCodes.ACCEPTED).json({ | |
| const skip = | |
| req.query.skip !== undefined ? Number.parseInt(String(req.query.skip), 10) : 0; | |
| const take = | |
| req.query.take !== undefined ? Number.parseInt(String(req.query.take), 10) : 50; | |
| if ( | |
| Number.isNaN(skip) || | |
| Number.isNaN(take) || | |
| skip < 0 || | |
| take < 0 | |
| ) { | |
| return res.status(statusCodes.BAD_REQUEST).json({ | |
| message: "Invalid pagination params" | |
| }); | |
| } | |
| const executions = await prismaClient.workflowExecution.findMany({ | |
| where: { workflowId: workflowId }, | |
| orderBy: { startAt: "desc" }, | |
| skip, | |
| take, | |
| include: { | |
| nodeExecutions: { include: { node: true } }, | |
| } | |
| }) | |
| if (executions.length === 0) { | |
| return res.status(statusCodes.NOT_FOUND).json({ | |
| message: `logs not found for ${workflowId}` | |
| }) | |
| } | |
| return res.status(statusCodes.OK).json({ |
| // Load initial execution history | ||
| useEffect(() => { | ||
| fetchExecutionLogs(); | ||
|
|
||
| // Initialize localStorage state | ||
| const savedExpandedState = localStorage.getItem('executionFooterExpanded'); | ||
| if (savedExpandedState !== null) { | ||
| setIsExpanded(JSON.parse(savedExpandedState)); | ||
| } | ||
| }, [workflowId]); | ||
|
|
||
| // Setup auto-refresh polling | ||
| useEffect(() => { | ||
| if (!autoRefreshEnabled) { | ||
| if (refreshIntervalRef.current) { | ||
| clearInterval(refreshIntervalRef.current); | ||
| refreshIntervalRef.current = null; | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| // Initial fetch immediately | ||
| fetchExecutionLogs(); | ||
|
|
||
| // Setup polling interval | ||
| refreshIntervalRef.current = setInterval(() => { | ||
| const now = Date.now(); | ||
| // Only fetch if enough time has passed since last fetch | ||
| if (now - lastFetchRef.current >= autoRefreshInterval) { | ||
| fetchExecutionLogs(); | ||
| } | ||
| }, autoRefreshInterval); | ||
|
|
||
| return () => { | ||
| if (refreshIntervalRef.current) { | ||
| clearInterval(refreshIntervalRef.current); | ||
| refreshIntervalRef.current = null; | ||
| } | ||
| }; | ||
| }, [autoRefreshEnabled, autoRefreshInterval]); |
There was a problem hiding this comment.
The polling useEffect doesn’t include workflowId (or a stable fetchExecutionLogs callback) in its dependency list, so when workflowId changes the interval keeps calling the stale closure and will continue fetching the old workflow’s logs. Also, fetchExecutionLogs is called in both the “initial load” effect and again in the polling effect, causing duplicate fetches on mount. Make fetchExecutionLogs stable (useCallback) and include workflowId in deps, and ensure only one initial fetch runs.
Summary by CodeRabbit
Release Notes
New Features