Initial default implementation of the tracing service + docs
Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
+270
@@ -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';
|
||||
+133
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user