From dfd7a522244720ca536e73b8cb3da7802a850a48 Mon Sep 17 00:00:00 2001 From: Ali Elkhateeb Date: Tue, 29 Sep 2026 06:53:58 +0000 Subject: [PATCH] fix(API): Return an execution when its stored trace context is incomplete (#39715) Co-authored-by: Cursor --- .../cli/src/events/maps/relay.event-map.ts | 3 +- .../support/otel-integration-utils.ts | 3 +- .../executions.public.controller.ts | 13 +++- .../repositories/execution.repository.test.ts | 4 +- .../integration/public-api/executions.test.ts | 65 ++++++++++++++++++- .../test/integration/shared/db/executions.ts | 2 + 6 files changed, 85 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 2e37e36efe5..bb26886efb9 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -30,6 +30,7 @@ import type { PolicyAttachment, PolicyRule, } from '@/modules/type-availability-policies/policy-rule.types'; +import type { TracingContext } from '@/modules/otel/tracing-context'; import type { McpCallerAuth } from '@/services/oauth-token-verifier-proxy.service'; import type { AiEventMap } from './ai.event-map'; @@ -658,7 +659,7 @@ export type RelayEventMap = { stoppedAt: Date; detector: CrashDetector; hostId: string; - tracingContext?: { traceparent: string; tracestate?: string }; + tracingContext?: TracingContext; workflowVersionId?: string; retryOf?: string; workflowCustomTelemetryTags?: IWorkflowSettings['customTelemetryTags']; diff --git a/packages/cli/src/modules/otel/__tests__/support/otel-integration-utils.ts b/packages/cli/src/modules/otel/__tests__/support/otel-integration-utils.ts index dbc1c192a7f..cea55ad8ae5 100644 --- a/packages/cli/src/modules/otel/__tests__/support/otel-integration-utils.ts +++ b/packages/cli/src/modules/otel/__tests__/support/otel-integration-utils.ts @@ -26,6 +26,7 @@ import { TestNodeWithTracing } from './test-node-with-tracing'; import { OtelSettingsService } from '../../otel-settings.service'; import { OtelConfig } from '../../otel.config'; import { OtelService } from '../../otel.service'; +import type { TracingContext } from '../../tracing-context'; const BASE_DIR = path.resolve(__dirname, '../../../../../..'); @@ -117,7 +118,7 @@ export async function executeWorkflow( options: { mode?: 'webhook' | 'trigger' | 'manual' | 'retry'; retryOf?: string; - tracingContext?: { traceparent: string; tracestate?: string }; + tracingContext?: TracingContext; triggerData?: IDataObject; } = {}, ): Promise { diff --git a/packages/cli/src/public-api/v1/controllers/executions.public.controller.ts b/packages/cli/src/public-api/v1/controllers/executions.public.controller.ts index ff45fcbf85e..a0d770ad638 100644 --- a/packages/cli/src/public-api/v1/controllers/executions.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/executions.public.controller.ts @@ -33,6 +33,7 @@ import { Put, Query, } from '@n8n/decorators'; +import { isRecord } from '@n8n/utils/is-record'; import type { Response } from 'express'; import { replaceCircularReferences, WorkflowOperationError } from 'n8n-workflow'; @@ -47,6 +48,7 @@ import { isRedactableExecution } from '@/executions/execution-redaction'; import { ExecutionRedactionServiceProxy } from '@/executions/execution-redaction-proxy.service'; import { ExecutionService } from '@/executions/execution.service'; import type { StopResult } from '@/executions/execution.types'; +import type { TracingContext } from '@/modules/otel/tracing-context'; import { decodeCursor, encodeNextCursor } from '@/public-api/v1/shared/services/pagination.service'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; @@ -434,6 +436,15 @@ export class ExecutionsPublicController { } } +function toPublicTracingContext(tracingContext: unknown): TracingContext | null { + if (!isRecord(tracingContext)) return null; + + const { traceparent, tracestate } = tracingContext; + if (typeof traceparent !== 'string' || traceparent.length === 0) return null; + + return typeof tracestate === 'string' ? { traceparent, tracestate } : { traceparent }; +} + function toBaseFields(execution: PublicExecution) { return { finished: execution.finished, @@ -448,7 +459,7 @@ function toBaseFields(execution: PublicExecution) { workflowId: execution.workflowId, waitTill: execution.waitTill ? execution.waitTill.toISOString() : null, storedAt: execution.storedAt, - tracingContext: execution.tracingContext ?? null, + tracingContext: toPublicTracingContext(execution.tracingContext), deduplicationKey: execution.deduplicationKey ?? null, jsonSizeBytes: execution.jsonSizeBytes ?? 0, binaryDataSizeBytes: execution.binaryDataSizeBytes ?? 0, diff --git a/packages/cli/test/integration/database/repositories/execution.repository.test.ts b/packages/cli/test/integration/database/repositories/execution.repository.test.ts index 58879b1bec3..22cf7d7c149 100644 --- a/packages/cli/test/integration/database/repositories/execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/execution.repository.test.ts @@ -6,6 +6,8 @@ import { stringify } from 'flatted'; import type { ExecutionStatus, IRunExecutionData, IRunExecutionDataAll } from 'n8n-workflow'; import { WAIT_FOR_SUB_EXECUTION, WAIT_INDEFINITELY } from 'n8n-workflow'; +import type { TracingContext } from '@/modules/otel/tracing-context'; + describe('ExecutionRepository', () => { beforeAll(async () => { await testDb.init(); @@ -192,7 +194,7 @@ describe('ExecutionRepository', () => { status: ExecutionStatus, extra: { waitTill?: Date; - tracingContext?: { traceparent: string }; + tracingContext?: TracingContext; workflowVersionId?: string; retryOf?: string; } = {}, diff --git a/packages/cli/test/integration/public-api/executions.test.ts b/packages/cli/test/integration/public-api/executions.test.ts index 41dbb7046c6..2197061a847 100644 --- a/packages/cli/test/integration/public-api/executions.test.ts +++ b/packages/cli/test/integration/public-api/executions.test.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { createManyWorkflows, createTeamProject, @@ -93,6 +94,12 @@ const testWithAPIKey = expect(response.statusCode).toBe(401); }; +function createTraceparent() { + const traceId = randomBytes(16).toString('hex'); + const spanId = randomBytes(8).toString('hex'); + return `00-${traceId}-${spanId}-01`; +} + describe('GET /executions/:id', () => { test('should fail due to missing API Key', testWithAPIKey('get', '/executions/1', null)); @@ -110,8 +117,16 @@ describe('GET /executions/:id', () => { test('owner should be able to get an execution owned by him', async () => { const workflow = await createWorkflow({}, owner); + const traceparent = createTraceparent(); - const execution = await createSuccessfulExecution(workflow); + const execution = await createExecution( + { + finished: true, + status: 'success', + tracingContext: { traceparent }, + }, + workflow, + ); const response = await authOwnerAgent.get(`/executions/${execution.id}`); @@ -127,6 +142,7 @@ describe('GET /executions/:id', () => { stoppedAt, workflowId, waitTill, + tracingContext, } = response.body; expect(id).toBeDefined(); @@ -138,6 +154,53 @@ describe('GET /executions/:id', () => { expect(stoppedAt).not.toBeNull(); expect(workflowId).toBe(execution.workflowId); expect(waitTill).toBeNull(); + expect(tracingContext).toEqual({ traceparent }); + }); + + test('should return a webhook execution when the stored tracestate is null', async () => { + const traceparent = createTraceparent(); + const workflow = await createWorkflow({}, owner); + const execution = await createExecution( + { + finished: true, + status: 'success', + mode: 'webhook', + tracingContext: { + traceparent, + tracestate: null, + } as unknown as ExecutionEntity['tracingContext'], + }, + workflow, + ); + + const withoutData = await authOwnerAgent.get(`/executions/${execution.id}`); + const withData = await authOwnerAgent.get(`/executions/${execution.id}?includeData=true`); + + expect(withoutData.statusCode).toBe(200); + expect(withData.statusCode).toBe(200); + expect(withoutData.body.tracingContext).toEqual({ traceparent }); + expect(withData.body.tracingContext).toEqual({ traceparent }); + }); + + test('should return a webhook execution when the stored traceparent is missing', async () => { + const workflow = await createWorkflow({}, owner); + const execution = await createExecution( + { + finished: true, + status: 'success', + mode: 'webhook', + tracingContext: {} as unknown as ExecutionEntity['tracingContext'], + }, + workflow, + ); + + const withoutData = await authOwnerAgent.get(`/executions/${execution.id}`); + const withData = await authOwnerAgent.get(`/executions/${execution.id}?includeData=true`); + + expect(withoutData.statusCode).toBe(200); + expect(withData.statusCode).toBe(200); + expect(withoutData.body.tracingContext).toBeNull(); + expect(withData.body.tracingContext).toBeNull(); }); test('owner should be able to read executions of other users', async () => { diff --git a/packages/cli/test/integration/shared/db/executions.ts b/packages/cli/test/integration/shared/db/executions.ts index 7c1caa3aba6..7ff30280fe2 100644 --- a/packages/cli/test/integration/shared/db/executions.ts +++ b/packages/cli/test/integration/shared/db/executions.ts @@ -47,6 +47,7 @@ export async function createExecution( jsonSizeBytes, binaryDataSizeBytes, workflowVersionId, + tracingContext, } = attributes; const execution = await Container.get(ExecutionRepository).save({ @@ -62,6 +63,7 @@ export async function createExecution( ...(jsonSizeBytes !== undefined && { jsonSizeBytes }), ...(binaryDataSizeBytes !== undefined && { binaryDataSizeBytes }), ...(workflowVersionId !== undefined && { workflowVersionId }), + ...(tracingContext !== undefined && { tracingContext }), }); if (metadata?.length) {