From 7b12ec165ddf998e86198dba76720cccbd8d125f Mon Sep 17 00:00:00 2001 From: "n8n-cat-bot[bot]" <283985454+n8n-cat-bot[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:41:18 +0000 Subject: [PATCH] refactor(API): Migrate GET /settings/otel to the decorator pattern (no-changelog) (#39720) Co-authored-by: n8n-cat-bot[bot] Co-authored-by: Uddish Verma Co-authored-by: Claude Fable 5.1 Co-authored-by: Declan Carroll --- packages/@n8n/api-types/src/dto/index.ts | 4 + .../src/dto/otel/otel-settings-public.dto.ts | 31 +++++++ .../dto/otel/otel-settings-public.openapi.ts | 77 ++++++++++++++++ packages/cli/src/public-api/types.ts | 1 - .../src/public-api/v1/controllers/index.ts | 1 + .../v1/controllers/otel.public.controller.ts | 40 +++++++++ .../v1/handlers/otel/otel.handler.ts | 13 +-- .../otel/spec/paths/settings.otel.yml | 21 ----- .../otel/spec/schemas/otel-settings.yml | 2 +- .../spec/paths/getOtelSettings.generated.yml | 90 +++++++++++++++++++ .../v1/openapi.decorator-routes.generated.yml | 3 + .../{handlers/otel => shared}/otel.mapper.ts | 0 .../public-api/otel-settings.test.ts | 23 +++++ 13 files changed, 271 insertions(+), 35 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/otel/otel-settings-public.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/otel/otel-settings-public.openapi.ts create mode 100644 packages/cli/src/public-api/v1/controllers/otel.public.controller.ts create mode 100644 packages/cli/src/public-api/v1/handlers/settings/spec/paths/getOtelSettings.generated.yml rename packages/cli/src/public-api/v1/{handlers/otel => shared}/otel.mapper.ts (100%) diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index d0fda76bd05..d215bbbb6bd 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/dto/otel/otel-settings-public.dto.ts b/packages/@n8n/api-types/src/dto/otel/otel-settings-public.dto.ts new file mode 100644 index 00000000000..76274e0d2df --- /dev/null +++ b/packages/@n8n/api-types/src/dto/otel/otel-settings-public.dto.ts @@ -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 }) {} diff --git a/packages/@n8n/api-types/src/dto/otel/otel-settings-public.openapi.ts b/packages/@n8n/api-types/src/dto/otel/otel-settings-public.openapi.ts new file mode 100644 index 00000000000..c96e6ff15e6 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/otel/otel-settings-public.openapi.ts @@ -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; diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index f1f85200166..b640e5785da 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -205,7 +205,6 @@ export declare namespace SsoSamlRequest { // ---------------------------------- export declare namespace OtelSettingsRequest { - type Get = AuthenticatedRequest; type Update = AuthenticatedRequest<{}, {}, UpdateOtelSettingsDto>; type Test = AuthenticatedRequest<{}, {}, TestOtelTraceDto>; } diff --git a/packages/cli/src/public-api/v1/controllers/index.ts b/packages/cli/src/public-api/v1/controllers/index.ts index e2b01b4948a..090513b6a4d 100644 --- a/packages/cli/src/public-api/v1/controllers/index.ts +++ b/packages/cli/src/public-api/v1/controllers/index.ts @@ -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'; diff --git a/packages/cli/src/public-api/v1/controllers/otel.public.controller.ts b/packages/cli/src/public-api/v1/controllers/otel.public.controller.ts new file mode 100644 index 00000000000..8606477ecb5 --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/otel.public.controller.ts @@ -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 { + await this.settingsService.loadSettings(); + const settings = this.settingsService.getSettings(); + + return toOtelSettingsResponse(settings); + } +} diff --git a/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts b/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts index a4850ccdd2c..3b8ff9e3fc9 100644 --- a/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts @@ -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; updateOtelSettings: PublicAPIEndpoint; testOtelTrace: PublicAPIEndpoint; }; 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) => { diff --git a/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.yml b/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.yml index 87ae25b7f8b..183fc88f0f5 100644 --- a/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.yml +++ b/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.yml @@ -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 diff --git a/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-settings.yml b/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-settings.yml index d48ce74cf2f..05b951cf09d 100644 --- a/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-settings.yml +++ b/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-settings.yml @@ -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 diff --git a/packages/cli/src/public-api/v1/handlers/settings/spec/paths/getOtelSettings.generated.yml b/packages/cli/src/public-api/v1/handlers/settings/spec/paths/getOtelSettings.generated.yml new file mode 100644 index 00000000000..fdcaa6f3f44 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/settings/spec/paths/getOtelSettings.generated.yml @@ -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 diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index 53b54a58533..8ee48200de8 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -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 diff --git a/packages/cli/src/public-api/v1/handlers/otel/otel.mapper.ts b/packages/cli/src/public-api/v1/shared/otel.mapper.ts similarity index 100% rename from packages/cli/src/public-api/v1/handlers/otel/otel.mapper.ts rename to packages/cli/src/public-api/v1/shared/otel.mapper.ts diff --git a/packages/cli/test/integration/public-api/otel-settings.test.ts b/packages/cli/test/integration/public-api/otel-settings.test.ts index 4082c9f6d54..7fdf841b8ec 100644 --- a/packages/cli/test/integration/public-api/otel-settings.test.ts +++ b/packages/cli/test/integration/public-api/otel-settings.test.ts @@ -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'); }); });