From a9dc59f4a3a0ce880009225ebee7c61c5c06ba6b Mon Sep 17 00:00:00 2001 From: Raina451 Date: Wed, 29 Apr 2026 14:14:04 +0530 Subject: [PATCH] feat: inject W3C traceparent header for request correlation Generates a unique traceparent header (W3C Trace Context) on every outgoing SDK API request. The traceId maps to App Insights operation_Id, enabling end-to-end correlation between CF Worker logs and backend App Insights. - Added TRACEPARENT and UIPATH_TRACEPARENT_ID constants to headers.ts - Both traceparent and x-uipath-traceparent-id headers sent with same value - traceId and spanId generated independently per W3C spec - Header format: 00-{traceId:32hex}-{spanId:16hex}-01 --- src/core/http/api-client.ts | 11 ++- src/utils/constants/headers.ts | 2 + tests/unit/core/http/api-client.test.ts | 91 +++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 tests/unit/core/http/api-client.test.ts diff --git a/src/core/http/api-client.ts b/src/core/http/api-client.ts index 7a45de6d6..9168ae63c 100644 --- a/src/core/http/api-client.ts +++ b/src/core/http/api-client.ts @@ -4,7 +4,7 @@ import { RequestSpec } from '../../models/common/request-spec'; import { TokenManager } from '../auth/token-manager'; import { errorResponseParser } from '../errors/parser'; import { ErrorFactory } from '../errors/error-factory'; -import { CONTENT_TYPES, RESPONSE_TYPES } from '../../utils/constants/headers'; +import { CONTENT_TYPES, RESPONSE_TYPES, TRACEPARENT, UIPATH_TRACEPARENT_ID } from '../../utils/constants/headers'; export interface ApiClientConfig { headers?: Record; @@ -64,8 +64,15 @@ export class ApiClient { if (isFormData) { delete defaultHeaders['Content-Type']; } - const headers = { + + const traceId = crypto.randomUUID().replace(/-/g, ''); + const spanId = crypto.randomUUID().replace(/-/g, '').slice(0, 16); + const traceparentValue = `00-${traceId}-${spanId}-01`; + + const headers: Record = { ...defaultHeaders, + [TRACEPARENT]: traceparentValue, + [UIPATH_TRACEPARENT_ID]: traceparentValue, ...options.headers }; diff --git a/src/utils/constants/headers.ts b/src/utils/constants/headers.ts index b6819427c..2eab51c97 100644 --- a/src/utils/constants/headers.ts +++ b/src/utils/constants/headers.ts @@ -7,6 +7,8 @@ export const CORRELATION_ID = 'X-UIPATH-Correlation-Id'; export const JOB_KEY = 'X-UIPATH-JobKey'; export const FOLDER_ID = 'X-UIPATH-OrganizationUnitId'; export const INSTANCE_ID = 'X-UIPATH-InstanceId'; +export const TRACEPARENT = 'traceparent'; +export const UIPATH_TRACEPARENT_ID = 'x-uipath-traceparent-id'; /** * Content type constants for HTTP requests/responses diff --git a/tests/unit/core/http/api-client.test.ts b/tests/unit/core/http/api-client.test.ts new file mode 100644 index 000000000..115f51580 --- /dev/null +++ b/tests/unit/core/http/api-client.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { TEST_CONSTANTS } from '../../../utils/constants/common'; + +const TRACEPARENT_REGEX = /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/; + +const mockTokenManager = { + getValidToken: vi.fn().mockResolvedValue(TEST_CONSTANTS.DEFAULT_ACCESS_TOKEN), +}; + +const mockConfig = { + baseUrl: TEST_CONSTANTS.BASE_URL, + orgName: TEST_CONSTANTS.ORGANIZATION_ID, + tenantName: TEST_CONSTANTS.TENANT_ID, +}; + +const mockExecutionContext = {}; + +let capturedHeaders: Record = {}; + +beforeEach(() => { + capturedHeaders = {}; + global.fetch = vi.fn().mockImplementation((_url: string, options: any) => { + capturedHeaders = { ...options.headers }; + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ result: 'ok' })), + }); + }); +}); + +function createClient(clientConfig = {}) { + return new ApiClient( + mockConfig as any, + mockExecutionContext as any, + mockTokenManager as any, + clientConfig, + ); +} + +describe('ApiClient traceparent', () => { + it('injects a traceparent header with correct W3C format', async () => { + const client = createClient(); + await client.get('/test'); + + expect(capturedHeaders['traceparent']).toBeDefined(); + expect(capturedHeaders['traceparent']).toMatch(TRACEPARENT_REGEX); + }); + + it('injects x-uipath-traceparent-id with same value as traceparent', async () => { + const client = createClient(); + await client.get('/test'); + + expect(capturedHeaders['x-uipath-traceparent-id']).toBeDefined(); + expect(capturedHeaders['x-uipath-traceparent-id']).toBe(capturedHeaders['traceparent']); + }); + + it('generates different traceId and spanId values', async () => { + const client = createClient(); + await client.get('/test'); + + const parts = capturedHeaders['traceparent'].split('-'); + const traceId = parts[1]; + const spanId = parts[2]; + + expect(traceId).not.toBe(spanId.padEnd(32, '0')); + expect(traceId.slice(0, 16)).not.toBe(spanId); + }); + + it('generates unique traceparent per request', async () => { + const client = createClient(); + + await client.get('/test1'); + const first = capturedHeaders['traceparent']; + + await client.get('/test2'); + const second = capturedHeaders['traceparent']; + + expect(first).not.toBe(second); + }); + + it('allows caller to override traceparent via options.headers', async () => { + const client = createClient(); + const custom = '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01'; + + await client.get('/test', { headers: { traceparent: custom } }); + + expect(capturedHeaders['traceparent']).toBe(custom); + }); +});