Initial default implementation of the tracing service + docs

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2026-04-28 17:23:56 +02:00
parent 90b572e4a3
commit 130db0d6c6
12 changed files with 869 additions and 0 deletions
+1
View File
@@ -1,5 +1,6 @@
---
'@backstage/backend-plugin-api': patch
'@backstage/backend-defaults': patch
---
Adds an alpha `TracingService` to provide a unified interface for emitting trace spans across Backstage plugins.
@@ -34,5 +34,6 @@ import { coreServices } from '@backstage/backend-plugin-api';
- [Root Logger Service](./root-logger.md) - Root-level logging.
- [Scheduler Service](./scheduler.md) - Scheduling of distributed background tasks.
- [Token Manager Service](./token-manager.md) - Deprecated service authentication service, use the [Auth Service](./auth.md) instead.
- [Tracing Service](./tracing.md) - Plugin-scoped trace span emission with built-in principal enrichment (alpha).
- [Url Reader Service](./url-reader.md) - Reading content from external systems.
- [User Info Service](./user-info.md) - Authenticated user information retrieval.
@@ -0,0 +1,161 @@
---
id: tracing
title: Tracing Service (alpha)
sidebar_label: Tracing Service (alpha)
description: Documentation for the Tracing service
---
The Tracing Service provides a unified interface for emitting application-level [OpenTelemetry](https://opentelemetry.io/) trace spans from Backstage backend plugins. It scopes each plugin's spans automatically using the OpenTelemetry [Instrumentation Scope](https://opentelemetry.io/docs/concepts/instrumentation-scope/), wraps span lifecycle (auto-end, exception recording, error status) so plugins don't need to write that boilerplate, and transparently enriches spans with the authenticated principal's identity when an HTTP request or `BackstageCredentials` is supplied.
:::note
This service is currently in **alpha** and is imported from `@backstage/backend-plugin-api/alpha`. The API may change in future releases.
:::
## Setting up OpenTelemetry
The Tracing Service does **not** configure the OpenTelemetry SDK itself. You are responsible for initializing the OpenTelemetry Node SDK — including exporters, samplers, and resource attributes — before starting the Backstage backend. Follow the [tutorial](../../tutorials/setup-opentelemetry.md) for more information.
## How it Relates to OpenTelemetry Auto-Instrumentation
The Tracing Service **complements** auto-instrumentation rather than replacing it. Auto-instrumentation captures infrastructure-level spans like inbound HTTP requests, outbound HTTP calls, and database queries automatically — including all the standard HTTP / DB attributes. The Tracing Service is for **application-level spans** that only your plugin can produce, and child spans you want to attach to that infrastructure work.
Because HTTP spans are auto-instrumented, you typically should **not** set `http.*` attributes on Tracing Service spans yourself — the parent HTTP span already carries them. Spans you create are children of that HTTP span, in the same trace.
## Using the Service
Since the Tracing Service is an alpha API, the service reference is imported from `@backstage/backend-plugin-api/alpha` instead of `coreServices`.
```ts
import { createBackendPlugin } from '@backstage/backend-plugin-api';
import { tracingServiceRef } from '@backstage/backend-plugin-api/alpha';
createBackendPlugin({
pluginId: 'todos',
register(env) {
env.registerInit({
deps: { tracing: tracingServiceRef },
async init({ tracing }) {
// ... wire up your routes/handlers, holding onto `tracing` ...
const result = await tracing.startActiveSpan(
'process-todo',
async span => {
span.setAttribute('todo.category', 'personal');
// ...do the work...
return computeResult();
},
);
},
});
},
});
```
`startActiveSpan(name, fn, options?)` runs `fn` inside a new active span. The span is finished automatically when `fn` resolves, and on a thrown error the exception is recorded, `error.type` is set from the error's `name`, and the span status is set to `ERROR` — you do not need to write a `try/catch/finally` for that.
Every span emitted through the service is automatically attributed to the calling plugin via `backstage.plugin.id` (matching `pluginMetadata.getId()`). Tracing backends can use this to filter all activity for a given plugin without inspecting the OpenTelemetry instrumentation scope. If your span represents work logically owned by a different plugin (for example, a wrapper that dispatches into another plugin's code), call `span.setAttribute('backstage.plugin.id', 'other-plugin')` from inside the callback to re-attribute it.
## Span Options
The third argument to `startActiveSpan` is an optional options object:
| Property | Type | Description |
| ------------- | -------------------------- | ---------------------------------------------------------------------------------------------------- |
| `attributes` | `TracingServiceAttributes` | Attributes to attach to the span at creation time. |
| `kind` | `TracingServiceSpanKind` | Span kind. Defaults to OpenTelemetry's `internal`. See [Span Kinds](#span-kinds). |
| `credentials` | `BackstageCredentials` | Authenticated principal source — adds principal-derived attributes to the span. |
| `request` | `Request` | HTTP request to extract credentials from (used only for principal extraction, not HTTP attribution). |
### Span Kinds
| Kind | Use Case |
| ------------ | --------------------------------------------------------------------------------- |
| `'internal'` | Default. Internal application work — e.g. processing pipelines, scheduled tasks. |
| `'server'` | Protocol-level inbound request handlers (e.g. an MCP `tools/call` server). |
| `'client'` | Outbound calls. Usually auto-instrumented at the HTTP / RPC client layer instead. |
| `'producer'` | Sending a message to a queue or stream. |
| `'consumer'` | Receiving a message from a queue or stream. |
Most Backstage application-level spans are `internal` — leave `kind` unset and OpenTelemetry's default applies.
## Setting Attributes and Status from Inside the Callback
The callback receives a span object on which you can set additional attributes or status:
```ts
await tracing.startActiveSpan('refresh-entity', async span => {
const entity = await fetchEntity(ref);
span.setAttribute('catalog.entity.kind', entity.kind);
if (entity.spec.deprecated) {
span.setStatus({ code: 'error', message: 'entity is deprecated' });
}
});
```
The span object exposes:
| Method | Description |
| ------------------------------ | -------------------------------------------------------------------- |
| `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'`. |
## Principal Enrichment
When you supply either `credentials` or a `request`, the service adds principal-derived attributes to the span:
- `backstage.principal.type` is always set when a principal is present (`'user'`, `'service'`, or `'none'`). This is a Backstage-specific extension.
- `enduser.id` is set **only when** [`backend.tracing.capture.endUser`](#capturing-the-authenticated-end-user) is enabled at the backend level. For a user principal this is the user entity ref (e.g. `user:default/alice`); for a service principal it is the service subject (e.g. `external:my-service`).
If both `credentials` and `request` are supplied, `credentials` wins — the service does not extract from the request. The `request` is used only for credential extraction and does not influence other span attributes.
```ts
async ({ credentials }) => {
await tracing.startActiveSpan(
'process-tool-call',
async span => {
// ... span automatically has backstage.principal.type, and (if enabled)
// enduser.id matching the credentials' principal ...
},
{ credentials },
);
};
```
### Capturing the authenticated end user
The `backend.tracing.capture.endUser` flag controls whether Tracing Service spans include the authenticated principal's identity as `enduser.id`. It defaults to `false` so identity is not exported by default.
```yaml title="app-config.yaml"
backend:
tracing:
capture:
endUser: true # defaults to false
```
This is a backend-wide configuration honored by every plugin that creates spans through this service.
## Per-Plugin Tracer Configuration
Each plugin automatically receives a tracer named `backstage-plugin-<pluginId>`. Operators can override the OpenTelemetry Instrumentation Scope for a specific plugin without code changes:
```yaml title="app-config.yaml"
backend:
tracing:
plugin:
catalog:
tracer:
name: 'custom-catalog-tracer'
version: '2.0.0'
schemaUrl: 'https://example.com/schema'
```
| Property | Type | Default | Description |
| ----------- | -------- | ----------------------------- | -------------------------------- |
| `name` | `string` | `backstage-plugin-<pluginId>` | Name of the OpenTelemetry tracer |
| `version` | `string` | — | Version string for the tracer |
| `schemaUrl` | `string` | — | Schema URL for the tracer |
:::tip
Most plugins won't need any of this — the defaults are designed to attribute every plugin's spans uniquely without configuration.
:::
+46
View File
@@ -1186,6 +1186,52 @@ export interface Config {
};
};
/**
* Tracing-related backend configuration. Honored by Backstage backend
* plugins that emit OpenTelemetry trace spans.
*/
tracing?: {
/**
* Opt-in capture of attributes that may identify users or contain
* sensitive data on backend trace spans.
*/
capture?: {
/**
* When true, backend plugins emitting trace spans for authenticated
* requests SHOULD include the authenticated principal's identity as
* `enduser.id` (the user entity ref for a user principal, or the
* service subject for a service principal). Defaults to false.
*/
endUser?: boolean;
};
/**
* Plugin-specific tracing configuration. Each plugin can override
* tracer instrumentation scope metadata.
*/
plugin?: {
[pluginId: string]: {
/**
* Tracer configuration for this plugin.
*/
tracer?: {
/**
* Custom tracer name. If not set, defaults to
* backstage-plugin-{pluginId}.
*/
name?: string;
/**
* Version for the tracer.
*/
version?: string;
/**
* Schema URL for the tracer.
*/
schemaUrl?: string;
};
};
};
};
/**
* Options to configure the default RootLoggerService.
*/
@@ -8,6 +8,7 @@ import { ActionsService } from '@backstage/backend-plugin-api/alpha';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
import { RootSystemMetadataService } from '@backstage/backend-plugin-api/alpha';
import { ServiceFactory } from '@backstage/backend-plugin-api';
import { TracingService } from '@backstage/backend-plugin-api/alpha';
// @public (undocumented)
export const actionsRegistryServiceFactory: ServiceFactory<
@@ -37,5 +38,12 @@ export const rootSystemMetadataServiceFactory: ServiceFactory<
'singleton'
>;
// @alpha
export const tracingServiceFactory: ServiceFactory<
TracingService,
'plugin',
'singleton'
>;
// (No @packageDocumentation comment for this package)
```
@@ -40,6 +40,7 @@ import {
actionsRegistryServiceFactory,
actionsServiceFactory,
metricsServiceFactory,
tracingServiceFactory,
} from '@backstage/backend-defaults/alpha';
import { instanceMetadataServiceFactory } from './alpha/entrypoints/instanceMetadata/instanceMetadataServiceFactory';
@@ -70,6 +71,7 @@ export const defaultServiceFactories: ServiceFactory[] = [
actionsRegistryServiceFactory,
actionsServiceFactory,
metricsServiceFactory,
tracingServiceFactory,
// Unexported alpha services kept around for compatibility reasons
instanceMetadataServiceFactory,
@@ -0,0 +1,270 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SpanKind, SpanStatusCode, trace } from '@opentelemetry/api';
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import { DefaultTracingService } from './DefaultTracingService';
type MockSpan = {
setAttribute: jest.Mock;
setStatus: jest.Mock;
recordException: jest.Mock;
end: jest.Mock;
};
type MockTracer = {
startActiveSpan: jest.Mock;
};
function createMockTracingPrimitives() {
const span: MockSpan = {
setAttribute: jest.fn(),
setStatus: jest.fn(),
recordException: jest.fn(),
end: jest.fn(),
};
const tracer: MockTracer = {
startActiveSpan: jest.fn(async (_name, _options, fn) => fn(span)),
};
const getTracer = jest.fn(() => tracer);
return { span, tracer, getTracer };
}
describe('DefaultTracingService', () => {
let mocks: ReturnType<typeof createMockTracingPrimitives>;
let getTracerProviderSpy: jest.SpyInstance;
beforeEach(() => {
mocks = createMockTracingPrimitives();
getTracerProviderSpy = jest
.spyOn(trace, 'getTracerProvider')
.mockReturnValue({ getTracer: mocks.getTracer } as any);
});
afterEach(() => {
jest.restoreAllMocks();
});
function createService(opts?: {
captureEndUser?: boolean;
name?: string;
version?: string;
schemaUrl?: string;
pluginId?: string;
}) {
return DefaultTracingService.create({
name: opts?.name ?? 'backstage-plugin-test',
version: opts?.version,
schemaUrl: opts?.schemaUrl,
pluginId: opts?.pluginId ?? 'test',
captureEndUser: opts?.captureEndUser ?? false,
httpAuth: mockServices.httpAuth(),
});
}
it('configures the tracer with name, version, and schemaUrl', () => {
createService({
name: 'tracer-x',
version: '1.2.3',
schemaUrl: 'https://example.com/schema',
});
expect(getTracerProviderSpy).toHaveBeenCalled();
expect(mocks.getTracer).toHaveBeenCalledWith('tracer-x', '1.2.3', {
schemaUrl: 'https://example.com/schema',
});
});
it('passes name, kind, and caller attributes through to the tracer', async () => {
const service = createService();
await service.startActiveSpan('op', async () => undefined, {
kind: 'server',
attributes: { foo: 'bar' },
});
expect(mocks.tracer.startActiveSpan).toHaveBeenCalledWith(
'op',
expect.objectContaining({
kind: SpanKind.SERVER,
attributes: expect.objectContaining({ foo: 'bar' }),
}),
expect.any(Function),
);
});
it('auto-attaches backstage.plugin.id matching the calling plugin', async () => {
const service = createService({ pluginId: 'my-plugin' });
await service.startActiveSpan('op', async () => undefined);
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
expect(attrs['backstage.plugin.id']).toBe('my-plugin');
});
it('lets caller-supplied attributes override backstage.plugin.id at start time', async () => {
const service = createService({ pluginId: 'my-plugin' });
await service.startActiveSpan('op', async () => undefined, {
attributes: { 'backstage.plugin.id': 'other-plugin' },
});
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
expect(attrs['backstage.plugin.id']).toBe('other-plugin');
});
it('omits kind when none is supplied (OTel default applies)', async () => {
const service = createService();
await service.startActiveSpan('op', async () => undefined);
const [, options] = mocks.tracer.startActiveSpan.mock.calls[0];
expect(options.kind).toBeUndefined();
});
it('translates each Backstage span kind into the matching OTel SpanKind', async () => {
const service = createService();
const cases: Array<[string, SpanKind]> = [
['internal', SpanKind.INTERNAL],
['server', SpanKind.SERVER],
['client', SpanKind.CLIENT],
['producer', SpanKind.PRODUCER],
['consumer', SpanKind.CONSUMER],
];
for (const [kind, expected] of cases) {
mocks.tracer.startActiveSpan.mockClear();
await service.startActiveSpan('op', async () => undefined, {
kind: kind as any,
});
expect(mocks.tracer.startActiveSpan.mock.calls[0][1].kind).toBe(expected);
}
});
it('adds backstage.principal.type but not enduser.id when capture is off', async () => {
const service = createService({ captureEndUser: false });
await service.startActiveSpan('op', async () => undefined, {
credentials: mockCredentials.user('user:default/alice'),
});
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
expect(attrs['backstage.principal.type']).toBe('user');
expect(attrs).not.toHaveProperty('enduser.id');
});
it('adds enduser.id from a user principal when capture is on', async () => {
const service = createService({ captureEndUser: true });
await service.startActiveSpan('op', async () => undefined, {
credentials: mockCredentials.user('user:default/alice'),
});
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
expect(attrs['enduser.id']).toBe('user:default/alice');
});
it('adds enduser.id from a service principal subject when capture is on', async () => {
const service = createService({ captureEndUser: true });
await service.startActiveSpan('op', async () => undefined, {
credentials: mockCredentials.service('plugin:test'),
});
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
expect(attrs['enduser.id']).toBe('plugin:test');
expect(attrs['backstage.principal.type']).toBe('service');
});
it('extracts credentials from a request via httpAuth when credentials are not supplied', async () => {
const httpAuth = mockServices.httpAuth();
const credSpy = jest.spyOn(httpAuth, 'credentials');
const service = DefaultTracingService.create({
name: 'backstage-plugin-test',
pluginId: 'test',
captureEndUser: true,
httpAuth,
});
await service.startActiveSpan('op', async () => undefined, {
request: { headers: {} } as any,
});
expect(credSpy).toHaveBeenCalledTimes(1);
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
expect(attrs['enduser.id']).toBe('user:default/mock');
});
it('prefers credentials over request when both are supplied (no httpAuth call)', async () => {
const httpAuth = mockServices.httpAuth();
const credSpy = jest.spyOn(httpAuth, 'credentials');
const service = DefaultTracingService.create({
name: 'backstage-plugin-test',
pluginId: 'test',
captureEndUser: true,
httpAuth,
});
await service.startActiveSpan('op', async () => undefined, {
credentials: mockCredentials.user('user:default/explicit'),
request: { headers: {} } as any,
});
expect(credSpy).not.toHaveBeenCalled();
const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes;
expect(attrs['enduser.id']).toBe('user:default/explicit');
});
it('translates Backstage span status codes to OTel SpanStatusCode on the underlying span', async () => {
const service = createService();
await service.startActiveSpan('op', async span => {
span.setStatus({ code: 'ok' });
});
expect(mocks.span.setStatus).toHaveBeenCalledWith({
code: SpanStatusCode.OK,
message: undefined,
});
mocks.span.setStatus.mockClear();
await service.startActiveSpan('op', async span => {
span.setStatus({ code: 'error', message: 'boom' });
});
expect(mocks.span.setStatus).toHaveBeenCalledWith({
code: SpanStatusCode.ERROR,
message: 'boom',
});
});
it('records exceptions, sets error.type, sets ERROR status, and ends the span on throw', async () => {
const service = createService();
const boom = new Error('Boom');
boom.name = 'CustomError';
await expect(
service.startActiveSpan('op', async () => {
throw boom;
}),
).rejects.toThrow('Boom');
expect(mocks.span.recordException).toHaveBeenCalledWith(boom);
expect(mocks.span.setAttribute).toHaveBeenCalledWith(
'error.type',
'CustomError',
);
expect(mocks.span.setStatus).toHaveBeenCalledWith({
code: SpanStatusCode.ERROR,
message: 'Boom',
});
expect(mocks.span.end).toHaveBeenCalled();
});
it('ends the span and returns the value on success', async () => {
const service = createService();
const value = await service.startActiveSpan('op', () => 42);
expect(value).toBe(42);
expect(mocks.span.end).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,171 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SpanKind, SpanStatusCode, Tracer, trace } from '@opentelemetry/api';
import {
BackstageCredentials,
HttpAuthService,
} from '@backstage/backend-plugin-api';
import {
TracingService,
TracingServiceAttributes,
TracingServiceSpan,
TracingServiceSpanKind,
TracingServiceSpanOptions,
TracingServiceSpanStatus,
} from '@backstage/backend-plugin-api/alpha';
/**
* Options for creating a {@link DefaultTracingService}.
*
* @alpha
*/
export interface DefaultTracingServiceOptions {
name: string;
version?: string;
schemaUrl?: string;
pluginId: string;
captureEndUser: boolean;
httpAuth: HttpAuthService;
}
/**
* Default implementation of the {@link TracingService} interface.
*
* @alpha
*/
export class DefaultTracingService implements TracingService {
private readonly tracer: Tracer;
private readonly pluginId: string;
private readonly captureEndUser: boolean;
private readonly httpAuth: HttpAuthService;
private constructor(opts: DefaultTracingServiceOptions) {
this.tracer = trace
.getTracerProvider()
.getTracer(opts.name, opts.version, { schemaUrl: opts.schemaUrl });
this.pluginId = opts.pluginId;
this.captureEndUser = opts.captureEndUser;
this.httpAuth = opts.httpAuth;
}
static create(opts: DefaultTracingServiceOptions): TracingService {
return new DefaultTracingService(opts);
}
async startActiveSpan<T>(
name: string,
fn: (span: TracingServiceSpan) => T | Promise<T>,
options: TracingServiceSpanOptions = {},
): Promise<T> {
let credentials = options.credentials;
if (!credentials && options.request) {
credentials = await this.httpAuth.credentials(options.request);
}
const principalAttributes = this.getPrincipalAttributes(credentials);
const attributes: TracingServiceAttributes = {
'backstage.plugin.id': this.pluginId,
...options.attributes,
...principalAttributes,
};
return this.tracer.startActiveSpan(
name,
{ kind: toSpanKind(options.kind), attributes },
async span => {
try {
const wrapped: TracingServiceSpan = {
setAttribute(key, value) {
span.setAttribute(key, value);
},
setStatus(status) {
span.setStatus({
code: toSpanStatusCode(status.code),
message: status.message,
});
},
};
const result = await fn(wrapped);
span.end();
return result;
} catch (err) {
const error = err as Error;
span.recordException(error);
span.setAttribute('error.type', error.name || 'Error');
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message || String(error),
});
span.end();
throw err;
}
},
);
}
private getPrincipalAttributes(
credentials: BackstageCredentials | undefined,
): TracingServiceAttributes {
if (!credentials) return {};
const principal = credentials.principal as
| { type?: string; userEntityRef?: string; subject?: string }
| undefined;
if (!principal?.type) return {};
const attrs: TracingServiceAttributes = {
'backstage.principal.type': principal.type,
};
if (!this.captureEndUser) return attrs;
if (principal.type === 'user' && principal.userEntityRef) {
attrs['enduser.id'] = principal.userEntityRef;
} else if (principal.type === 'service' && principal.subject) {
attrs['enduser.id'] = principal.subject;
}
return attrs;
}
}
function toSpanKind(
kind: TracingServiceSpanKind | undefined,
): SpanKind | undefined {
switch (kind) {
case 'internal':
return SpanKind.INTERNAL;
case 'server':
return SpanKind.SERVER;
case 'client':
return SpanKind.CLIENT;
case 'producer':
return SpanKind.PRODUCER;
case 'consumer':
return SpanKind.CONSUMER;
default:
return undefined;
}
}
function toSpanStatusCode(
code: TracingServiceSpanStatus['code'],
): SpanStatusCode {
switch (code) {
case 'ok':
return SpanStatusCode.OK;
case 'error':
return SpanStatusCode.ERROR;
default:
return SpanStatusCode.UNSET;
}
}
@@ -0,0 +1,16 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { tracingServiceFactory } from './tracingServiceFactory';
@@ -0,0 +1,133 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
mockServices,
ServiceFactoryTester,
} from '@backstage/backend-test-utils';
import { tracingServiceFactory } from './tracingServiceFactory';
import { DefaultTracingService } from './DefaultTracingService';
describe('tracingServiceFactory', () => {
let createSpy: jest.SpyInstance;
beforeEach(() => {
createSpy = jest.spyOn(DefaultTracingService, 'create');
});
afterEach(() => {
jest.restoreAllMocks();
});
const defaultDeps = [
mockServices.rootConfig.factory(),
mockServices.httpAuth.factory(),
tracingServiceFactory,
];
it('uses backstage-plugin-{pluginId} as the tracer name when no config is set', async () => {
await ServiceFactoryTester.from(tracingServiceFactory, {
dependencies: defaultDeps,
}).getSubject('my-plugin');
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'backstage-plugin-my-plugin',
version: undefined,
schemaUrl: undefined,
pluginId: 'my-plugin',
captureEndUser: false,
}),
);
});
it('uses a custom tracer name from config', async () => {
await ServiceFactoryTester.from(tracingServiceFactory, {
dependencies: [
mockServices.rootConfig.factory({
data: {
backend: {
tracing: {
plugin: {
'my-plugin': {
tracer: { name: 'custom-tracer-name' },
},
},
},
},
},
}),
mockServices.httpAuth.factory(),
tracingServiceFactory,
],
}).getSubject('my-plugin');
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'custom-tracer-name',
}),
);
});
it('accepts version and schemaUrl from config', async () => {
await ServiceFactoryTester.from(tracingServiceFactory, {
dependencies: [
mockServices.rootConfig.factory({
data: {
backend: {
tracing: {
plugin: {
'my-plugin': {
tracer: {
name: 'my-plugin-tracer',
version: '1.2.3',
schemaUrl: 'https://example.com/schema',
},
},
},
},
},
},
}),
mockServices.httpAuth.factory(),
tracingServiceFactory,
],
}).getSubject('my-plugin');
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'my-plugin-tracer',
version: '1.2.3',
schemaUrl: 'https://example.com/schema',
}),
);
});
it('reads backend.tracing.capture.endUser into the service', async () => {
await ServiceFactoryTester.from(tracingServiceFactory, {
dependencies: [
mockServices.rootConfig.factory({
data: { backend: { tracing: { capture: { endUser: true } } } },
}),
mockServices.httpAuth.factory(),
tracingServiceFactory,
],
}).getSubject('my-plugin');
expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({ captureEndUser: true }),
);
});
});
@@ -0,0 +1,59 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { tracingServiceRef } from '@backstage/backend-plugin-api/alpha';
import {
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { DefaultTracingService } from './DefaultTracingService';
/**
* Service factory for emitting plugin-scoped trace spans.
*
* @alpha
*/
export const tracingServiceFactory = createServiceFactory({
service: tracingServiceRef,
deps: {
config: coreServices.rootConfig,
pluginMetadata: coreServices.pluginMetadata,
httpAuth: coreServices.httpAuth,
},
factory: ({ config, pluginMetadata, httpAuth }) => {
const pluginId = pluginMetadata.getId();
const tracerConfig = config.getOptionalConfig(
`backend.tracing.plugin.${pluginId}.tracer`,
);
const scopeName = `backstage-plugin-${pluginId}`;
const name = tracerConfig?.getOptionalString('name') ?? scopeName;
const version = tracerConfig?.getOptionalString('version');
const schemaUrl = tracerConfig?.getOptionalString('schemaUrl');
const captureEndUser =
config.getOptionalBoolean('backend.tracing.capture.endUser') ?? false;
return DefaultTracingService.create({
name,
version,
schemaUrl,
pluginId,
captureEndUser,
httpAuth,
});
},
});
@@ -18,3 +18,4 @@ export { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry';
export { actionsServiceFactory } from './entrypoints/actions';
export { metricsServiceFactory } from './entrypoints/metrics';
export { rootSystemMetadataServiceFactory } from './entrypoints/rootSystemMetadata';
export { tracingServiceFactory } from './entrypoints/tracing';