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