refactor(API): Migrate GET /settings/otel to the decorator pattern (no-changelog) (#39720)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Uddish Verma <uddish.verma@n8n.io>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Declan Carroll <declancarroll@gmail.com>
This commit is contained in:
n8n-cat-bot[bot] 2026-09-29 12:41:18 +00:00 committed by GitHub
parent 41a0ccaf16
commit 7b12ec165d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 271 additions and 35 deletions

View File

@ -588,6 +588,10 @@ export {
type OtlpProtocol,
} from './otel/update-otel-settings.dto';
export { TestOtelTraceDto } from './otel/test-otel-trace.dto';
export {
GetOtelSettingsQueryPublicDto,
OtelSettingsPublicDto,
} from './otel/otel-settings-public.dto';
export {
PromotionChangesDto,

View File

@ -0,0 +1,31 @@
import '../../openapi-extend';
import { z } from 'zod';
import {
otelSettingsPublicDescription,
otelSettingsPublicFieldDocs as docs,
} from './otel-settings-public.openapi';
import { OTLP_PROTOCOLS } from './update-otel-settings.dto';
import { Z } from '../../zod-class';
export class OtelSettingsPublicDto extends Z.class(
{
enabled: z.boolean().openapi(docs.enabled),
exporterProtocol: z.string().openapi({ ...docs.exporterProtocol, enum: [...OTLP_PROTOCOLS] }),
exporterEndpoint: z.string().openapi(docs.exporterEndpoint),
exporterTracingPath: z.string().openapi(docs.exporterTracingPath),
exporterServiceName: z.string().openapi(docs.exporterServiceName),
exporterHeaders: z.string().openapi(docs.exporterHeaders),
tracesSampleRate: z.number().openapi(docs.tracesSampleRate),
startupConnectivityTimeoutMs: z.number().openapi(docs.startupConnectivityTimeoutMs),
includeNodeSpans: z.boolean().openapi(docs.includeNodeSpans),
injectOutbound: z.boolean().openapi(docs.injectOutbound),
productionExecutionsOnly: z.boolean().openapi(docs.productionExecutionsOnly),
},
{ strict: true },
) {
static schema = super.schema.openapi({ description: otelSettingsPublicDescription });
}
export class GetOtelSettingsQueryPublicDto extends Z.class({}, { strict: true }) {}

View File

@ -0,0 +1,77 @@
import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi';
export const otelSettingsPublicDescription =
'The OpenTelemetry configuration, matching the fields exposed in the UI. On a write this is a ' +
'full replacement: every field must be provided, except `exporterProtocol`, which defaults to ' +
'`http/protobuf` when omitted. Fields managed declaratively via environment variables are ' +
'returned with their effective value. A write can repeat these values, but changing them returns 409.';
export const otelSettingsPublicFieldDocs = {
enabled: { description: 'Whether OpenTelemetry tracing is enabled.', example: true },
exporterProtocol: {
default: 'http/protobuf',
description:
'The wire protocol used to export spans. Collectors conventionally serve OTLP/HTTP on port ' +
'4318 and OTLP/gRPC on port 4317. The endpoint scheme (`http://` or `https://`) controls ' +
'TLS for both protocols. Optional: a write that omits it selects `http/protobuf`, but an ' +
'instance that sets this field to another value with an environment variable rejects that ' +
'write with 409.',
example: 'http/protobuf',
},
exporterEndpoint: {
format: 'uri',
description:
'The base URL of the OTLP collector to export traces to. The value must be an `http://` or ' +
'`https://` URL, because the scheme selects TLS — for gRPC, `https://` uses SSL and ' +
'`http://` is plaintext.',
example: 'http://localhost:4318',
},
exporterTracingPath: {
description:
'The path appended to the endpoint for the OTLP traces signal. Ignored when ' +
'`exporterProtocol` is `grpc`, which takes no URL path.',
example: '/v1/traces',
},
exporterServiceName: {
minLength: 1,
description: 'The `service.name` resource attribute reported on every span.',
example: 'n8n',
},
exporterHeaders: {
description:
'Additional headers sent to the OTLP collector, as a single string of comma-separated ' +
'`key=value` pairs (e.g. `authorization=Bearer my-token,x-tenant-id=acme`). Whitespace ' +
'around each key and value is trimmed; a value may contain spaces but not commas. Use an ' +
'empty string when unused.',
example: 'authorization=Bearer my-token,x-tenant-id=acme',
},
tracesSampleRate: {
minimum: 0,
maximum: 1,
description: 'The ratio of traces to sample, between 0 (none) and 1 (all).',
example: 1,
},
startupConnectivityTimeoutMs: {
type: 'integer',
minimum: 0,
description:
'How long, in milliseconds, to wait when checking the collector is reachable. Also used as ' +
'the timeout for the test-trace endpoint.',
example: 2000,
},
includeNodeSpans: {
description: 'Whether to emit a span for each node execution in addition to the workflow span.',
example: true,
},
injectOutbound: {
description:
'Whether to inject trace context headers into outbound HTTP requests made by nodes.',
example: true,
},
productionExecutionsOnly: {
description:
'When true, only production executions of published (active) workflows are traced, not ' +
'manual/test runs.',
example: true,
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;

View File

@ -205,7 +205,6 @@ export declare namespace SsoSamlRequest {
// ----------------------------------
export declare namespace OtelSettingsRequest {
type Get = AuthenticatedRequest;
type Update = AuthenticatedRequest<{}, {}, UpdateOtelSettingsDto>;
type Test = AuthenticatedRequest<{}, {}, TestOtelTraceDto>;
}

View File

@ -14,6 +14,7 @@ import './executions.public.controller';
import './folders.public.controller';
import './insights.public.controller';
import './node-type-policies.public.controller';
import './otel.public.controller';
import './projects.public.controller';
import './promotions.public.controller';
import './role-mapping-rules.public.controller';

View File

@ -0,0 +1,40 @@
import { GetOtelSettingsQueryPublicDto, OtelSettingsPublicDto } from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import {
ApiDescription,
ApiKeyScope,
ApiResponse,
ApiSummary,
ApiTags,
Get,
PublicApiController,
Query,
} from '@n8n/decorators';
import type { Response } from 'express';
import { OtelSettingsService } from '@/modules/otel/otel-settings.service';
import { toOtelSettingsResponse } from '@/public-api/v1/shared/otel.mapper';
@PublicApiController('/settings/otel')
export class OtelPublicController {
constructor(private readonly settingsService: OtelSettingsService) {}
@Get('/')
@ApiKeyScope('otel:manage')
@ApiSummary('Retrieve the OpenTelemetry configuration')
@ApiDescription(
'Retrieve the current OpenTelemetry configuration, including every field exposed in the UI. Requires the `otel:manage` scope.',
)
@ApiTags(['SettingsOtel'])
@ApiResponse(200, OtelSettingsPublicDto)
async getOtelSettings(
_req: AuthenticatedRequest,
_res: Response,
@Query _query: GetOtelSettingsQueryPublicDto,
): Promise<OtelSettingsPublicDto> {
await this.settingsService.loadSettings();
const settings = this.settingsService.getSettings();
return toOtelSettingsResponse(settings);
}
}

View File

@ -8,28 +8,17 @@ import { OtelSettingsService } from '@/modules/otel/otel-settings.service';
import { OtelService } from '@/modules/otel/otel.service';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { toOtelSettingsResponse } from './otel.mapper';
import { toOtelSettingsResponse } from '../../shared/otel.mapper';
import type { OtelSettingsRequest } from '../../../types';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import { apiKeyHasScopeWithGlobalScopeFallback } from '../../shared/middlewares/global.middleware';
type OtelHandlers = {
getOtelSettings: PublicAPIEndpoint<OtelSettingsRequest.Get>;
updateOtelSettings: PublicAPIEndpoint<OtelSettingsRequest.Update>;
testOtelTrace: PublicAPIEndpoint<OtelSettingsRequest.Test>;
};
const otelHandlers: OtelHandlers = {
getOtelSettings: [
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'otel:manage' }),
async (_req, res) => {
const settingsService = Container.get(OtelSettingsService);
await settingsService.loadSettings();
return res.json(toOtelSettingsResponse(settingsService.getSettings()));
},
],
updateOtelSettings: [
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'otel:manage' }),
async (req, res) => {

View File

@ -1,24 +1,3 @@
get:
x-eov-operation-id: getOtelSettings
x-required-scope: otel:manage
x-eov-operation-handler: v1/handlers/otel/otel.handler
tags:
- SettingsOtel
summary: Retrieve the OpenTelemetry configuration
description: >
Retrieve the current OpenTelemetry configuration, including every field exposed in the UI.
Requires the `otel:manage` scope.
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/otel-settings.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
put:
x-eov-operation-id: updateOtelSettings
x-required-scope: otel:manage

View File

@ -4,7 +4,7 @@ description: >
The OpenTelemetry configuration, matching the fields exposed in the UI. On a write this is a
full replacement: every field must be provided, except `exporterProtocol`, which defaults to
`http/protobuf` when omitted. Fields managed declaratively via environment variables are
returned with their effective value and ignored on write.
returned with their effective value. A write can repeat these values, but changing them returns 409.
required:
- enabled
- exporterEndpoint

View File

@ -0,0 +1,90 @@
operationId: getOtelSettings
tags:
- SettingsOtel
summary: Retrieve the OpenTelemetry configuration
description: Retrieve the current OpenTelemetry configuration, including every field exposed in the UI. Requires the `otel:manage` scope.
x-required-scope: otel:manage
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
type: object
properties:
enabled:
type: boolean
description: Whether OpenTelemetry tracing is enabled.
example: true
exporterProtocol:
type: string
default: http/protobuf
description: 'The wire protocol used to export spans. Collectors conventionally serve OTLP/HTTP on port 4318 and OTLP/gRPC on port 4317. The endpoint scheme (`http://` or `https://`) controls TLS for both protocols. Optional: a write that omits it selects `http/protobuf`, but an instance that sets this field to another value with an environment variable rejects that write with 409.'
example: http/protobuf
enum:
- http/protobuf
- grpc
exporterEndpoint:
type: string
format: uri
description: The base URL of the OTLP collector to export traces to. The value must be an `http://` or `https://` URL, because the scheme selects TLS — for gRPC, `https://` uses SSL and `http://` is plaintext.
example: http://localhost:4318
exporterTracingPath:
type: string
description: The path appended to the endpoint for the OTLP traces signal. Ignored when `exporterProtocol` is `grpc`, which takes no URL path.
example: /v1/traces
exporterServiceName:
type: string
minLength: 1
description: The `service.name` resource attribute reported on every span.
example: n8n
exporterHeaders:
type: string
description: Additional headers sent to the OTLP collector, as a single string of comma-separated `key=value` pairs (e.g. `authorization=Bearer my-token,x-tenant-id=acme`). Whitespace around each key and value is trimmed; a value may contain spaces but not commas. Use an empty string when unused.
example: authorization=Bearer my-token,x-tenant-id=acme
tracesSampleRate:
type: number
description: The ratio of traces to sample, between 0 (none) and 1 (all).
minimum: 0
maximum: 1
example: 1
startupConnectivityTimeoutMs:
type: integer
description: How long, in milliseconds, to wait when checking the collector is reachable. Also used as the timeout for the test-trace endpoint.
minimum: 0
example: 2000
includeNodeSpans:
type: boolean
description: Whether to emit a span for each node execution in addition to the workflow span.
example: true
injectOutbound:
type: boolean
description: Whether to inject trace context headers into outbound HTTP requests made by nodes.
example: true
productionExecutionsOnly:
type: boolean
description: When true, only production executions of published (active) workflows are traced, not manual/test runs.
example: true
required:
- enabled
- exporterProtocol
- exporterEndpoint
- exporterTracingPath
- exporterServiceName
- exporterHeaders
- tracesSampleRate
- startupConnectivityTimeoutMs
- includeNodeSpans
- injectOutbound
- productionExecutionsOnly
additionalProperties: false
description: 'The OpenTelemetry configuration, matching the fields exposed in the UI. On a write this is a full replacement: every field must be provided, except `exporterProtocol`, which defaults to `http/protobuf` when omitted. Fields managed declaratively via environment variables are returned with their effective value. A write can repeat these values, but changing them returns 409.'
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml

View File

@ -156,6 +156,9 @@ paths:
/node-type-policies/scopes/{scopeId}/attachments:
put:
$ref: ./handlers/node-type-policies/spec/paths/replaceNodeTypePolicyAttachments.generated.yml
/settings/otel:
get:
$ref: ./handlers/settings/spec/paths/getOtelSettings.generated.yml
/projects:
get:
$ref: ./handlers/projects/spec/paths/getProjects.generated.yml

View File

@ -74,6 +74,18 @@ describe('OpenTelemetry settings in Public API', () => {
expect(typeof response.body.exporterEndpoint).toBe('string');
});
it('returns saved settings with masked exporter headers', async () => {
await testServer.publicApiAgentFor(owner).put('/settings/otel').send(validSettings);
const response = await testServer.publicApiAgentFor(owner).get('/settings/otel');
expect(response.status).toBe(200);
expect(response.body).toStrictEqual({
...validSettings,
exporterHeaders: `authorization=${CREDENTIAL_BLANKING_VALUE}`,
});
});
it('exposes exactly the fields the UI configures, and nothing more', async () => {
const response = await testServer.publicApiAgentFor(owner).get('/settings/otel');
@ -109,6 +121,17 @@ describe('OpenTelemetry settings in Public API', () => {
const response = await testServer.publicApiAgentFor(scopedOwner).get('/settings/otel');
expect(response.status).toBe(403);
expect(response.body).toEqual({ message: 'Forbidden' });
});
it('rejects an unknown query parameter with 400', async () => {
const response = await testServer
.publicApiAgentFor(owner)
.get('/settings/otel')
.query({ unexpected: 'value' });
expect(response.status).toBe(400);
expect(response.body.message).toContain('unexpected');
});
});