From 130db0d6c685fb4dd50671bc1bd2d2d537aa3274 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 28 Apr 2026 17:23:56 +0200 Subject: [PATCH] Initial default implementation of the tracing service + docs Signed-off-by: Eric Peterson --- .changeset/backend-tracing-and-stuff.md | 1 + docs/backend-system/core-services/01-index.md | 1 + docs/backend-system/core-services/tracing.md | 161 +++++++++++ packages/backend-defaults/config.d.ts | 46 +++ packages/backend-defaults/report-alpha.api.md | 8 + .../backend-defaults/src/CreateBackend.ts | 2 + .../tracing/DefaultTracingService.test.ts | 270 ++++++++++++++++++ .../tracing/DefaultTracingService.ts | 171 +++++++++++ .../src/alpha/entrypoints/tracing/index.ts | 16 ++ .../tracing/tracingServiceFactory.test.ts | 133 +++++++++ .../tracing/tracingServiceFactory.ts | 59 ++++ packages/backend-defaults/src/alpha/index.ts | 1 + 12 files changed, 869 insertions(+) create mode 100644 docs/backend-system/core-services/tracing.md create mode 100644 packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts create mode 100644 packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts create mode 100644 packages/backend-defaults/src/alpha/entrypoints/tracing/index.ts create mode 100644 packages/backend-defaults/src/alpha/entrypoints/tracing/tracingServiceFactory.test.ts create mode 100644 packages/backend-defaults/src/alpha/entrypoints/tracing/tracingServiceFactory.ts diff --git a/.changeset/backend-tracing-and-stuff.md b/.changeset/backend-tracing-and-stuff.md index 0909a5c250..c41742472f 100644 --- a/.changeset/backend-tracing-and-stuff.md +++ b/.changeset/backend-tracing-and-stuff.md @@ -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. diff --git a/docs/backend-system/core-services/01-index.md b/docs/backend-system/core-services/01-index.md index 7da7fc1efa..a857598ffb 100644 --- a/docs/backend-system/core-services/01-index.md +++ b/docs/backend-system/core-services/01-index.md @@ -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. diff --git a/docs/backend-system/core-services/tracing.md b/docs/backend-system/core-services/tracing.md new file mode 100644 index 0000000000..66c6a35d3d --- /dev/null +++ b/docs/backend-system/core-services/tracing.md @@ -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-`. 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-` | 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. +::: diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index dc8ecfc644..d8f2e9b031 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -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. */ diff --git a/packages/backend-defaults/report-alpha.api.md b/packages/backend-defaults/report-alpha.api.md index 352ec1d688..dae718d3b5 100644 --- a/packages/backend-defaults/report-alpha.api.md +++ b/packages/backend-defaults/report-alpha.api.md @@ -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) ``` diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index d069c17b99..07c5055641 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -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, diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts new file mode 100644 index 0000000000..9675ecd8ef --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts @@ -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; + 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); + }); +}); diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts new file mode 100644 index 0000000000..77049cedfd --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts @@ -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( + name: string, + fn: (span: TracingServiceSpan) => T | Promise, + options: TracingServiceSpanOptions = {}, + ): Promise { + 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; + } +} diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/index.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/index.ts new file mode 100644 index 0000000000..5d432d4ea3 --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/index.ts @@ -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'; diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/tracingServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/tracingServiceFactory.test.ts new file mode 100644 index 0000000000..06870818de --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/tracingServiceFactory.test.ts @@ -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 }), + ); + }); +}); diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/tracingServiceFactory.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/tracingServiceFactory.ts new file mode 100644 index 0000000000..69008656a0 --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/tracingServiceFactory.ts @@ -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, + }); + }, +}); diff --git a/packages/backend-defaults/src/alpha/index.ts b/packages/backend-defaults/src/alpha/index.ts index 1d0ec158af..7dfefbdae5 100644 --- a/packages/backend-defaults/src/alpha/index.ts +++ b/packages/backend-defaults/src/alpha/index.ts @@ -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';