feat(viewer-cloud): add ExecutionLogger for workflow execution tracking

CF11-005: Execution Logger Integration (Part 1 - Logger Class)

- Add ExecutionLogger class wrapping ExecutionStore
- Implement execution lifecycle: startExecution, completeExecution
- Implement node lifecycle: startNode, completeNode, skipNode
- Add configurable settings (enabled, captureInputs, captureOutputs)
- Add automatic data truncation for large payloads
- Add retention cleanup utility
- Add comprehensive unit tests with MockExecutionStore

CloudRunner integration (Part 2) deferred until Phase 5 TASK-007C
creates the workflow runtime engine.
This commit is contained in:
Richard Osborne
2026-01-15 16:21:41 +01:00
parent 8938fa6885
commit 95bf2f363c
3 changed files with 837 additions and 0 deletions

View File

@@ -0,0 +1,394 @@
/**
* ExecutionLogger - High-level API for logging workflow executions
*
* Provides lifecycle methods for tracking workflow and node executions.
* Wraps ExecutionStore with additional features like:
* - Configuration for enabling/disabling capture
* - Automatic data truncation
* - State management for current execution
* - Retention policy enforcement
*
* @module execution-history/ExecutionLogger
*
* @example
* ```typescript
* const logger = new ExecutionLogger(store, { enabled: true });
*
* // Start a workflow execution
* const execId = logger.startExecution({
* workflowId: 'wf-123',
* workflowName: 'My Workflow',
* triggerType: 'webhook',
* triggerData: { path: '/api/hook' }
* });
*
* // Log node executions
* const stepId = logger.startNode({
* nodeId: 'node-1',
* nodeType: 'noodl.logic.condition',
* inputData: { value: true }
* });
* logger.completeNode(stepId, true, { result: false });
*
* // Complete the workflow
* logger.completeExecution(true);
* ```
*/
import type { ExecutionStore } from './store';
import type { TriggerType } from './types';
/**
* Configuration for ExecutionLogger
*/
export interface LoggerConfig {
/** Whether logging is enabled (default: true) */
enabled: boolean;
/** Whether to capture input data for nodes (default: true) */
captureInputs: boolean;
/** Whether to capture output data for nodes (default: true) */
captureOutputs: boolean;
/** Maximum size for JSON data in bytes (default: 100KB) */
maxDataSize: number;
/** Number of days to retain execution records (default: 30) */
retentionDays: number;
}
/**
* Parameters for starting an execution
*/
export interface StartExecutionParams {
/** ID of the workflow component */
workflowId: string;
/** Display name of the workflow */
workflowName: string;
/** Type of trigger that started the workflow */
triggerType: TriggerType;
/** Trigger-specific data */
triggerData?: Record<string, unknown>;
/** Additional metadata */
metadata?: Record<string, unknown>;
}
/**
* Parameters for starting a node execution
*/
export interface StartNodeParams {
/** ID of the node in the graph */
nodeId: string;
/** Type of node */
nodeType: string;
/** Display name of the node */
nodeName?: string;
/** Input data received by the node */
inputData?: Record<string, unknown>;
}
/** Default logger configuration */
const DEFAULT_CONFIG: LoggerConfig = {
enabled: true,
captureInputs: true,
captureOutputs: true,
maxDataSize: 100_000, // 100KB
retentionDays: 30
};
/**
* ExecutionLogger class
*
* High-level API for logging workflow executions with lifecycle management.
*/
export class ExecutionLogger {
private store: ExecutionStore;
private config: LoggerConfig;
// Current execution state
private currentExecutionId: string | null = null;
private executionStartTime: number = 0;
private stepIndex: number = 0;
// Track active steps for timing
private activeSteps: Map<string, number> = new Map();
/**
* Create an ExecutionLogger
*
* @param store - ExecutionStore instance
* @param config - Optional configuration overrides
*/
constructor(store: ExecutionStore, config?: Partial<LoggerConfig>) {
this.store = store;
this.config = { ...DEFAULT_CONFIG, ...config };
}
// ===========================================================================
// CONFIGURATION
// ===========================================================================
/**
* Check if logging is enabled
*/
get isEnabled(): boolean {
return this.config.enabled;
}
/**
* Enable or disable logging
*/
setEnabled(enabled: boolean): void {
this.config.enabled = enabled;
}
/**
* Update configuration
*/
updateConfig(updates: Partial<LoggerConfig>): void {
this.config = { ...this.config, ...updates };
}
/**
* Get current configuration
*/
getConfig(): Readonly<LoggerConfig> {
return { ...this.config };
}
// ===========================================================================
// EXECUTION LIFECYCLE
// ===========================================================================
/**
* Start logging a workflow execution
*
* @param params - Execution parameters
* @returns Execution ID (empty string if logging is disabled)
*/
startExecution(params: StartExecutionParams): string {
if (!this.config.enabled) {
return '';
}
// Clean up any previous execution state
this.resetState();
const now = Date.now();
this.executionStartTime = now;
const executionId = this.store.createExecution({
workflowId: params.workflowId,
workflowName: params.workflowName,
triggerType: params.triggerType,
triggerData: this.truncateData(params.triggerData),
status: 'running',
startedAt: now,
metadata: this.truncateData(params.metadata)
});
this.currentExecutionId = executionId;
return executionId;
}
/**
* Complete the current workflow execution
*
* @param success - Whether the execution succeeded
* @param error - Error if execution failed
*/
completeExecution(success: boolean, error?: Error): void {
if (!this.config.enabled || !this.currentExecutionId) {
return;
}
const now = Date.now();
const durationMs = now - this.executionStartTime;
this.store.updateExecution(this.currentExecutionId, {
status: success ? 'success' : 'error',
completedAt: now,
durationMs,
errorMessage: error?.message,
errorStack: error?.stack
});
// Clean up state
this.resetState();
}
/**
* Get the current execution ID
*/
getCurrentExecutionId(): string | null {
return this.currentExecutionId;
}
/**
* Check if there's an active execution
*/
hasActiveExecution(): boolean {
return this.currentExecutionId !== null;
}
// ===========================================================================
// NODE LIFECYCLE
// ===========================================================================
/**
* Start logging a node execution
*
* @param params - Node parameters
* @returns Step ID (empty string if logging is disabled or no active execution)
*/
startNode(params: StartNodeParams): string {
if (!this.config.enabled || !this.currentExecutionId) {
return '';
}
const now = Date.now();
const stepId = this.store.addStep({
executionId: this.currentExecutionId,
nodeId: params.nodeId,
nodeType: params.nodeType,
nodeName: params.nodeName,
stepIndex: this.stepIndex++,
startedAt: now,
status: 'running',
inputData: this.config.captureInputs ? this.truncateData(params.inputData) : undefined
});
// Track start time for duration calculation
this.activeSteps.set(stepId, now);
return stepId;
}
/**
* Complete a node execution
*
* @param stepId - Step ID returned from startNode
* @param success - Whether the node execution succeeded
* @param outputData - Output data produced by the node
* @param error - Error if node failed
*/
completeNode(stepId: string, success: boolean, outputData?: Record<string, unknown>, error?: Error): void {
if (!this.config.enabled || !stepId) {
return;
}
const now = Date.now();
const startTime = this.activeSteps.get(stepId);
const durationMs = startTime ? now - startTime : undefined;
this.store.updateStep(stepId, {
status: success ? 'success' : 'error',
completedAt: now,
durationMs,
outputData: this.config.captureOutputs ? this.truncateData(outputData) : undefined,
errorMessage: error?.message
});
// Clean up tracking
this.activeSteps.delete(stepId);
}
/**
* Mark a node as skipped
*
* @param params - Node parameters
* @returns Step ID
*/
skipNode(params: StartNodeParams): string {
if (!this.config.enabled || !this.currentExecutionId) {
return '';
}
const now = Date.now();
return this.store.addStep({
executionId: this.currentExecutionId,
nodeId: params.nodeId,
nodeType: params.nodeType,
nodeName: params.nodeName,
stepIndex: this.stepIndex++,
startedAt: now,
completedAt: now,
durationMs: 0,
status: 'skipped'
});
}
// ===========================================================================
// UTILITIES
// ===========================================================================
/**
* Truncate data if it exceeds the maximum size
*/
private truncateData(data?: Record<string, unknown>): Record<string, unknown> | undefined {
if (data === undefined || data === null) {
return undefined;
}
try {
const json = JSON.stringify(data);
if (json.length <= this.config.maxDataSize) {
return data;
}
// Return truncated indicator
return {
__truncated: true,
__originalSize: json.length,
__maxSize: this.config.maxDataSize,
__preview: json.substring(0, 1000) + '...'
};
} catch (e) {
return {
__error: 'Failed to serialize data',
__message: e instanceof Error ? e.message : 'Unknown error'
};
}
}
/**
* Reset internal state
*/
private resetState(): void {
this.currentExecutionId = null;
this.executionStartTime = 0;
this.stepIndex = 0;
this.activeSteps.clear();
}
// ===========================================================================
// RETENTION
// ===========================================================================
/**
* Run retention cleanup based on configured policy
*
* @returns Number of executions deleted
*/
runRetentionCleanup(): number {
const maxAgeMs = this.config.retentionDays * 24 * 60 * 60 * 1000;
return this.store.cleanupByAge(maxAgeMs);
}
/**
* Get the underlying store for advanced queries
*/
getStore(): ExecutionStore {
return this.store;
}
}

View File

@@ -54,6 +54,10 @@
export { ExecutionStore } from './store';
export type { SQLiteDatabase } from './store';
// Export logger
export { ExecutionLogger } from './ExecutionLogger';
export type { LoggerConfig, StartExecutionParams, StartNodeParams } from './ExecutionLogger';
// Export types
export type {
// Status types

View File

@@ -0,0 +1,439 @@
/**
* Unit tests for ExecutionLogger
*
* Tests lifecycle methods, configuration, and data handling.
*/
import { ExecutionLogger, ExecutionStore, SQLiteDatabase } from '../src/execution-history';
/**
* Mock ExecutionStore that tracks calls for testing
*/
class MockExecutionStore {
public executions: Map<string, Record<string, unknown>> = new Map();
public steps: Map<string, Record<string, unknown>> = new Map();
public calls: Array<{ method: string; args: unknown[] }> = [];
private executionCounter = 0;
private stepCounter = 0;
createExecution(options: Record<string, unknown>): string {
const id = `exec_${++this.executionCounter}`;
this.executions.set(id, { id, ...options });
this.calls.push({ method: 'createExecution', args: [options] });
return id;
}
updateExecution(id: string, updates: Record<string, unknown>): void {
const exec = this.executions.get(id);
if (exec) {
Object.assign(exec, updates);
}
this.calls.push({ method: 'updateExecution', args: [id, updates] });
}
addStep(options: Record<string, unknown>): string {
const id = `step_${++this.stepCounter}`;
this.steps.set(id, { id, ...options });
this.calls.push({ method: 'addStep', args: [options] });
return id;
}
updateStep(id: string, updates: Record<string, unknown>): void {
const step = this.steps.get(id);
if (step) {
Object.assign(step, updates);
}
this.calls.push({ method: 'updateStep', args: [id, updates] });
}
cleanupByAge(maxAgeMs: number): number {
this.calls.push({ method: 'cleanupByAge', args: [maxAgeMs] });
return 0;
}
// Test helpers
reset(): void {
this.executions.clear();
this.steps.clear();
this.calls = [];
this.executionCounter = 0;
this.stepCounter = 0;
}
}
describe('ExecutionLogger', () => {
let store: MockExecutionStore;
let logger: ExecutionLogger;
beforeEach(() => {
store = new MockExecutionStore();
logger = new ExecutionLogger(store as unknown as ExecutionStore);
});
describe('configuration', () => {
it('should be enabled by default', () => {
expect(logger.isEnabled).toBe(true);
});
it('should allow disabling logging', () => {
logger.setEnabled(false);
expect(logger.isEnabled).toBe(false);
});
it('should return default config values', () => {
const config = logger.getConfig();
expect(config.enabled).toBe(true);
expect(config.captureInputs).toBe(true);
expect(config.captureOutputs).toBe(true);
expect(config.maxDataSize).toBe(100_000);
expect(config.retentionDays).toBe(30);
});
it('should allow custom config on construction', () => {
const customLogger = new ExecutionLogger(store as unknown as ExecutionStore, {
captureInputs: false,
retentionDays: 7
});
const config = customLogger.getConfig();
expect(config.captureInputs).toBe(false);
expect(config.retentionDays).toBe(7);
});
it('should allow updating config', () => {
logger.updateConfig({ maxDataSize: 50_000 });
expect(logger.getConfig().maxDataSize).toBe(50_000);
});
});
describe('execution lifecycle', () => {
it('should create an execution on start', () => {
const execId = logger.startExecution({
workflowId: 'wf-123',
workflowName: 'Test Workflow',
triggerType: 'manual'
});
expect(execId).toBe('exec_1');
expect(store.executions.size).toBe(1);
const exec = store.executions.get(execId);
expect(exec?.workflowId).toBe('wf-123');
expect(exec?.workflowName).toBe('Test Workflow');
expect(exec?.triggerType).toBe('manual');
expect(exec?.status).toBe('running');
});
it('should return empty string when disabled', () => {
logger.setEnabled(false);
const execId = logger.startExecution({
workflowId: 'wf-123',
workflowName: 'Test',
triggerType: 'manual'
});
expect(execId).toBe('');
expect(store.executions.size).toBe(0);
});
it('should complete execution with success', () => {
const execId = logger.startExecution({
workflowId: 'wf-123',
workflowName: 'Test',
triggerType: 'manual'
});
logger.completeExecution(true);
const exec = store.executions.get(execId);
expect(exec?.status).toBe('success');
expect(exec?.completedAt).toBeDefined();
expect(exec?.durationMs).toBeDefined();
});
it('should complete execution with error', () => {
const execId = logger.startExecution({
workflowId: 'wf-123',
workflowName: 'Test',
triggerType: 'manual'
});
const error = new Error('Test error');
logger.completeExecution(false, error);
const exec = store.executions.get(execId);
expect(exec?.status).toBe('error');
expect(exec?.errorMessage).toBe('Test error');
});
it('should track current execution ID', () => {
expect(logger.hasActiveExecution()).toBe(false);
expect(logger.getCurrentExecutionId()).toBeNull();
logger.startExecution({
workflowId: 'wf-123',
workflowName: 'Test',
triggerType: 'manual'
});
expect(logger.hasActiveExecution()).toBe(true);
expect(logger.getCurrentExecutionId()).toBe('exec_1');
logger.completeExecution(true);
expect(logger.hasActiveExecution()).toBe(false);
expect(logger.getCurrentExecutionId()).toBeNull();
});
it('should store trigger data', () => {
logger.startExecution({
workflowId: 'wf-123',
workflowName: 'Test',
triggerType: 'webhook',
triggerData: { path: '/api/hook', method: 'POST' }
});
const exec = store.executions.get('exec_1');
expect(exec?.triggerData).toEqual({ path: '/api/hook', method: 'POST' });
});
it('should store metadata', () => {
logger.startExecution({
workflowId: 'wf-123',
workflowName: 'Test',
triggerType: 'manual',
metadata: { version: '1.0', user: 'test@example.com' }
});
const exec = store.executions.get('exec_1');
expect(exec?.metadata).toEqual({ version: '1.0', user: 'test@example.com' });
});
});
describe('node lifecycle', () => {
beforeEach(() => {
logger.startExecution({
workflowId: 'wf-123',
workflowName: 'Test',
triggerType: 'manual'
});
});
it('should create a step on startNode', () => {
const stepId = logger.startNode({
nodeId: 'node-1',
nodeType: 'noodl.logic.condition',
nodeName: 'Check Condition'
});
expect(stepId).toBe('step_1');
expect(store.steps.size).toBe(1);
const step = store.steps.get(stepId);
expect(step?.nodeId).toBe('node-1');
expect(step?.nodeType).toBe('noodl.logic.condition');
expect(step?.nodeName).toBe('Check Condition');
expect(step?.status).toBe('running');
expect(step?.stepIndex).toBe(0);
});
it('should capture input data when enabled', () => {
logger.startNode({
nodeId: 'node-1',
nodeType: 'test',
inputData: { value: 42, name: 'test' }
});
const step = store.steps.get('step_1');
expect(step?.inputData).toEqual({ value: 42, name: 'test' });
});
it('should not capture input data when disabled', () => {
logger.updateConfig({ captureInputs: false });
logger.startNode({
nodeId: 'node-1',
nodeType: 'test',
inputData: { value: 42 }
});
const step = store.steps.get('step_1');
expect(step?.inputData).toBeUndefined();
});
it('should complete node with success', () => {
const stepId = logger.startNode({
nodeId: 'node-1',
nodeType: 'test'
});
logger.completeNode(stepId, true, { result: 'done' });
const step = store.steps.get(stepId);
expect(step?.status).toBe('success');
expect(step?.outputData).toEqual({ result: 'done' });
expect(step?.completedAt).toBeDefined();
});
it('should complete node with error', () => {
const stepId = logger.startNode({
nodeId: 'node-1',
nodeType: 'test'
});
const error = new Error('Node failed');
logger.completeNode(stepId, false, undefined, error);
const step = store.steps.get(stepId);
expect(step?.status).toBe('error');
expect(step?.errorMessage).toBe('Node failed');
});
it('should not capture output data when disabled', () => {
logger.updateConfig({ captureOutputs: false });
const stepId = logger.startNode({
nodeId: 'node-1',
nodeType: 'test'
});
logger.completeNode(stepId, true, { secret: 'data' });
const step = store.steps.get(stepId);
expect(step?.outputData).toBeUndefined();
});
it('should increment step index for each node', () => {
logger.startNode({ nodeId: 'node-1', nodeType: 'test' });
logger.startNode({ nodeId: 'node-2', nodeType: 'test' });
logger.startNode({ nodeId: 'node-3', nodeType: 'test' });
const step1 = store.steps.get('step_1');
const step2 = store.steps.get('step_2');
const step3 = store.steps.get('step_3');
expect(step1?.stepIndex).toBe(0);
expect(step2?.stepIndex).toBe(1);
expect(step3?.stepIndex).toBe(2);
});
it('should return empty string for startNode without active execution', () => {
logger.completeExecution(true);
const stepId = logger.startNode({
nodeId: 'node-1',
nodeType: 'test'
});
expect(stepId).toBe('');
});
it('should handle skipNode', () => {
const stepId = logger.skipNode({
nodeId: 'node-1',
nodeType: 'noodl.logic.branch',
nodeName: 'Skip Branch'
});
const step = store.steps.get(stepId);
expect(step?.status).toBe('skipped');
expect(step?.durationMs).toBe(0);
});
});
describe('data truncation', () => {
beforeEach(() => {
logger.startExecution({
workflowId: 'wf-123',
workflowName: 'Test',
triggerType: 'manual'
});
});
it('should truncate large input data', () => {
// Set small max size for testing
logger.updateConfig({ maxDataSize: 100 });
const largeData = { bigValue: 'x'.repeat(200) };
logger.startNode({
nodeId: 'node-1',
nodeType: 'test',
inputData: largeData
});
const step = store.steps.get('step_1');
expect(step?.inputData?.__truncated).toBe(true);
expect(step?.inputData?.__originalSize).toBeGreaterThan(100);
});
it('should not truncate small data', () => {
const smallData = { value: 'small' };
logger.startNode({
nodeId: 'node-1',
nodeType: 'test',
inputData: smallData
});
const step = store.steps.get('step_1');
expect(step?.inputData).toEqual(smallData);
});
});
describe('retention', () => {
it('should call cleanupByAge with correct value', () => {
logger.updateConfig({ retentionDays: 7 });
logger.runRetentionCleanup();
expect(store.calls).toContainEqual({
method: 'cleanupByAge',
args: [7 * 24 * 60 * 60 * 1000]
});
});
});
describe('getStore', () => {
it('should return the underlying store', () => {
const returnedStore = logger.getStore();
expect(returnedStore).toBe(store);
});
});
describe('state management', () => {
it('should reset state between executions', () => {
// First execution
logger.startExecution({
workflowId: 'wf-1',
workflowName: 'First',
triggerType: 'manual'
});
logger.startNode({ nodeId: 'node-1', nodeType: 'test' });
logger.startNode({ nodeId: 'node-2', nodeType: 'test' });
logger.completeExecution(true);
// Second execution should start fresh
logger.startExecution({
workflowId: 'wf-2',
workflowName: 'Second',
triggerType: 'manual'
});
logger.startNode({ nodeId: 'node-3', nodeType: 'test' });
// Step index should be 0 for new execution
const step = store.steps.get('step_3');
expect(step?.stepIndex).toBe(0);
});
it('should handle multiple executions sequentially', () => {
for (let i = 1; i <= 3; i++) {
logger.startExecution({
workflowId: `wf-${i}`,
workflowName: `Workflow ${i}`,
triggerType: 'manual'
});
logger.startNode({ nodeId: `node-${i}`, nodeType: 'test' });
logger.completeExecution(true);
}
expect(store.executions.size).toBe(3);
expect(store.steps.size).toBe(3);
});
});
});