fix(API): Return an execution when its stored trace context is incomplete (#39715)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ali Elkhateeb 2026-09-29 06:53:58 +00:00 committed by GitHub
parent 918bcddb4f
commit dfd7a52224
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 85 additions and 5 deletions

View File

@ -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'];

View File

@ -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<string> {

View File

@ -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,

View File

@ -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;
} = {},

View File

@ -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 () => {

View File

@ -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) {