feat(logs): added tool calls + time to execute to logs, updated workflow_logs db to include metadata

This commit is contained in:
Waleed Latif
2025-03-15 05:52:24 -07:00
parent dfa9275aa8
commit 7c34004223
24 changed files with 1952 additions and 123 deletions
+45 -2
View File
@@ -7,6 +7,8 @@ const logger = createLogger('ProxyAPI')
export async function POST(request: Request) {
const requestId = crypto.randomUUID().slice(0, 8)
const startTime = new Date()
const startTimeISO = startTime.toISOString()
try {
const { toolId, params } = await request.json()
@@ -26,9 +28,18 @@ export async function POST(request: Request) {
toolId,
error: error instanceof Error ? error.message : String(error),
})
// Add timing information even to error responses
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : String(error),
startTime: startTimeISO,
endTime: endTimeISO,
duration,
})
}
@@ -81,8 +92,32 @@ export async function POST(request: Request) {
}
}
logger.info(`[${requestId}] Tool executed successfully`, { toolId })
return NextResponse.json(result)
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
// Add explicit timing information directly to the response
// This will ensure it's passed to the agent block
const responseWithTimingData = {
...result,
// Add timing data both at root level and in nested timing object
startTime: startTimeISO,
endTime: endTimeISO,
duration,
timing: {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
},
}
logger.info(`[${requestId}] Tool executed successfully`, {
toolId,
duration,
startTime: startTimeISO,
endTime: endTimeISO,
})
return NextResponse.json(responseWithTimingData)
} catch (error: any) {
throw error
}
@@ -91,9 +126,17 @@ export async function POST(request: Request) {
error: error instanceof Error ? error.message : String(error),
})
// Add timing information even to error responses
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : String(error),
startTime: startTimeISO,
endTime: endTimeISO,
duration,
})
}
}
+19 -4
View File
@@ -1,7 +1,7 @@
import { NextRequest } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console-logger'
import { persistLog } from '@/lib/logs/execution-logger'
import { persistExecutionLogs, persistLog } from '@/lib/logs/execution-logger'
import { validateWorkflowAccess } from '../../middleware'
import { createErrorResponse, createSuccessResponse } from '../../utils'
@@ -21,8 +21,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
const body = await request.json()
const { logs, executionId } = body
const { logs, executionId, result } = body
// If result is provided, use persistExecutionLogs for full tool call extraction
if (result) {
logger.info(`[${requestId}] Persisting execution result for workflow: ${id}`, {
executionId,
success: result.success,
})
// Use persistExecutionLogs which handles tool call extraction
await persistExecutionLogs(id, executionId, result, 'manual')
return createSuccessResponse({ message: 'Execution logs persisted successfully' })
}
// Fall back to the original log format if 'result' isn't provided
if (!logs || !Array.isArray(logs) || logs.length === 0) {
logger.warn(`[${requestId}] No logs provided for workflow: ${id}`)
return createErrorResponse('No logs provided', 400)
@@ -32,7 +46,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
executionId,
})
// Persist each log
// Persist each log using the original method
for (const log of logs) {
await persistLog({
id: uuidv4(),
@@ -41,8 +55,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
level: log.level,
message: log.message,
duration: log.duration,
trigger: 'manual',
trigger: log.trigger || 'manual',
createdAt: new Date(log.createdAt || new Date()),
metadata: log.metadata,
})
}
@@ -0,0 +1 @@
ALTER TABLE "workflow_logs" ADD COLUMN "metadata" json;
@@ -0,0 +1,811 @@
{
"id": "ae998729-181f-4ead-bcfe-9abe7ea6046d",
"prevId": "0e9f1d5e-b6fc-4429-ba94-82efa2ef1b21",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.environment": {
"name": "environment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"variables": {
"name": "variables",
"type": "json",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"environment_user_id_user_id_fk": {
"name": "environment_user_id_user_id_fk",
"tableFrom": "environment",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"environment_user_id_unique": {
"name": "environment_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.settings": {
"name": "settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"general": {
"name": "general",
"type": "json",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"settings_user_id_user_id_fk": {
"name": "settings_user_id_user_id_fk",
"tableFrom": "settings",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"settings_user_id_unique": {
"name": "settings_user_id_unique",
"nullsNotDistinct": false,
"columns": ["user_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.waitlist": {
"name": "waitlist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.webhook": {
"name": "webhook",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"workflow_id": {
"name": "workflow_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provider_config": {
"name": "provider_config",
"type": "json",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"path_idx": {
"name": "path_idx",
"columns": [
{
"expression": "path",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"webhook_workflow_id_workflow_id_fk": {
"name": "webhook_workflow_id_workflow_id_fk",
"tableFrom": "webhook",
"tableTo": "workflow",
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.workflow": {
"name": "workflow",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "json",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'#3972F6'"
},
"last_synced": {
"name": "last_synced",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"is_deployed": {
"name": "is_deployed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"deployed_at": {
"name": "deployed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"api_key": {
"name": "api_key",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"workflow_user_id_user_id_fk": {
"name": "workflow_user_id_user_id_fk",
"tableFrom": "workflow",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.workflow_logs": {
"name": "workflow_logs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"workflow_id": {
"name": "workflow_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"execution_id": {
"name": "execution_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"level": {
"name": "level",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "text",
"primaryKey": false,
"notNull": false
},
"trigger": {
"name": "trigger",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"metadata": {
"name": "metadata",
"type": "json",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"workflow_logs_workflow_id_workflow_id_fk": {
"name": "workflow_logs_workflow_id_workflow_id_fk",
"tableFrom": "workflow_logs",
"tableTo": "workflow",
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.workflow_schedule": {
"name": "workflow_schedule",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"workflow_id": {
"name": "workflow_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"cron_expression": {
"name": "cron_expression",
"type": "text",
"primaryKey": false,
"notNull": false
},
"next_run_at": {
"name": "next_run_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"last_ran_at": {
"name": "last_ran_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"trigger_type": {
"name": "trigger_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"workflow_schedule_workflow_id_workflow_id_fk": {
"name": "workflow_schedule_workflow_id_workflow_id_fk",
"tableFrom": "workflow_schedule",
"tableTo": "workflow",
"columnsFrom": ["workflow_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"workflow_schedule_workflow_id_unique": {
"name": "workflow_schedule_workflow_id_unique",
"nullsNotDistinct": false,
"columns": ["workflow_id"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -106,6 +106,13 @@
"when": 1741390762290,
"tag": "0014_nice_dragon_lord",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1742039670528,
"tag": "0015_brief_martin_li",
"breakpoints": true
}
]
}
+1
View File
@@ -86,6 +86,7 @@ export const workflowLogs = pgTable('workflow_logs', {
duration: text('duration'), // Store as text to allow 'NA' for errors
trigger: text('trigger'), // e.g. "api", "schedule", "manual"
createdAt: timestamp('created_at').notNull().defaultNow(),
metadata: json('metadata'), // Optional JSON field for storing additional context like tool calls
})
export const environment = pgTable('environment', {
+31 -3
View File
@@ -272,7 +272,15 @@ export class AgentBlockHandler implements BlockHandler {
},
toolCalls: response.toolCalls
? {
list: response.toolCalls,
list: response.toolCalls.map((tc) => ({
...tc,
// Preserve timing information if available
startTime: tc.startTime,
endTime: tc.endTime,
duration: tc.duration,
input: tc.arguments || tc.input,
output: tc.result || tc.output,
})),
count: response.toolCalls.length,
}
: undefined,
@@ -295,7 +303,17 @@ export class AgentBlockHandler implements BlockHandler {
total: 0,
},
toolCalls: {
list: response.toolCalls || [],
list: response.toolCalls
? response.toolCalls.map((tc) => ({
...tc,
// Preserve timing information if available
startTime: tc.startTime,
endTime: tc.endTime,
duration: tc.duration,
input: tc.arguments || tc.input,
output: tc.result || tc.output,
}))
: [],
count: response.toolCalls?.length || 0,
},
},
@@ -314,7 +332,17 @@ export class AgentBlockHandler implements BlockHandler {
total: 0,
},
toolCalls: {
list: response.toolCalls || [],
list: response.toolCalls
? response.toolCalls.map((tc) => ({
...tc,
// Preserve timing information if available
startTime: tc.startTime,
endTime: tc.endTime,
duration: tc.duration,
input: tc.arguments || tc.input,
output: tc.result || tc.output,
}))
: [],
count: response.toolCalls?.length || 0,
},
},
+598 -6
View File
@@ -15,6 +15,23 @@ export interface LogEntry {
createdAt: Date
duration?: string
trigger?: string
metadata?: ToolCallMetadata | Record<string, any>
}
// Define types for tool call tracking
export interface ToolCallMetadata {
toolCalls?: ToolCall[]
}
export interface ToolCall {
name: string
duration: number // in milliseconds
startTime: string // ISO timestamp
endTime: string // ISO timestamp
status: 'success' | 'error' // Status of the tool call
input?: Record<string, any> // Input parameters (optional)
output?: Record<string, any> // Output data (optional)
error?: string // Error message if status is 'error'
}
export async function persistLog(log: LogEntry) {
@@ -26,17 +43,324 @@ export async function persistLog(log: LogEntry) {
* @param workflowId - The ID of the workflow
* @param executionId - The ID of the execution
* @param result - The execution result
* @param triggerType - The type of trigger (api, webhook, schedule)
* @param triggerType - The type of trigger (api, webhook, schedule, manual)
*/
export async function persistExecutionLogs(
workflowId: string,
executionId: string,
result: ExecutorResult,
triggerType: 'api' | 'webhook' | 'schedule'
triggerType: 'api' | 'webhook' | 'schedule' | 'manual'
) {
try {
// Log each execution step
for (const log of result.logs || []) {
// Check for agent block and tool calls
let metadata: ToolCallMetadata | undefined = undefined
logger.debug('block type', log.blockType)
// If this is an agent block
if (log.blockType === 'agent' && log.output) {
logger.debug('Processing agent block output for tool calls', {
blockId: log.blockId,
blockName: log.blockName,
outputKeys: Object.keys(log.output),
hasToolCalls: !!log.output.toolCalls,
hasResponse: !!log.output.response,
})
// Extract tool calls from different possible structures
const blockStartTime = log.startedAt
const blockEndTime = log.endedAt || new Date().toISOString()
const blockDuration = log.durationMs || 0
let toolCallData: any[] = []
// Case 1: Direct toolCalls array
if (Array.isArray(log.output.toolCalls)) {
logger.debug('Found direct toolCalls array', { count: log.output.toolCalls.length })
// Log raw timing data for debugging
log.output.toolCalls.forEach((tc: any, idx: number) => {
logger.debug(`Tool call ${idx} raw timing data:`, {
name: tc.name,
startTime: tc.startTime,
endTime: tc.endTime,
duration: tc.duration,
timing: tc.timing,
argumentKeys: tc.arguments ? Object.keys(tc.arguments) : undefined,
})
})
toolCallData = log.output.toolCalls.map((toolCall: any) => {
// Extract timing info - try various formats that providers might use
const duration = extractDuration(toolCall)
const timing = extractTimingInfo(
toolCall,
blockStartTime ? new Date(blockStartTime) : undefined,
blockEndTime ? new Date(blockEndTime) : undefined
)
// Log what we extracted
logger.debug(`Tool call timing extracted:`, {
name: toolCall.name,
extracted_duration: duration,
extracted_startTime: timing.startTime,
extracted_endTime: timing.endTime,
})
return {
name: toolCall.name,
duration: duration,
startTime: timing.startTime,
endTime: timing.endTime,
status: toolCall.error ? 'error' : 'success',
input: toolCall.input || toolCall.arguments,
output: toolCall.output || toolCall.result,
error: toolCall.error,
}
})
}
// Case 2: toolCalls with a list array (as seen in the screenshot)
else if (log.output.toolCalls && Array.isArray(log.output.toolCalls.list)) {
logger.debug('Found toolCalls with list array', {
count: log.output.toolCalls.list.length,
})
// Log raw timing data for debugging
log.output.toolCalls.list.forEach((tc: any, idx: number) => {
logger.debug(`Tool call list ${idx} raw timing data:`, {
name: tc.name,
startTime: tc.startTime,
endTime: tc.endTime,
duration: tc.duration,
timing: tc.timing,
argumentKeys: tc.arguments ? Object.keys(tc.arguments) : undefined,
})
})
toolCallData = log.output.toolCalls.list.map((toolCall: any) => {
// Extract timing info - try various formats that providers might use
const duration = extractDuration(toolCall)
const timing = extractTimingInfo(
toolCall,
blockStartTime ? new Date(blockStartTime) : undefined,
blockEndTime ? new Date(blockEndTime) : undefined
)
// Log what we extracted
logger.debug(`Tool call list timing extracted:`, {
name: toolCall.name,
extracted_duration: duration,
extracted_startTime: timing.startTime,
extracted_endTime: timing.endTime,
})
return {
name: toolCall.name,
duration: duration,
startTime: timing.startTime,
endTime: timing.endTime,
status: toolCall.error ? 'error' : 'success',
input: toolCall.arguments || toolCall.input,
output: toolCall.result || toolCall.output,
error: toolCall.error,
}
})
}
// Case 3: Response has toolCalls
else if (log.output.response && log.output.response.toolCalls) {
const toolCalls = Array.isArray(log.output.response.toolCalls)
? log.output.response.toolCalls
: log.output.response.toolCalls.list || []
logger.debug('Found toolCalls in response', { count: toolCalls.length })
// Log raw timing data for debugging
toolCalls.forEach((tc: any, idx: number) => {
logger.debug(`Response tool call ${idx} raw timing data:`, {
name: tc.name,
startTime: tc.startTime,
endTime: tc.endTime,
duration: tc.duration,
timing: tc.timing,
argumentKeys: tc.arguments ? Object.keys(tc.arguments) : undefined,
})
})
toolCallData = toolCalls.map((toolCall: any) => {
// Extract timing info - try various formats that providers might use
const duration = extractDuration(toolCall)
const timing = extractTimingInfo(
toolCall,
blockStartTime ? new Date(blockStartTime) : undefined,
blockEndTime ? new Date(blockEndTime) : undefined
)
// Log what we extracted
logger.debug(`Response tool call timing extracted:`, {
name: toolCall.name,
extracted_duration: duration,
extracted_startTime: timing.startTime,
extracted_endTime: timing.endTime,
})
return {
name: toolCall.name,
duration: duration,
startTime: timing.startTime,
endTime: timing.endTime,
status: toolCall.error ? 'error' : 'success',
input: toolCall.arguments || toolCall.input,
output: toolCall.result || toolCall.output,
error: toolCall.error,
}
})
}
// Case 4: toolCalls is an object and has a list property
else if (
log.output.toolCalls &&
typeof log.output.toolCalls === 'object' &&
log.output.toolCalls.list
) {
const toolCalls = log.output.toolCalls
logger.debug('Found toolCalls object with list property', {
count: toolCalls.list.length,
})
// Log raw timing data for debugging
toolCalls.list.forEach((tc: any, idx: number) => {
logger.debug(`toolCalls object list ${idx} raw timing data:`, {
name: tc.name,
startTime: tc.startTime,
endTime: tc.endTime,
duration: tc.duration,
timing: tc.timing,
argumentKeys: tc.arguments ? Object.keys(tc.arguments) : undefined,
})
})
toolCallData = toolCalls.list.map((toolCall: any) => {
// Extract timing info - try various formats that providers might use
const duration = extractDuration(toolCall)
const timing = extractTimingInfo(
toolCall,
blockStartTime ? new Date(blockStartTime) : undefined,
blockEndTime ? new Date(blockEndTime) : undefined
)
// Log what we extracted
logger.debug(`toolCalls object list timing extracted:`, {
name: toolCall.name,
extracted_duration: duration,
extracted_startTime: timing.startTime,
extracted_endTime: timing.endTime,
})
return {
name: toolCall.name,
duration: duration,
startTime: timing.startTime,
endTime: timing.endTime,
status: toolCall.error ? 'error' : 'success',
input: toolCall.arguments || toolCall.input,
output: toolCall.result || toolCall.output,
error: toolCall.error,
}
})
}
// Case 5: Parse the response string for toolCalls as a last resort
else if (typeof log.output.response === 'string') {
const match = log.output.response.match(/"toolCalls"\s*:\s*({[^}]*}|(\[.*?\]))/s)
if (match) {
try {
const toolCallsJson = JSON.parse(`{${match[0]}}`)
const list = Array.isArray(toolCallsJson.toolCalls)
? toolCallsJson.toolCalls
: toolCallsJson.toolCalls.list || []
logger.debug('Found toolCalls in parsed response string', {
count: list.length,
})
// Log raw timing data for debugging
list.forEach((tc: any, idx: number) => {
logger.debug(`Parsed response ${idx} raw timing data:`, {
name: tc.name,
startTime: tc.startTime,
endTime: tc.endTime,
duration: tc.duration,
timing: tc.timing,
argumentKeys: tc.arguments ? Object.keys(tc.arguments) : undefined,
})
})
toolCallData = list.map((toolCall: any) => {
// Extract timing info - try various formats that providers might use
const duration = extractDuration(toolCall)
const timing = extractTimingInfo(
toolCall,
blockStartTime ? new Date(blockStartTime) : undefined,
blockEndTime ? new Date(blockEndTime) : undefined
)
// Log what we extracted
logger.debug(`Parsed response timing extracted:`, {
name: toolCall.name,
extracted_duration: duration,
extracted_startTime: timing.startTime,
extracted_endTime: timing.endTime,
})
return {
name: toolCall.name,
duration: duration,
startTime: timing.startTime,
endTime: timing.endTime,
status: toolCall.error ? 'error' : 'success',
input: toolCall.arguments || toolCall.input,
output: toolCall.result || toolCall.output,
error: toolCall.error,
}
})
} catch (error) {
logger.error('Error parsing toolCalls from response string', {
error,
response: log.output.response,
})
}
}
}
// Verbose output debugging as a fallback
else {
logger.debug('Could not find tool calls in standard formats, output data:', {
outputSample: JSON.stringify(log.output).substring(0, 500) + '...',
})
}
// Fill in missing timing information
if (toolCallData.length > 0) {
const estimatedToolCalls = estimateToolCallTimings(
toolCallData,
blockStartTime,
blockEndTime,
blockDuration
)
const redactedToolCalls = estimatedToolCalls.map((toolCall) => ({
...toolCall,
input: redactApiKeys(toolCall.input),
}))
metadata = {
toolCalls: redactedToolCalls,
}
logger.debug('Created metadata with tool calls', {
count: redactedToolCalls.length,
})
}
}
await persistLog({
id: uuidv4(),
workflowId,
@@ -48,7 +372,16 @@ export async function persistExecutionLogs(
duration: log.success ? `${log.durationMs}ms` : 'NA',
trigger: triggerType,
createdAt: new Date(log.endedAt || log.startedAt),
metadata,
})
if (metadata) {
logger.debug('Persisted log with metadata', {
logId: uuidv4(),
executionId,
toolCallCount: metadata.toolCalls?.length || 0,
})
}
}
// Calculate total duration from successful block logs
@@ -81,13 +414,13 @@ export async function persistExecutionLogs(
* @param workflowId - The ID of the workflow
* @param executionId - The ID of the execution
* @param error - The error that occurred
* @param triggerType - The type of trigger (api, webhook, schedule)
* @param triggerType - The type of trigger (api, webhook, schedule, manual)
*/
export async function persistExecutionError(
workflowId: string,
executionId: string,
error: Error,
triggerType: 'api' | 'webhook' | 'schedule'
triggerType: 'api' | 'webhook' | 'schedule' | 'manual'
) {
try {
const errorPrefix = getTriggerErrorPrefix(triggerType)
@@ -108,7 +441,7 @@ export async function persistExecutionError(
}
// Helper functions for trigger-specific messages
function getTriggerSuccessMessage(triggerType: 'api' | 'webhook' | 'schedule'): string {
function getTriggerSuccessMessage(triggerType: 'api' | 'webhook' | 'schedule' | 'manual'): string {
switch (triggerType) {
case 'api':
return 'API workflow executed successfully'
@@ -116,12 +449,14 @@ function getTriggerSuccessMessage(triggerType: 'api' | 'webhook' | 'schedule'):
return 'Webhook workflow executed successfully'
case 'schedule':
return 'Scheduled workflow executed successfully'
case 'manual':
return 'Manual workflow executed successfully'
default:
return 'Workflow executed successfully'
}
}
function getTriggerErrorPrefix(triggerType: 'api' | 'webhook' | 'schedule'): string {
function getTriggerErrorPrefix(triggerType: 'api' | 'webhook' | 'schedule' | 'manual'): string {
switch (triggerType) {
case 'api':
return 'API workflow'
@@ -129,7 +464,264 @@ function getTriggerErrorPrefix(triggerType: 'api' | 'webhook' | 'schedule'): str
return 'Webhook workflow'
case 'schedule':
return 'Scheduled workflow'
case 'manual':
return 'Manual workflow'
default:
return 'Workflow'
}
}
/**
* Extracts duration information for tool calls
* This function preserves actual timing data while ensuring duration is calculated
*/
function estimateToolCallTimings(
toolCalls: any[],
blockStart: string,
blockEnd: string,
totalDuration: number
): any[] {
if (!toolCalls || toolCalls.length === 0) return []
logger.debug('Estimating tool call timings', {
toolCallCount: toolCalls.length,
blockStartTime: blockStart,
blockEndTime: blockEnd,
totalDuration,
})
// First, try to preserve any existing timing data
const result = toolCalls.map((toolCall, index) => {
// Start with the original tool call
const enhancedToolCall = { ...toolCall }
// If we don't have timing data, set it from the block timing info
// Divide block duration evenly among tools as a fallback
const toolDuration = totalDuration / toolCalls.length
const toolStartOffset = index * toolDuration
// Force a minimum duration of 1000ms if none exists
if (!enhancedToolCall.duration || enhancedToolCall.duration === 0) {
enhancedToolCall.duration = Math.max(1000, toolDuration)
logger.debug(`Setting minimum duration for tool ${toolCall.name}`, {
duration: enhancedToolCall.duration,
})
}
// Force reasonable startTime and endTime if missing
if (!enhancedToolCall.startTime) {
const startTimestamp = new Date(blockStart).getTime() + toolStartOffset
enhancedToolCall.startTime = new Date(startTimestamp).toISOString()
logger.debug(`Setting startTime for tool ${toolCall.name}`, {
startTime: enhancedToolCall.startTime,
})
}
if (!enhancedToolCall.endTime) {
const endTimestamp =
new Date(enhancedToolCall.startTime).getTime() + enhancedToolCall.duration
enhancedToolCall.endTime = new Date(endTimestamp).toISOString()
logger.debug(`Setting endTime for tool ${toolCall.name}`, {
endTime: enhancedToolCall.endTime,
})
}
return enhancedToolCall
})
logger.debug('Finished estimating tool call timings', {
originalTools: toolCalls.map((t) => ({
name: t.name,
hadDuration: !!t.duration,
hadStartTime: !!t.startTime,
hadEndTime: !!t.endTime,
})),
enhancedTools: result.map((t) => ({
name: t.name,
duration: t.duration,
startTime: t.startTime,
endTime: t.endTime,
})),
})
return result
}
/**
* Extracts the duration from a tool call object, trying various property formats
* that different agent providers might use
*/
function extractDuration(toolCall: any): number {
if (!toolCall) return 0
// Direct duration fields (various formats providers might use)
if (typeof toolCall.duration === 'number' && toolCall.duration > 0) return toolCall.duration
if (typeof toolCall.durationMs === 'number' && toolCall.durationMs > 0) return toolCall.durationMs
if (typeof toolCall.duration_ms === 'number' && toolCall.duration_ms > 0)
return toolCall.duration_ms
if (typeof toolCall.executionTime === 'number' && toolCall.executionTime > 0)
return toolCall.executionTime
if (typeof toolCall.execution_time === 'number' && toolCall.execution_time > 0)
return toolCall.execution_time
if (typeof toolCall.timing?.duration === 'number' && toolCall.timing.duration > 0)
return toolCall.timing.duration
// Try to calculate from timestamps if available
if (toolCall.startTime && toolCall.endTime) {
try {
const start = new Date(toolCall.startTime).getTime()
const end = new Date(toolCall.endTime).getTime()
if (!isNaN(start) && !isNaN(end) && end >= start) {
return end - start
}
} catch (e) {
// Silently fail if date parsing fails
}
}
// Also check for startedAt/endedAt format
if (toolCall.startedAt && toolCall.endedAt) {
try {
const start = new Date(toolCall.startedAt).getTime()
const end = new Date(toolCall.endedAt).getTime()
if (!isNaN(start) && !isNaN(end) && end >= start) {
return end - start
}
} catch (e) {
// Silently fail if date parsing fails
}
}
// For some providers, timing info might be in a separate object
if (toolCall.timing) {
if (toolCall.timing.startTime && toolCall.timing.endTime) {
try {
const start = new Date(toolCall.timing.startTime).getTime()
const end = new Date(toolCall.timing.endTime).getTime()
if (!isNaN(start) && !isNaN(end) && end >= start) {
return end - start
}
} catch (e) {
// Silently fail if date parsing fails
}
}
}
// No duration info found
return 0
}
/**
* Extract timing information from a tool call object
* @param toolCall The tool call object
* @param blockStartTime Optional block start time (for reference, not used as fallback anymore)
* @param blockEndTime Optional block end time (for reference, not used as fallback anymore)
* @returns Object with startTime and endTime properties
*/
function extractTimingInfo(
toolCall: any,
blockStartTime?: Date,
blockEndTime?: Date
): { startTime?: Date; endTime?: Date } {
logger.debug('Extracting timing info from tool call', {
tool: toolCall.name,
hasStartTime: !!toolCall.startTime,
hasEndTime: !!toolCall.endTime,
hasTiming: !!toolCall.timing,
blockStartRef: blockStartTime?.toISOString(),
blockEndRef: blockEndTime?.toISOString(),
})
let startTime: Date | undefined = undefined
let endTime: Date | undefined = undefined
// Try to get direct timing properties
if (toolCall.startTime && isValidDate(toolCall.startTime)) {
startTime = new Date(toolCall.startTime)
} else if (toolCall.timing?.startTime && isValidDate(toolCall.timing.startTime)) {
startTime = new Date(toolCall.timing.startTime)
} else if (toolCall.timing?.start && isValidDate(toolCall.timing.start)) {
startTime = new Date(toolCall.timing.start)
} else if (toolCall.startedAt && isValidDate(toolCall.startedAt)) {
startTime = new Date(toolCall.startedAt)
}
if (toolCall.endTime && isValidDate(toolCall.endTime)) {
endTime = new Date(toolCall.endTime)
} else if (toolCall.timing?.endTime && isValidDate(toolCall.timing.endTime)) {
endTime = new Date(toolCall.timing.endTime)
} else if (toolCall.timing?.end && isValidDate(toolCall.timing.end)) {
endTime = new Date(toolCall.timing.end)
} else if (toolCall.completedAt && isValidDate(toolCall.completedAt)) {
endTime = new Date(toolCall.completedAt)
}
// If we have start time but no end time, calculate end time from duration
if (startTime && !endTime) {
const duration = extractDuration(toolCall)
if (duration > 0) {
endTime = new Date(startTime.getTime() + duration)
logger.debug('Calculated end time from start time and duration', {
tool: toolCall.name,
startTime: startTime.toISOString(),
duration,
calculatedEndTime: endTime.toISOString(),
})
}
}
// Log the final timing information
logger.debug('Final extracted timing info', {
tool: toolCall.name,
startTime: startTime?.toISOString(),
endTime: endTime?.toISOString(),
hasStartTime: !!startTime,
hasEndTime: !!endTime,
})
return { startTime, endTime }
}
/**
* Helper function to check if a string is a valid date
*/
function isValidDate(dateString: string): boolean {
if (!dateString) return false
try {
const timestamp = Date.parse(dateString)
return !isNaN(timestamp)
} catch (e) {
return false
}
}
// Add this utility function for redacting API keys in tool call inputs
function redactApiKeys(obj: any): any {
if (!obj || typeof obj !== 'object') {
return obj
}
if (Array.isArray(obj)) {
return obj.map(redactApiKeys)
}
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(obj)) {
// Check if the key is 'apiKey' (case insensitive) or related keys
if (
key.toLowerCase() === 'apikey' ||
key.toLowerCase() === 'api_key' ||
key.toLowerCase() === 'access_token'
) {
result[key] = '***REDACTED***'
} else if (typeof value === 'object' && value !== null) {
result[key] = redactApiKeys(value)
} else {
result[key] = value
}
}
return result
}
+4
View File
@@ -239,6 +239,10 @@ ${fieldDescriptions}
toolCalls.push({
name: toolName,
arguments: toolArgs,
startTime: result.timing?.startTime,
endTime: result.timing?.endTime,
duration: result.timing?.duration,
result: result.output,
})
// Add the tool call and result to messages
+4
View File
@@ -154,6 +154,10 @@ export const cerebrasProvider: ProviderConfig = {
toolCalls.push({
name: toolName,
arguments: toolArgs,
startTime: result.timing?.startTime,
endTime: result.timing?.endTime,
duration: result.timing?.duration,
result: result.output,
})
// Add the tool call and result to messages
+4
View File
@@ -127,6 +127,10 @@ export const deepseekProvider: ProviderConfig = {
toolCalls.push({
name: toolName,
arguments: toolArgs,
startTime: result.timing?.startTime,
endTime: result.timing?.endTime,
duration: result.timing?.duration,
result: result.output,
})
// Add the tool call and result to messages
+4
View File
@@ -126,6 +126,10 @@ export const googleProvider: ProviderConfig = {
toolCalls.push({
name: toolName,
arguments: toolArgs,
startTime: result.timing?.startTime,
endTime: result.timing?.endTime,
duration: result.timing?.duration,
result: result.output,
})
// Add the tool call and result to messages
+4
View File
@@ -125,6 +125,10 @@ export const groqProvider: ProviderConfig = {
toolCalls.push({
name: toolName,
arguments: toolArgs,
startTime: result.timing?.startTime,
endTime: result.timing?.endTime,
duration: result.timing?.duration,
result: result.output,
})
// Add the tool call and result to messages
+4
View File
@@ -152,6 +152,10 @@ export const openaiProvider: ProviderConfig = {
toolCalls.push({
name: toolName,
arguments: toolArgs,
startTime: result.timing?.startTime,
endTime: result.timing?.endTime,
duration: result.timing?.duration,
result: result.output,
})
// Add the tool call and result to messages
+6
View File
@@ -31,6 +31,12 @@ export interface ProviderConfig {
export interface FunctionCallResponse {
name: string
arguments: Record<string, any>
startTime?: string
endTime?: string
duration?: number
result?: Record<string, any>
output?: Record<string, any>
input?: Record<string, any>
}
export interface ProviderResponse {
+4
View File
@@ -125,6 +125,10 @@ export const xAIProvider: ProviderConfig = {
toolCalls.push({
name: toolName,
arguments: toolArgs,
startTime: result.timing?.startTime,
endTime: result.timing?.endTime,
duration: result.timing?.duration,
result: result.output,
})
currentMessages.push({
+101 -8
View File
@@ -294,6 +294,10 @@ export async function executeTool(
skipProxy = false,
skipPostProcess = false
): Promise<ToolResponse> {
// Capture start time for precise timing
const startTime = new Date()
const startTimeISO = startTime.toISOString()
try {
const tool = getTool(toolId)
@@ -309,7 +313,18 @@ export async function executeTool(
if (toolId.startsWith('custom_') && tool.directExecution) {
const directResult = await tool.directExecution(params)
if (directResult) {
return directResult
// Add timing data to the result
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return {
...directResult,
timing: {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
},
}
}
// If directExecution returns undefined, fall back to API route
}
@@ -321,15 +336,50 @@ export async function executeTool(
// Apply post-processing if available and not skipped
if (tool.postProcess && result.success && !skipPostProcess) {
try {
return await tool.postProcess(result, params, executeTool)
const postProcessResult = await tool.postProcess(result, params, executeTool)
// Add timing data to the post-processed result
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return {
...postProcessResult,
timing: {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
},
}
} catch (error) {
logger.error(`Error in post-processing for tool ${toolId}:`, { error })
// Return original result if post-processing fails
return result
// Still include timing data
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return {
...result,
timing: {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
},
}
}
}
return result
// Add timing data to the result
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return {
...result,
timing: {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
},
}
}
// For external APIs, use the proxy
@@ -338,15 +388,49 @@ export async function executeTool(
// Apply post-processing if available and not skipped
if (tool.postProcess && result.success && !skipPostProcess) {
try {
return await tool.postProcess(result, params, executeTool)
const postProcessResult = await tool.postProcess(result, params, executeTool)
// Add timing data to the post-processed result
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return {
...postProcessResult,
timing: {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
},
}
} catch (error) {
logger.error(`Error in post-processing for tool ${toolId}:`, { error })
// Return original result if post-processing fails
return result
// Return original result if post-processing fails, but include timing data
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return {
...result,
timing: {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
},
}
}
}
return result
// Add timing data to the result
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return {
...result,
timing: {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
},
}
} catch (error: any) {
logger.error(`Error executing tool ${toolId}:`, { error })
@@ -360,10 +444,19 @@ export async function executeTool(
logger.error(`Looking for custom tool with identifier: ${identifier}`)
}
// Add timing data even for errors
const endTime = new Date()
const endTimeISO = endTime.toISOString()
const duration = endTime.getTime() - startTime.getTime()
return {
success: false,
output: {},
error: error.message || 'Unknown error',
timing: {
startTime: startTimeISO,
endTime: endTimeISO,
duration,
},
}
}
}
+5
View File
@@ -6,6 +6,11 @@ export interface ToolResponse {
success: boolean // Whether the tool execution was successful
output: Record<string, any> // The structured output from the tool
error?: string // Error message if success is false
timing?: {
startTime: string // ISO timestamp when the tool execution started
endTime: string // ISO timestamp when the tool execution ended
duration: number // Duration in milliseconds
}
}
export interface OAuthConfig {
+13 -88
View File
@@ -24,59 +24,17 @@ export function useWorkflowExecution() {
const { isExecuting, setIsExecuting } = useExecutionStore()
const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null)
const persistLogs = async (logs: any[], executionId: string) => {
// Check if we're in local storage mode
const useLocalStorage =
typeof window !== 'undefined' &&
(window.localStorage.getItem('USE_LOCAL_STORAGE') === 'true' ||
process.env.NEXT_PUBLIC_USE_LOCAL_STORAGE === 'true')
if (useLocalStorage) {
// Store logs in localStorage
try {
const storageKey = `workflow-logs-${activeWorkflowId}-${executionId}`
window.localStorage.setItem(
storageKey,
JSON.stringify({
logs,
timestamp: new Date().toISOString(),
workflowId: activeWorkflowId,
})
)
// Also update a list of all execution logs for this workflow
const logListKey = `workflow-logs-list-${activeWorkflowId}`
const existingLogList = window.localStorage.getItem(logListKey)
const logList = existingLogList ? JSON.parse(existingLogList) : []
logList.push({
executionId,
timestamp: new Date().toISOString(),
})
// Keep only the last 20 executions
if (logList.length > 20) {
const removedLogs = logList.splice(0, logList.length - 20)
// Clean up old logs
removedLogs.forEach((log: any) => {
window.localStorage.removeItem(`workflow-logs-${activeWorkflowId}-${log.executionId}`)
})
}
window.localStorage.setItem(logListKey, JSON.stringify(logList))
} catch (error) {
logger.error('Error storing logs in localStorage:', { error })
}
return
}
// Fall back to API if not in local storage mode
const persistLogs = async (executionId: string, result: ExecutionResult) => {
try {
const response = await fetch(`/api/workflow/${activeWorkflowId}/log`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ logs, executionId }),
body: JSON.stringify({
executionId,
result,
}),
})
if (!response.ok) {
@@ -140,59 +98,26 @@ export function useWorkflowExecution() {
activeWorkflowId
)
// Prepare logs for persistence (moved after notification)
const blockLogs = (result.logs || []).map((log) => ({
level: log.success ? 'info' : 'error',
message: log.success
? `Block ${log.blockName || log.blockId} (${log.blockType}): ${JSON.stringify(log.output?.response || {})}`
: `Block ${log.blockName || log.blockId} (${log.blockType}): ${log.error || 'Failed'}`,
duration: log.success ? `${log.durationMs}ms` : 'NA',
createdAt: new Date(log.endedAt || log.startedAt).toISOString(),
}))
// Calculate total duration from successful block logs
const totalDuration = (result.logs || [])
.filter((log) => log.success)
.reduce((sum, log) => sum + log.durationMs, 0)
// Add final execution result log
blockLogs.push({
level: result.success ? 'info' : 'error',
message: result.success
? 'Manual workflow executed successfully'
: `Manual workflow execution failed: ${result.error}`,
duration: result.success ? `${totalDuration}ms` : 'NA',
createdAt: new Date().toISOString(),
})
// Persist logs after notification
await persistLogs(blockLogs, executionId)
// Send the entire execution result to our API to be processed server-side
await persistLogs(executionId, result)
} catch (error: any) {
logger.error('Workflow Execution Error:', { error })
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
// Set error result and show notification immediately
setExecutionResult({
const errorResult = {
success: false,
output: { response: {} },
error: errorMessage,
logs: [],
})
}
setExecutionResult(errorResult)
addNotification('error', `Workflow execution failed: ${errorMessage}`, activeWorkflowId)
// Persist error log after notification
await persistLogs(
[
{
level: 'error',
message: `Manual workflow execution failed: ${errorMessage}`,
duration: 'NA',
createdAt: new Date().toISOString(),
},
],
executionId
)
// Also send the error result to the API
await persistLogs(executionId, errorResult)
} finally {
setIsExecuting(false)
}
+48
View File
@@ -0,0 +1,48 @@
'use client'
import { useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface CopyButtonProps {
text: string
className?: string
showLabel?: boolean
}
export function CopyButton({ text, className = '', showLabel = true }: CopyButtonProps) {
const [copied, setCopied] = useState(false)
const copyToClipboard = () => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="absolute top-1 right-1 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
{showLabel && (
<div className="text-xs text-muted-foreground bg-background/80 px-2 py-1 rounded-md">
{copied ? 'Copied!' : 'Click to copy'}
</div>
)}
<Button
type="button"
variant="ghost"
size="icon"
className={`h-6 w-6 p-0 ${className}`}
onClick={(e) => {
e.stopPropagation() // Prevent click from affecting parent elements
copyToClipboard()
}}
title="Copy to clipboard"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5 text-muted-foreground" />
)}
</Button>
</div>
)
}
+44 -10
View File
@@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { WorkflowLog } from '@/app/w/logs/stores/types'
import { formatDate } from '@/app/w/logs/utils/format-date'
import { CopyButton } from '../copy-button'
import { ToolCallsDisplay } from '../tool-calls/tool-calls-display'
interface LogSidebarProps {
log: WorkflowLog | null
@@ -55,7 +57,8 @@ const formatSingleJsonContent = (content: string): JSX.Element => {
return (
<div>
{messagePart && <div className="mb-2 font-medium text-sm break-words">{messagePart}</div>}
<div className="bg-secondary/50 p-3 rounded-md">
<div className="bg-secondary/50 p-3 rounded-md relative group">
<CopyButton text={JSON.stringify(jsonData, null, 2)} />
<pre className="text-xs whitespace-pre-wrap break-all max-w-full overflow-hidden">
<code>{JSON.stringify(jsonData, null, 2)}</code>
</pre>
@@ -77,7 +80,8 @@ const formatSingleJsonContent = (content: string): JSX.Element => {
try {
const parsedJson = JSON.parse(jsonStr)
return (
<div key={idx} className="bg-secondary/50 p-3 rounded-md mt-2">
<div key={idx} className="bg-secondary/50 p-3 rounded-md mt-2 relative group">
<CopyButton text={JSON.stringify(parsedJson, null, 2)} />
<pre className="text-xs whitespace-pre-wrap break-all max-w-full overflow-hidden">
<code>{JSON.stringify(parsedJson, null, 2)}</code>
</pre>
@@ -85,7 +89,8 @@ const formatSingleJsonContent = (content: string): JSX.Element => {
)
} catch {
return (
<div key={idx} className="mt-2 text-sm break-words">
<div key={idx} className="mt-2 text-sm break-words relative group">
<CopyButton text={jsonStr} />
{jsonStr}
</div>
)
@@ -99,7 +104,12 @@ const formatSingleJsonContent = (content: string): JSX.Element => {
// If all parsing fails, return the original content
}
return <div className="text-sm break-words">{content}</div>
return (
<div className="text-sm break-words relative group">
<CopyButton text={content} />
{content}
</div>
)
}
export function Sidebar({ log, isOpen, onClose }: LogSidebarProps) {
@@ -187,7 +197,10 @@ export function Sidebar({ log, isOpen, onClose }: LogSidebarProps) {
{/* Timestamp */}
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Timestamp</h3>
<p className="text-sm">{formatDate(log.createdAt).full}</p>
<p className="text-sm relative group">
<CopyButton text={formatDate(log.createdAt).full} />
{formatDate(log.createdAt).full}
</p>
</div>
{/* Workflow */}
@@ -195,12 +208,13 @@ export function Sidebar({ log, isOpen, onClose }: LogSidebarProps) {
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Workflow</h3>
<div
className="inline-flex items-center px-2 py-1 text-xs rounded-md"
className="inline-flex items-center px-2 py-1 text-xs rounded-md relative group"
style={{
backgroundColor: `${log.workflow.color}20`,
color: log.workflow.color,
}}
>
<CopyButton text={log.workflow.name} />
{log.workflow.name}
</div>
</div>
@@ -210,21 +224,30 @@ export function Sidebar({ log, isOpen, onClose }: LogSidebarProps) {
{log.executionId && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Execution ID</h3>
<p className="text-sm font-mono break-all">{log.executionId}</p>
<p className="text-sm font-mono break-all relative group">
<CopyButton text={log.executionId} />
{log.executionId}
</p>
</div>
)}
{/* Level */}
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Level</h3>
<p className="text-sm capitalize">{log.level}</p>
<p className="text-sm capitalize relative group">
<CopyButton text={log.level} />
{log.level}
</p>
</div>
{/* Trigger */}
{log.trigger && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Trigger</h3>
<p className="text-sm capitalize">{log.trigger}</p>
<p className="text-sm capitalize relative group">
<CopyButton text={log.trigger} />
{log.trigger}
</p>
</div>
)}
@@ -232,7 +255,18 @@ export function Sidebar({ log, isOpen, onClose }: LogSidebarProps) {
{log.duration && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Duration</h3>
<p className="text-sm">{log.duration}</p>
<p className="text-sm relative group">
<CopyButton text={log.duration} />
{log.duration}
</p>
</div>
)}
{/* Tool Calls (if available) */}
{log.metadata?.toolCalls && log.metadata.toolCalls.length > 0 && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Tool Calls</h3>
<ToolCallsDisplay metadata={log.metadata} />
</div>
)}
@@ -0,0 +1,176 @@
'use client'
import { useState } from 'react'
import { AlertCircle, CheckCircle2, ChevronDown, ChevronRight, Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import { ToolCall, ToolCallMetadata } from '../../stores/types'
import { CopyButton } from '../copy-button'
interface ToolCallsDisplayProps {
metadata: ToolCallMetadata
}
export function ToolCallsDisplay({ metadata }: ToolCallsDisplayProps) {
if (!metadata.toolCalls || metadata.toolCalls.length === 0) {
return <div className="text-sm text-muted-foreground">No tool calls recorded</div>
}
return (
<div className="space-y-2">
<div className="border rounded-md bg-secondary/20">
{metadata.toolCalls.map((toolCall, index) => (
<ToolCallItem key={index} toolCall={toolCall} index={index} />
))}
</div>
</div>
)
}
interface ToolCallItemProps {
toolCall: ToolCall
index: number
}
function ToolCallItem({ toolCall, index }: ToolCallItemProps) {
const [expanded, setExpanded] = useState(false)
// Always show exact milliseconds for duration
const formattedDuration = toolCall.duration ? `${toolCall.duration}ms` : 'N/A'
// Determine status color
const statusColor = toolCall.status === 'success' ? 'text-green-500' : 'text-red-500'
const StatusIcon = toolCall.status === 'success' ? CheckCircle2 : AlertCircle
return (
<div className={cn('border-b last:border-b-0', expanded ? 'bg-secondary/30' : '')}>
{/* Tool call header */}
<div
className="flex items-center p-2 cursor-pointer hover:bg-secondary/40 transition-colors"
onClick={() => setExpanded(!expanded)}
>
<div className="mr-1">
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</div>
<div className="flex-1 flex items-center">
<span className="text-sm font-medium">{toolCall.name}</span>
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{formattedDuration}</span>
</div>
<div className={cn('flex items-center gap-1', statusColor)}>
<StatusIcon className="h-3 w-3" />
<span className="capitalize">{toolCall.status}</span>
</div>
</div>
</div>
</div>
{/* Tool call details */}
{expanded && (
<div className="p-3 bg-secondary/20 text-xs border-t">
<div className="space-y-4">
{/* Timing information */}
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-muted-foreground mb-1">Start Time</div>
<div className="font-mono relative group">
{toolCall.startTime && isValidDate(toolCall.startTime) ? (
<>
<CopyButton
text={formatDateWithMilliseconds(new Date(toolCall.startTime))}
showLabel={false}
/>
{formatDateWithMilliseconds(new Date(toolCall.startTime))}
</>
) : (
'Not available'
)}
</div>
</div>
<div>
<div className="text-muted-foreground mb-1">End Time</div>
<div className="font-mono relative group">
{toolCall.endTime && isValidDate(toolCall.endTime) ? (
<>
<CopyButton
text={formatDateWithMilliseconds(new Date(toolCall.endTime))}
showLabel={false}
/>
{formatDateWithMilliseconds(new Date(toolCall.endTime))}
</>
) : (
'Not available'
)}
</div>
</div>
</div>
{/* Input */}
{toolCall.input && (
<div>
<div className="text-muted-foreground mb-1">Input</div>
<pre className="bg-background rounded p-2 overflow-auto max-h-32 relative group">
<CopyButton text={JSON.stringify(toolCall.input, null, 2)} />
<code>{JSON.stringify(toolCall.input, null, 2)}</code>
</pre>
</div>
)}
{/* Output or Error */}
{toolCall.status === 'success' && toolCall.output && (
<div>
<div className="text-muted-foreground mb-1">Output</div>
<pre className="bg-background rounded p-2 overflow-auto max-h-32 relative group">
<CopyButton text={JSON.stringify(toolCall.output, null, 2)} />
<code>{JSON.stringify(toolCall.output, null, 2)}</code>
</pre>
</div>
)}
{toolCall.status === 'error' && toolCall.error && (
<div>
<div className="text-destructive mb-1">Error</div>
<pre className="bg-destructive/10 text-destructive rounded p-2 overflow-auto max-h-32 relative group">
<CopyButton text={toolCall.error} />
<code>{toolCall.error}</code>
</pre>
</div>
)}
</div>
</div>
)}
</div>
)
}
/**
* Helper function to check if a string is a valid date
*/
function isValidDate(dateString: string): boolean {
if (!dateString) return false
try {
const timestamp = Date.parse(dateString)
return !isNaN(timestamp)
} catch (e) {
return false
}
}
/**
* Format a date with millisecond precision
*/
function formatDateWithMilliseconds(date: Date): string {
// Get hours, minutes, seconds components
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
const seconds = date.getSeconds().toString().padStart(2, '0')
// Get milliseconds and format to 3 digits
const milliseconds = date.getMilliseconds().toString().padStart(3, '0')
// Format as HH:MM:SS.mmm
return `${hours}:${minutes}:${seconds}.${milliseconds}`
}
+16
View File
@@ -7,6 +7,21 @@ export interface WorkflowData {
// Add other workflow fields as needed
}
export interface ToolCall {
name: string
duration: number // in milliseconds
startTime: string // ISO timestamp
endTime: string // ISO timestamp
status: 'success' | 'error' // Status of the tool call
input?: Record<string, any> // Input parameters (optional)
output?: Record<string, any> // Output data (optional)
error?: string // Error message if status is 'error'
}
export interface ToolCallMetadata {
toolCalls?: ToolCall[]
}
export interface WorkflowLog {
id: string
workflowId: string
@@ -17,6 +32,7 @@ export interface WorkflowLog {
trigger: string | null
createdAt: string
workflow?: WorkflowData | null
metadata?: ToolCallMetadata | Record<string, any> // Add metadata for tool calls
}
export interface LogsResponse {
+2 -2
View File
@@ -1,8 +1,8 @@
import type { Config } from 'drizzle-kit'
export default {
schema: './db/schema.ts',
out: './db/migrations',
schema: './app/db/schema.ts',
out: './app/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,