Add support for async context propagation and baggage in tracing service.

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2026-05-12 13:35:45 +02:00
parent 8916f83bee
commit 6209065f00
10 changed files with 226 additions and 4 deletions
@@ -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.
@@ -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:
@@ -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' }],
]);
});
});
});
@@ -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<T>(
headers: Record<string, string | string[] | undefined>,
fn: () => T | Promise<T>,
): Promise<T> {
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 {
@@ -292,11 +292,16 @@ export const rootSystemMetadataServiceRef: ServiceRef<
// @alpha
export interface TracingService {
getActiveBaggage(): TracingServiceBaggage | undefined;
startActiveSpan<T>(
name: string,
fn: (span: TracingServiceSpan) => T | Promise<T>,
options?: TracingServiceSpanOptions,
): Promise<T>;
withPropagatedContext<T>(
headers: Record<string, string | string[] | undefined>,
fn: () => T | Promise<T>,
): Promise<T>;
}
// @alpha
@@ -314,6 +319,20 @@ export type TracingServiceAttributeValue =
| Array<null | undefined | number>
| Array<null | undefined | boolean>;
// @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,
@@ -106,4 +106,39 @@ export interface TracingService {
fn: (span: TracingServiceSpan) => T | Promise<T>,
options?: TracingServiceSpanOptions,
): Promise<T>;
/**
* 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<T>(
headers: Record<string, string | string[] | undefined>,
fn: () => T | Promise<T>,
): Promise<T>;
/**
* 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;
}
@@ -50,6 +50,8 @@ export type {
TracingService,
TracingServiceAttributeValue,
TracingServiceAttributes,
TracingServiceBaggage,
TracingServiceBaggageEntry,
TracingServiceSpan,
TracingServiceSpanKind,
TracingServiceSpanOptions,
@@ -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
*/
@@ -108,9 +108,15 @@ export type ServiceMock<TService> = {
export interface TracingServiceMock extends TracingService {
// (undocumented)
factory: ServiceFactory<TracingService>;
// (undocumented)
getActiveBaggage: jest.MockedFunction<TracingService['getActiveBaggage']>;
spans: MockedTracingServiceSpan[];
// (undocumented)
startActiveSpan: jest.MockedFunction<TracingService['startActiveSpan']>;
// (undocumented)
withPropagatedContext: jest.MockedFunction<
TracingService['withPropagatedContext']
>;
}
// @alpha (undocumented)
@@ -46,6 +46,10 @@ export interface MockedTracingServiceSpan extends TracingServiceSpan {
*/
export interface TracingServiceMock extends TracingService {
startActiveSpan: jest.MockedFunction<TracingService['startActiveSpan']>;
withPropagatedContext: jest.MockedFunction<
TracingService['withPropagatedContext']
>;
getActiveBaggage: jest.MockedFunction<TracingService['getActiveBaggage']>;
/** Spans created by `startActiveSpan` calls, in order. */
spans: MockedTracingServiceSpan[];
factory: ServiceFactory<TracingService>;
@@ -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,