diff --git a/packages/@n8n/decorators/src/execution-lifecycle/lifecycle-metadata.ts b/packages/@n8n/decorators/src/execution-lifecycle/lifecycle-metadata.ts index 1ac74e3fa534a..9332bada9648b 100644 --- a/packages/@n8n/decorators/src/execution-lifecycle/lifecycle-metadata.ts +++ b/packages/@n8n/decorators/src/execution-lifecycle/lifecycle-metadata.ts @@ -6,6 +6,7 @@ import type { ITaskData, ITaskStartedData, IWorkflowBase, + RelatedExecution, Workflow, } from 'n8n-workflow'; @@ -38,6 +39,12 @@ export type WorkflowExecuteBeforeContext = { workflowInstance: Workflow; executionData?: IRunExecutionData; executionId: string; + /** + * The parent execution that triggered this one, if any. Set for + * sub-workflow executions invoked via the Execute Workflow node so + * modules (e.g. tracing) can link the child execution to its parent. + */ + parentExecution?: RelatedExecution; }; export type WorkflowExecuteAfterContext = { diff --git a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts index 153408754d22e..ed1683a888402 100644 --- a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts @@ -2,6 +2,9 @@ import { Logger } from '@n8n/backend-common'; import { mockInstance } from '@n8n/backend-test-utils'; import type { Project, User } from '@n8n/db'; import { ExecutionRepository, UserRepository } from '@n8n/db'; +import type { WorkflowExecuteBeforeContext } from '@n8n/decorators'; +import { LifecycleMetadata } from '@n8n/decorators'; +import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { BinaryDataService, @@ -1227,6 +1230,56 @@ describe('Execution Lifecycle Hooks', () => { ); }); + it('should expose parentExecution on the hooks instance', () => { + expect(lifecycleHooks.parentExecution).toEqual(parentExecution); + }); + + it('should invoke module-registered workflowExecuteBefore handlers with parentExecution in the context', async () => { + const handlerSpy = jest.fn(); + + class TestLifecycleHandler { + onWorkflowStart(ctx: WorkflowExecuteBeforeContext) { + handlerSpy(ctx); + } + } + + const scopedMetadata = new LifecycleMetadata(); + scopedMetadata.register({ + handlerClass: TestLifecycleHandler as unknown as Parameters< + LifecycleMetadata['register'] + >[0]['handlerClass'], + methodName: 'onWorkflowStart', + eventName: 'workflowExecuteBefore', + }); + + const previousMetadata = Container.get(LifecycleMetadata); + Container.set(LifecycleMetadata, scopedMetadata); + Container.set(TestLifecycleHandler, new TestLifecycleHandler()); + + try { + const hooks = getLifecycleHooksForSubExecutions( + 'integrated', + executionId, + workflowData, + undefined, + parentExecution, + ); + + await hooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]); + + expect(handlerSpy).toHaveBeenCalledTimes(1); + expect(handlerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'workflowExecuteBefore', + executionId, + parentExecution, + }), + ); + } finally { + Container.set(LifecycleMetadata, previousMetadata); + } + }); + it('should duplicate binary data to parent execution', async () => { const binaryDataId = `filesystem:workflows/${workflowId}/executions/${executionId}/binary_data/123`; const duplicatedBinaryDataId = `filesystem:workflows/${parentWorkflowId}/executions/${parentExecutionId}/binary_data/456`; diff --git a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts index 9a508e7338675..4c35216294fbd 100644 --- a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts +++ b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts @@ -106,6 +106,7 @@ class ModulesHooksRegistry { workflowInstance, executionData, executionId: this.executionId, + parentExecution: this.parentExecution, }; // eslint-disable-next-line @typescript-eslint/no-unsafe-return return await instance[methodName].call(instance, context); @@ -684,7 +685,13 @@ export function getLifecycleHooksForSubExecutions( projectId?: string, projectName?: string, ): ExecutionLifecycleHooks { - const hooks = new ExecutionLifecycleHooks(mode, executionId, workflowData); + const hooks = new ExecutionLifecycleHooks( + mode, + executionId, + workflowData, + undefined, + parentExecution, + ); const saveSettings = toSaveSettings(workflowData.settings); hookFunctionsWorkflowEvents(hooks, userId, projectId, projectName); hookFunctionsNodeEvents(hooks); @@ -693,6 +700,7 @@ export function getLifecycleHooksForSubExecutions( hookFunctionsSaveProgress(hooks, { saveSettings }); hookFunctionsStatistics(hooks); hookFunctionsExternalHooks(hooks); + Container.get(ModulesHooksRegistry).addHooks(hooks); return hooks; } diff --git a/packages/cli/src/modules/otel/__tests__/workflow-start.handler.test.ts b/packages/cli/src/modules/otel/__tests__/workflow-start.handler.test.ts new file mode 100644 index 0000000000000..081136e5572fc --- /dev/null +++ b/packages/cli/src/modules/otel/__tests__/workflow-start.handler.test.ts @@ -0,0 +1,148 @@ +import type { WorkflowExecuteBeforeContext } from '@n8n/decorators'; +import { trace } from '@opentelemetry/api'; +import { mock } from 'jest-mock-extended'; +import type { IWorkflowBase, Workflow } from 'n8n-workflow'; + +import { ATTR } from '../otel.constants'; +import { SpanRegistry } from '../span-registry'; +import { WorkflowStartHandler } from '../handlers/workflow-start.handler'; +import { OtelTestProvider } from './support/otel-test-provider'; + +const TRACER_NAME = 'n8n-workflow'; + +let otel: OtelTestProvider; +let handler: WorkflowStartHandler; +let spans: SpanRegistry; + +beforeAll(() => { + otel = OtelTestProvider.create(); +}); + +beforeEach(() => { + otel.reset(); + handler = new WorkflowStartHandler(); + spans = new SpanRegistry(); +}); + +afterAll(async () => { + await otel.shutdown(); +}); + +const workflow: IWorkflowBase = { + id: 'wf-1', + name: 'Test Workflow', + active: false, + isArchived: false, + createdAt: new Date(), + updatedAt: new Date(), + activeVersionId: null, + versionId: 'v-1', + connections: {}, + nodes: [ + { + id: 'node-abc', + name: 'Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], +}; + +function makeCtx( + overrides: Partial = {}, +): WorkflowExecuteBeforeContext { + return { + type: 'workflowExecuteBefore', + workflow, + workflowInstance: mock(), + executionId: 'exec-1', + ...overrides, + }; +} + +describe('WorkflowStartHandler', () => { + it('should create a workflow.execute span with correct attributes', () => { + const tracer = trace.getTracer(TRACER_NAME); + + handler.handle(makeCtx(), spans, tracer); + + const workflowSpan = spans.getWorkflow('exec-1'); + expect(workflowSpan).toBeDefined(); + workflowSpan!.end(); + + const finished = otel.getFinishedSpans(); + expect(finished).toHaveLength(1); + expect(finished[0].name).toBe('workflow.execute'); + expect(finished[0].attributes).toMatchObject({ + [ATTR.WORKFLOW_ID]: 'wf-1', + [ATTR.WORKFLOW_NAME]: 'Test Workflow', + [ATTR.WORKFLOW_NODE_COUNT]: 1, + [ATTR.WORKFLOW_VERSION_ID]: 'v-1', + [ATTR.EXECUTION_ID]: 'exec-1', + }); + }); + + it('should create a root span when no parentExecution is provided', () => { + const tracer = trace.getTracer(TRACER_NAME); + + handler.handle(makeCtx(), spans, tracer); + + const span = spans.getWorkflow('exec-1'); + span!.end(); + + const finished = otel.getFinishedSpans(); + expect(finished).toHaveLength(1); + expect(finished[0].parentSpanContext?.spanId).toBeUndefined(); + }); + + it('should link a sub-workflow span to the parent workflow span when parentExecution is provided', () => { + const tracer = trace.getTracer(TRACER_NAME); + + // Simulate the parent workflow execution already being in progress + handler.handle(makeCtx({ executionId: 'parent-exec' }), spans, tracer); + const parentSpan = spans.getWorkflow('parent-exec')!; + + // Now the child sub-workflow starts, referencing the parent execution + handler.handle( + makeCtx({ + executionId: 'child-exec', + parentExecution: { executionId: 'parent-exec', workflowId: 'wf-parent' }, + }), + spans, + tracer, + ); + const childSpan = spans.getWorkflow('child-exec')!; + + childSpan.end(); + parentSpan.end(); + + const finished = otel.getFinishedSpans(); + const childFinished = finished.find((s) => s.attributes[ATTR.EXECUTION_ID] === 'child-exec')!; + const parentFinished = finished.find((s) => s.attributes[ATTR.EXECUTION_ID] === 'parent-exec')!; + + expect(childFinished.spanContext().traceId).toBe(parentFinished.spanContext().traceId); + expect(childFinished.parentSpanContext?.spanId).toBe(parentFinished.spanContext().spanId); + }); + + it('should fall back to a root span if parentExecution is present but the parent span is unknown', () => { + const tracer = trace.getTracer(TRACER_NAME); + + handler.handle( + makeCtx({ + executionId: 'child-exec', + parentExecution: { executionId: 'missing-parent', workflowId: 'wf-parent' }, + }), + spans, + tracer, + ); + + const childSpan = spans.getWorkflow('child-exec')!; + childSpan.end(); + + const finished = otel.getFinishedSpans(); + expect(finished).toHaveLength(1); + expect(finished[0].parentSpanContext?.spanId).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/modules/otel/handlers/workflow-start.handler.ts b/packages/cli/src/modules/otel/handlers/workflow-start.handler.ts index 799411e6cbefb..71079906ba0b9 100644 --- a/packages/cli/src/modules/otel/handlers/workflow-start.handler.ts +++ b/packages/cli/src/modules/otel/handlers/workflow-start.handler.ts @@ -1,5 +1,6 @@ import type { WorkflowExecuteBeforeContext } from '@n8n/decorators'; import { Service } from '@n8n/di'; +import { context, trace } from '@opentelemetry/api'; import type { Tracer } from '@opentelemetry/api'; import { ATTR } from '../otel.constants'; @@ -9,15 +10,27 @@ import type { SpanRegistry } from '../span-registry'; @Service() export class WorkflowStartHandler implements SpanHandler { handle(ctx: WorkflowExecuteBeforeContext, spans: SpanRegistry, tracer: Tracer) { - const span = tracer.startSpan('workflow.execute', { - attributes: { - [ATTR.WORKFLOW_ID]: ctx.workflow.id, - [ATTR.WORKFLOW_VERSION_ID]: ctx.workflow.versionId, - [ATTR.WORKFLOW_NAME]: ctx.workflow.name, - [ATTR.WORKFLOW_NODE_COUNT]: ctx.workflow.nodes.length, - [ATTR.EXECUTION_ID]: ctx.executionId, + // For sub-workflow executions invoked via the Execute Workflow node, + // anchor the child workflow span to the parent workflow span so both + // executions share a single trace with a correct parent/child chain. + const parentSpan = ctx.parentExecution + ? spans.getWorkflow(ctx.parentExecution.executionId) + : undefined; + const parentCtx = parentSpan ? trace.setSpan(context.active(), parentSpan) : context.active(); + + const span = tracer.startSpan( + 'workflow.execute', + { + attributes: { + [ATTR.WORKFLOW_ID]: ctx.workflow.id, + [ATTR.WORKFLOW_VERSION_ID]: ctx.workflow.versionId, + [ATTR.WORKFLOW_NAME]: ctx.workflow.name, + [ATTR.WORKFLOW_NODE_COUNT]: ctx.workflow.nodes.length, + [ATTR.EXECUTION_ID]: ctx.executionId, + }, }, - }); + parentCtx, + ); spans.addWorkflow(ctx.executionId, span); } diff --git a/packages/core/src/execution-engine/execution-lifecycle-hooks.ts b/packages/core/src/execution-engine/execution-lifecycle-hooks.ts index 6de74ad743c97..195a28dce70f1 100644 --- a/packages/core/src/execution-engine/execution-lifecycle-hooks.ts +++ b/packages/core/src/execution-engine/execution-lifecycle-hooks.ts @@ -7,6 +7,7 @@ import type { ITaskData, ITaskStartedData, IWorkflowBase, + RelatedExecution, StructuredChunk, Workflow, WorkflowExecuteMode, @@ -105,6 +106,7 @@ export class ExecutionLifecycleHooks { readonly executionId: string, readonly workflowData: IWorkflowBase, readonly retryOf?: string, + readonly parentExecution?: RelatedExecution, ) {} addHandler(