From 6209065f007ece564ec6347f6a70509e90b49d3d Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 12 May 2026 13:35:45 +0200 Subject: [PATCH] Add support for async context propagation and baggage in tracing service. Signed-off-by: Eric Peterson --- .../tracing-service-context-and-baggage.md | 7 ++ docs/backend-system/core-services/tracing.md | 35 +++++++++ .../tracing/DefaultTracingService.test.ts | 75 ++++++++++++++++++- .../tracing/DefaultTracingService.ts | 32 +++++++- .../backend-plugin-api/report-alpha.api.md | 19 +++++ .../src/alpha/TracingService.ts | 35 +++++++++ .../backend-plugin-api/src/alpha/index.ts | 2 + packages/backend-plugin-api/src/alpha/refs.ts | 2 +- .../backend-test-utils/report-alpha.api.md | 6 ++ .../src/alpha/services/TracingServiceMock.ts | 17 ++++- 10 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 .changeset/tracing-service-context-and-baggage.md diff --git a/.changeset/tracing-service-context-and-baggage.md b/.changeset/tracing-service-context-and-baggage.md new file mode 100644 index 0000000000..3f447cc339 --- /dev/null +++ b/.changeset/tracing-service-context-and-baggage.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-plugin-api': patch +'@backstage/backend-defaults': patch +'@backstage/backend-test-utils': patch +--- + +Added `withPropagatedContext` and `getActiveBaggage` to the alpha `TracingService`, enabling plugins to bridge OpenTelemetry context across async boundaries and read propagated baggage. diff --git a/docs/backend-system/core-services/tracing.md b/docs/backend-system/core-services/tracing.md index 66c6a35d3d..3cd9b7c00f 100644 --- a/docs/backend-system/core-services/tracing.md +++ b/docs/backend-system/core-services/tracing.md @@ -100,6 +100,41 @@ The span object exposes: | `setAttribute(key, value)` | Set a single attribute. Value is a primitive or array of primitives. | | `setStatus({ code, message })` | Set the span status. `code` is `'ok'`, `'error'`, or `'unset'`. | +## Context Propagation + +When your plugin receives requests through a protocol layer that breaks automatic OpenTelemetry context propagation (e.g. a WebSocket handler), use `withPropagatedContext` to extract the trace parent and baggage from the incoming HTTP headers and run the handler within that context: + +```ts +router.post('/', async (req, res) => { + await tracing.withPropagatedContext(req.headers, () => + transport.handleRequest(req, res, req.body), + ); +}); +``` + +Any spans created inside the callback — including those from `startActiveSpan` — will be children of the propagated trace and will have access to the propagated baggage. + +## Reading Baggage + +Use `getActiveBaggage()` to read baggage entries from the active context. This is useful for forwarding caller-set metadata onto your spans — for example, a request ID, tenant identifier, or feature-flag context that the caller propagated via baggage: + +```ts +const baggage = tracing.getActiveBaggage(); +const tenantId = baggage?.getEntry('app.tenant.id')?.value; +if (tenantId) { + span.setAttribute('app.tenant.id', tenantId); +} +``` + +The returned object exposes: + +| Method | Description | +| ----------------- | -------------------------------------------------------- | +| `getEntry(key)` | Returns `{ value: string }` for the key, or `undefined`. | +| `getAllEntries()` | Returns all entries as `[key, { value }][]`. | + +Returns `undefined` when no baggage is present in the active context. + ## Principal Enrichment When you supply either `credentials` or a `request`, the service adds principal-derived attributes to the span: diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts index 9675ecd8ef..c7dcbd9925 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts @@ -13,7 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import { + SpanKind, + SpanStatusCode, + context, + propagation, + trace, +} from '@opentelemetry/api'; import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; import { DefaultTracingService } from './DefaultTracingService'; @@ -267,4 +273,71 @@ describe('DefaultTracingService', () => { expect(value).toBe(42); expect(mocks.span.end).toHaveBeenCalledTimes(1); }); + + describe('withPropagatedContext', () => { + it('extracts context from headers and runs fn within it', async () => { + const extractSpy = jest.spyOn(propagation, 'extract'); + const withSpy = jest.spyOn(context, 'with'); + + const service = createService(); + const headers = { traceparent: '00-abc-def-01' }; + const result = await service.withPropagatedContext(headers, () => 42); + + expect(result).toBe(42); + expect(extractSpy).toHaveBeenCalledWith(expect.anything(), headers); + expect(withSpy).toHaveBeenCalledWith( + expect.anything(), + expect.any(Function), + ); + }); + + it('returns the value from an async fn', async () => { + const service = createService(); + const result = await service.withPropagatedContext( + {}, + async () => 'async-val', + ); + expect(result).toBe('async-val'); + }); + }); + + describe('getActiveBaggage', () => { + it('returns undefined when no baggage is present', () => { + jest.spyOn(propagation, 'getActiveBaggage').mockReturnValue(undefined); + const service = createService(); + expect(service.getActiveBaggage()).toBeUndefined(); + }); + + it('returns a read-only baggage wrapping the active context baggage', () => { + const mockBaggage = { + getEntry: jest.fn((key: string) => + key === 'gen_ai.conversation.id' ? { value: 'conv-1' } : undefined, + ), + getAllEntries: jest.fn(() => [ + ['gen_ai.conversation.id', { value: 'conv-1' }], + ['gen_ai.agent.id', { value: 'agent-2' }], + ]), + setEntry: jest.fn(), + removeEntry: jest.fn(), + removeEntries: jest.fn(), + clear: jest.fn(), + }; + jest + .spyOn(propagation, 'getActiveBaggage') + .mockReturnValue(mockBaggage as any); + + const service = createService(); + const baggage = service.getActiveBaggage(); + + expect(baggage).toBeDefined(); + expect(baggage!.getEntry('gen_ai.conversation.id')).toEqual({ + value: 'conv-1', + }); + expect(baggage!.getEntry('unknown')).toBeUndefined(); + expect(baggage!.getAllEntries()).toEqual([ + ['gen_ai.conversation.id', { value: 'conv-1' }], + ['gen_ai.agent.id', { value: 'agent-2' }], + ]); + }); + }); }); diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts index 77049cedfd..5d99562949 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts @@ -14,7 +14,14 @@ * limitations under the License. */ -import { SpanKind, SpanStatusCode, Tracer, trace } from '@opentelemetry/api'; +import { + SpanKind, + SpanStatusCode, + Tracer, + context, + propagation, + trace, +} from '@opentelemetry/api'; import { BackstageCredentials, HttpAuthService, @@ -117,6 +124,29 @@ export class DefaultTracingService implements TracingService { ); } + async withPropagatedContext( + headers: Record, + fn: () => T | Promise, + ): Promise { + const otelCtx = propagation.extract(context.active(), headers); + return context.with(otelCtx, fn); + } + + getActiveBaggage() { + const baggage = propagation.getActiveBaggage(); + if (!baggage) return undefined; + return { + getEntry: (key: string) => { + const entry = baggage.getEntry(key); + return entry ? { value: entry.value } : undefined; + }, + getAllEntries: (): Array<[string, { value: string }]> => + baggage + .getAllEntries() + .map(([key, entry]) => [key, { value: entry.value }]), + }; + } + private getPrincipalAttributes( credentials: BackstageCredentials | undefined, ): TracingServiceAttributes { diff --git a/packages/backend-plugin-api/report-alpha.api.md b/packages/backend-plugin-api/report-alpha.api.md index 0603f4310e..e970d85624 100644 --- a/packages/backend-plugin-api/report-alpha.api.md +++ b/packages/backend-plugin-api/report-alpha.api.md @@ -292,11 +292,16 @@ export const rootSystemMetadataServiceRef: ServiceRef< // @alpha export interface TracingService { + getActiveBaggage(): TracingServiceBaggage | undefined; startActiveSpan( name: string, fn: (span: TracingServiceSpan) => T | Promise, options?: TracingServiceSpanOptions, ): Promise; + withPropagatedContext( + headers: Record, + fn: () => T | Promise, + ): Promise; } // @alpha @@ -314,6 +319,20 @@ export type TracingServiceAttributeValue = | Array | Array; +// @alpha +export interface TracingServiceBaggage { + // (undocumented) + getAllEntries(): Array<[string, TracingServiceBaggageEntry]>; + // (undocumented) + getEntry(key: string): TracingServiceBaggageEntry | undefined; +} + +// @alpha +export interface TracingServiceBaggageEntry { + // (undocumented) + value: string; +} + // @alpha export const tracingServiceRef: ServiceRef< TracingService, diff --git a/packages/backend-plugin-api/src/alpha/TracingService.ts b/packages/backend-plugin-api/src/alpha/TracingService.ts index e0ccf26116..8919263676 100644 --- a/packages/backend-plugin-api/src/alpha/TracingService.ts +++ b/packages/backend-plugin-api/src/alpha/TracingService.ts @@ -106,4 +106,39 @@ export interface TracingService { fn: (span: TracingServiceSpan) => T | Promise, options?: TracingServiceSpanOptions, ): Promise; + + /** + * Extracts propagated context from HTTP headers and runs `fn` within + * it. Use this to bridge context across async boundaries where + * automatic propagation is lost. + */ + withPropagatedContext( + headers: Record, + fn: () => T | Promise, + ): Promise; + + /** + * Returns the active baggage from the current context, or `undefined` + * when none is present. + */ + getActiveBaggage(): TracingServiceBaggage | undefined; +} + +/** + * A read-only view of propagated baggage entries. + * + * @alpha + */ +export interface TracingServiceBaggage { + getEntry(key: string): TracingServiceBaggageEntry | undefined; + getAllEntries(): Array<[string, TracingServiceBaggageEntry]>; +} + +/** + * A single baggage entry. + * + * @alpha + */ +export interface TracingServiceBaggageEntry { + value: string; } diff --git a/packages/backend-plugin-api/src/alpha/index.ts b/packages/backend-plugin-api/src/alpha/index.ts index 4e1e73f23e..dc0624273b 100644 --- a/packages/backend-plugin-api/src/alpha/index.ts +++ b/packages/backend-plugin-api/src/alpha/index.ts @@ -50,6 +50,8 @@ export type { TracingService, TracingServiceAttributeValue, TracingServiceAttributes, + TracingServiceBaggage, + TracingServiceBaggageEntry, TracingServiceSpan, TracingServiceSpanKind, TracingServiceSpanOptions, diff --git a/packages/backend-plugin-api/src/alpha/refs.ts b/packages/backend-plugin-api/src/alpha/refs.ts index 967fce6344..47176645d9 100644 --- a/packages/backend-plugin-api/src/alpha/refs.ts +++ b/packages/backend-plugin-api/src/alpha/refs.ts @@ -71,7 +71,7 @@ export const metricsServiceRef = createServiceRef< /** * Service for managing trace spans. * - * See {@link TracingService} for the API surface. + * See `TracingService` for the API surface. * * @alpha */ diff --git a/packages/backend-test-utils/report-alpha.api.md b/packages/backend-test-utils/report-alpha.api.md index 71848aaaa1..b2d6b56e81 100644 --- a/packages/backend-test-utils/report-alpha.api.md +++ b/packages/backend-test-utils/report-alpha.api.md @@ -108,9 +108,15 @@ export type ServiceMock = { export interface TracingServiceMock extends TracingService { // (undocumented) factory: ServiceFactory; + // (undocumented) + getActiveBaggage: jest.MockedFunction; spans: MockedTracingServiceSpan[]; // (undocumented) startActiveSpan: jest.MockedFunction; + // (undocumented) + withPropagatedContext: jest.MockedFunction< + TracingService['withPropagatedContext'] + >; } // @alpha (undocumented) diff --git a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts index 6cdc1136cc..5e000b7603 100644 --- a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts +++ b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts @@ -46,6 +46,10 @@ export interface MockedTracingServiceSpan extends TracingServiceSpan { */ export interface TracingServiceMock extends TracingService { startActiveSpan: jest.MockedFunction; + withPropagatedContext: jest.MockedFunction< + TracingService['withPropagatedContext'] + >; + getActiveBaggage: jest.MockedFunction; /** Spans created by `startActiveSpan` calls, in order. */ spans: MockedTracingServiceSpan[]; factory: ServiceFactory; @@ -75,7 +79,18 @@ export namespace tracingServiceMock { return await fn(span); }) as TracingServiceMock['startActiveSpan']; - const service: TracingService = { startActiveSpan }; + const withPropagatedContext = jest.fn(async (_headers, fn) => + fn(), + ) as TracingServiceMock['withPropagatedContext']; + const getActiveBaggage = jest.fn( + () => undefined, + ) as TracingServiceMock['getActiveBaggage']; + + const service: TracingService = { + startActiveSpan, + withPropagatedContext, + getActiveBaggage, + }; return Object.assign(service as TracingServiceMock, { spans,