Add support for async context propagation and baggage in tracing service.
Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
+74
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user