refactor(API): Migrate POST /settings/otel/test-trace to the decorator pattern (no-changelog) (#39716)

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-30 15:49:59 +00:00 committed by GitHub
parent 1ebd6265db
commit cd1bfafe72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 286 additions and 150 deletions

View File

@ -598,6 +598,10 @@ export {
OtelSettingsQueryPublicDto,
UpdateOtelSettingsPublicDto,
} from './otel/otel-settings-public.dto';
export {
OtelTestTraceRequestPublicDto,
OtelTestTraceResultPublicDto,
} from './otel/otel-test-trace-public.dto';
export {
PromotionChangesDto,

View File

@ -96,6 +96,16 @@ describe('UpdateOtelSettingsDto', () => {
expect(result.data.exporterEndpoint).toBe(exporterEndpoint);
});
it('trims whitespace around the exporter endpoint', () => {
const result = UpdateOtelSettingsDto.safeParse({
...validSettings,
exporterEndpoint: ' http://localhost:4318 ',
});
assert(result.success);
expect(result.data.exporterEndpoint).toBe('http://localhost:4318');
});
it.each(['localhost:4318', 'grpc://host:4317', 'ftp://x'])(
'rejects %p as an exporter endpoint',
(exporterEndpoint) => {
@ -198,6 +208,16 @@ describe('TestOtelTraceDto', () => {
expect(result.data.exporterEndpoint).toBe('https://collector.example.com:4317');
});
it('trims whitespace around the exporter endpoint', () => {
const result = TestOtelTraceDto.safeParse({
...validConnection,
exporterEndpoint: ' http://localhost:4318 ',
});
assert(result.success);
expect(result.data.exporterEndpoint).toBe('http://localhost:4318');
});
it('rejects a non-http exporter endpoint scheme', () => {
const result = TestOtelTraceDto.safeParse({
...validConnection,

View File

@ -0,0 +1,50 @@
import '../../openapi-extend';
import { z } from 'zod';
import {
otelTestTraceRequestFieldDocs,
otelTestTraceRequestDocs,
otelTestTraceResultFieldDocs,
otelTestTraceResultDocs,
} from './otel-test-trace-public.openapi';
import { TestOtelTraceDto } from './test-otel-trace.dto';
import { Z } from '../../zod-class';
const connectionFields = TestOtelTraceDto.schema.shape;
export class OtelTestTraceRequestPublicDto extends Z.class(
{
exporterProtocol: connectionFields.exporterProtocol.openapi(
otelTestTraceRequestFieldDocs.exporterProtocol,
),
exporterEndpoint: connectionFields.exporterEndpoint.openapi(
otelTestTraceRequestFieldDocs.exporterEndpoint,
),
exporterTracingPath: connectionFields.exporterTracingPath.openapi(
otelTestTraceRequestFieldDocs.exporterTracingPath,
),
exporterServiceName: connectionFields.exporterServiceName.openapi(
otelTestTraceRequestFieldDocs.exporterServiceName,
),
exporterHeaders: connectionFields.exporterHeaders.openapi(
otelTestTraceRequestFieldDocs.exporterHeaders,
),
startupConnectivityTimeoutMs: connectionFields.startupConnectivityTimeoutMs.openapi(
otelTestTraceRequestFieldDocs.startupConnectivityTimeoutMs,
),
},
{ strict: true },
) {
static schema = super.schema.openapi(otelTestTraceRequestDocs);
}
export class OtelTestTraceResultPublicDto extends Z.class(
{
success: z.boolean().openapi(otelTestTraceResultFieldDocs.success),
error: z.string().optional().openapi(otelTestTraceResultFieldDocs.error),
},
{ strict: true },
) {
static schema = super.schema.openapi(otelTestTraceResultDocs);
}

View File

@ -0,0 +1,43 @@
import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi';
import { otelSettingsPublicFieldDocs } from './otel-settings-public.openapi';
export const otelTestTraceRequestDocs = {
description:
'The connection details to test against an OTLP collector. Fields managed declaratively via environment variables are overridden with their effective value before the test is sent.',
} satisfies ZodOpenAPIMetadata;
export const otelTestTraceResultDocs = {
description: 'The outcome of the test connection to the OTLP collector.',
} satisfies ZodOpenAPIMetadata;
export const otelTestTraceRequestFieldDocs = {
exporterProtocol: {
description:
'The wire protocol used to send the test span. 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: omitting it selects `http/protobuf`, unless an environment variable manages this field, which then supplies the value.',
example: 'http/protobuf',
},
exporterEndpoint: otelSettingsPublicFieldDocs.exporterEndpoint,
exporterTracingPath: otelSettingsPublicFieldDocs.exporterTracingPath,
exporterServiceName: {
description: 'The `service.name` resource attribute reported on the test span.',
example: 'n8n',
},
exporterHeaders: otelSettingsPublicFieldDocs.exporterHeaders,
startupConnectivityTimeoutMs: {
description: 'How long, in milliseconds, to wait for the collector to respond.',
example: 2000,
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const otelTestTraceResultFieldDocs = {
success: {
description: 'Whether the test span was accepted by the collector.',
example: true,
},
error: {
description:
'The error reported by the collector or exporter. Present only when `success` is false.',
example: 'Failed to connect: 401 Unauthorized',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;

View File

@ -10,6 +10,7 @@ export const otlpProtocolSchema = z.enum(OTLP_PROTOCOLS);
export const exporterEndpointSchema = z
.string()
.trim()
.url()
.regex(/^https?:\/\//i, 'Endpoint must start with http:// or https://. The scheme selects TLS.');

View File

@ -141,7 +141,6 @@ export default defineConfig(
'./src/public-api/v1/handlers/ldap/ldap.handler.ts',
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',
'./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts',
'./src/public-api/v1/handlers/otel/otel.handler.ts',
'./src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts',
'./src/public-api/v1/handlers/workflows/workflows.handler.ts',
],

View File

@ -135,7 +135,6 @@ export default defineConfig({
'./src/public-api/v1/handlers/ldap/ldap.handler.ts',
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',
'./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts',
'./src/public-api/v1/handlers/otel/otel.handler.ts',
'./src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts',
'./src/public-api/v1/handlers/workflows/workflows.handler.ts',
],

View File

@ -1,7 +1,6 @@
import type {
UpdateDataTableRowDto,
PublicCreateDestination,
TestOtelTraceDto,
UpdateSamlConfigurationDto,
UpdateLdapConfigurationDto,
LdapSyncDto,
@ -166,14 +165,6 @@ export declare namespace SsoSamlRequest {
type Update = AuthenticatedRequest<{}, {}, UpdateSamlConfigurationDto>;
}
// ----------------------------------
// /settings/otel
// ----------------------------------
export declare namespace OtelSettingsRequest {
type Test = AuthenticatedRequest<{}, {}, TestOtelTraceDto>;
}
// ----------------------------------
// /settings/ldap
// ----------------------------------

View File

@ -1,6 +1,8 @@
import {
OtelSettingsPublicDto,
OtelSettingsQueryPublicDto,
OtelTestTraceRequestPublicDto,
OtelTestTraceResultPublicDto,
UpdateOtelSettingsPublicDto,
} from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
@ -14,6 +16,7 @@ import {
ApiTags,
Body,
Get,
Post,
PublicApiController,
Put,
Query,
@ -89,4 +92,23 @@ export class OtelPublicController {
return toOtelSettingsResponse(updated);
}
@Post('/test-trace')
@ApiKeyScope('otel:manage')
@ApiSummary('Test the connection to an OTLP collector')
@ApiDescription(
'Send a single test span to the given OTLP collector and report whether it was accepted. This tests the supplied connection details without changing the stored configuration. Fields managed declaratively via environment variables are overridden with their effective value before the test is sent. Requires the `otel:manage` scope.',
)
@ApiTags(tags)
@ApiResponse(200, OtelTestTraceResultPublicDto)
async testOtelTrace(
_req: AuthenticatedRequest,
_res: Response,
@Body body: OtelTestTraceRequestPublicDto,
@Query _query: OtelSettingsQueryPublicDto,
): Promise<OtelTestTraceResultPublicDto> {
const connection = this.settingsService.resolveTestConnection(body);
return await this.otelService.sendTestTrace(connection);
}
}

View File

@ -1,34 +0,0 @@
import { TestOtelTraceDto } from '@n8n/api-types';
import { Container } from '@n8n/di';
import { BadRequestError } from '@n8n/errors';
import { OtelSettingsService } from '@/modules/otel/otel-settings.service';
import { OtelService } from '@/modules/otel/otel.service';
import type { OtelSettingsRequest } from '../../../types';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import { apiKeyHasScopeWithGlobalScopeFallback } from '../../shared/middlewares/global.middleware';
type OtelHandlers = {
testOtelTrace: PublicAPIEndpoint<OtelSettingsRequest.Test>;
};
const otelHandlers: OtelHandlers = {
testOtelTrace: [
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'otel:manage' }),
async (req, res) => {
const payload = TestOtelTraceDto.safeParse(req.body);
if (!payload.success) {
throw new BadRequestError(payload.error.errors[0]?.message ?? 'Invalid request body');
}
const settingsService = Container.get(OtelSettingsService);
const connection = settingsService.resolveTestConnection(payload.data);
const result = await Container.get(OtelService).sendTestTrace(connection);
return res.json(result);
},
],
};
export = otelHandlers;

View File

@ -1,32 +0,0 @@
post:
x-eov-operation-id: testOtelTrace
x-required-scope: otel:manage
x-eov-operation-handler: v1/handlers/otel/otel.handler
tags:
- SettingsOtel
summary: Test the connection to an OTLP collector
description: >
Send a single test span to the given OTLP collector and report whether it was accepted. This
tests the supplied connection details without changing the stored configuration. Fields managed
declaratively via environment variables are overridden with their effective value before the
test is sent. Requires the `otel:manage` scope.
requestBody:
description: The connection details to test.
required: true
content:
application/json:
schema:
$ref: '../schemas/otel-test-trace.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/otel-test-trace-result.yml'
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'

View File

@ -1,15 +0,0 @@
type: object
additionalProperties: false
description: The outcome of the test connection to the OTLP collector.
required:
- success
properties:
success:
type: boolean
description: Whether the test span was accepted by the collector.
example: true
error:
type: string
description: >
The error reported by the collector or exporter. Present only when `success` is false.
example: 'Failed to connect: 401 Unauthorized'

View File

@ -1,56 +0,0 @@
type: object
additionalProperties: false
description: >
The connection details to test against an OTLP collector. Fields managed declaratively via
environment variables are overridden with their effective value before the test is sent.
required:
- exporterEndpoint
- exporterTracingPath
- exporterServiceName
- exporterHeaders
- startupConnectivityTimeoutMs
properties:
exporterProtocol:
type: string
enum:
- http/protobuf
- grpc
default: http/protobuf
description: >
The wire protocol used to send the test span. 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: omitting it selects `http/protobuf`, unless an
environment variable manages this field, which then supplies the value.
example: http/protobuf
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 the test 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
startupConnectivityTimeoutMs:
type: integer
minimum: 0
description: How long, in milliseconds, to wait for the collector to respond.
example: 2000

View File

@ -0,0 +1,84 @@
operationId: testOtelTrace
tags:
- SettingsOtel
summary: Test the connection to an OTLP collector
description: Send a single test span to the given OTLP collector and report whether it was accepted. This tests the supplied connection details without changing the stored configuration. Fields managed declaratively via environment variables are overridden with their effective value before the test is sent. 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
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
exporterProtocol:
type: string
enum:
- http/protobuf
- grpc
default: http/protobuf
description: 'The wire protocol used to send the test span. 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: omitting it selects `http/protobuf`, unless an environment variable manages this field, which then supplies the value.'
example: http/protobuf
exporterEndpoint:
type: string
format: uri
pattern: ^https?:\/\/
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 the test 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
startupConnectivityTimeoutMs:
type: integer
minimum: 0
description: How long, in milliseconds, to wait for the collector to respond.
example: 2000
required:
- exporterEndpoint
- exporterTracingPath
- exporterServiceName
- exporterHeaders
- startupConnectivityTimeoutMs
additionalProperties: false
description: The connection details to test against an OTLP collector. Fields managed declaratively via environment variables are overridden with their effective value before the test is sent.
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
description: Whether the test span was accepted by the collector.
example: true
error:
type: string
description: The error reported by the collector or exporter. Present only when `success` is false.
example: 'Failed to connect: 401 Unauthorized'
required:
- success
additionalProperties: false
description: The outcome of the test connection to the OTLP collector.
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml

View File

@ -176,6 +176,9 @@ paths:
$ref: ./handlers/settings/spec/paths/getOtelSettings.generated.yml
put:
$ref: ./handlers/settings/spec/paths/updateOtelSettings.generated.yml
/settings/otel/test-trace:
post:
$ref: ./handlers/settings/spec/paths/testOtelTrace.generated.yml
/projects:
get:
$ref: ./handlers/projects/spec/paths/getProjects.generated.yml

View File

@ -83,8 +83,6 @@ paths:
$ref: './handlers/ldap/spec/paths/settings.ldap.yml'
/settings/ldap/sync:
$ref: './handlers/ldap/spec/paths/settings.ldap.sync.yml'
/settings/otel/test-trace:
$ref: './handlers/otel/spec/paths/settings.otel.test-trace.yml'
/settings/sso/saml:
$ref: './handlers/sso-saml/spec/paths/settings.sso.saml.yml'
/workflows/{id}/{versionId}:

View File

@ -248,6 +248,19 @@ describe('OpenTelemetry settings in Public API', () => {
expect(replaced.body.exporterProtocol).toBe('http/protobuf');
});
it('stores the exporter endpoint without surrounding whitespace', async () => {
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send({ ...validSettings, exporterEndpoint: ` ${validSettings.exporterEndpoint} ` });
expect(response.status).toBe(200);
expect(response.body.exporterEndpoint).toBe(validSettings.exporterEndpoint);
const read = await testServer.publicApiAgentFor(owner).get('/settings/otel');
expect(read.body.exporterEndpoint).toBe(validSettings.exporterEndpoint);
});
it('rejects an unsupported exporter protocol with 400', async () => {
const response = await testServer
.publicApiAgentFor(owner)
@ -529,6 +542,22 @@ describe('OpenTelemetry settings in Public API', () => {
expect(response.body).toEqual({ success: false, error: '401 Unauthorized' });
});
it('sends the exporter endpoint to the collector without surrounding whitespace', async () => {
const sendTestTrace = vi
.spyOn(Container.get(OtelService), 'sendTestTrace')
.mockResolvedValue({ success: true });
const response = await testServer
.publicApiAgentFor(owner)
.post('/settings/otel/test-trace')
.send({ ...testConnection, exporterEndpoint: ` ${testConnection.exporterEndpoint} ` });
expect(response.status).toBe(200);
expect(sendTestTrace).toHaveBeenCalledWith(
expect.objectContaining({ exporterEndpoint: testConnection.exporterEndpoint }),
);
});
it('rejects a partial body with 400', async () => {
const response = await testServer
.publicApiAgentFor(owner)
@ -538,6 +567,20 @@ describe('OpenTelemetry settings in Public API', () => {
expect(response.status).toBe(400);
});
it('rejects an unknown field with 400', async () => {
const sendTestTrace = vi
.spyOn(Container.get(OtelService), 'sendTestTrace')
.mockResolvedValue({ success: true });
const response = await testServer
.publicApiAgentFor(owner)
.post('/settings/otel/test-trace')
.send({ ...testConnection, unknown: true });
expect(response.status).toBe(400);
expect(sendTestTrace).not.toHaveBeenCalled();
});
it('accepts a connection body written before exporterProtocol existed', async () => {
const sendTestTrace = vi
.spyOn(Container.get(OtelService), 'sendTestTrace')
@ -563,5 +606,21 @@ describe('OpenTelemetry settings in Public API', () => {
expect(response.status).toBe(401);
});
it('rejects with 403 when the API key lacks the otel:manage scope', async () => {
const scopedOwner = await createOwnerWithApiKey({ scopes: ['workflow:read'] });
const sendTestTrace = vi
.spyOn(Container.get(OtelService), 'sendTestTrace')
.mockResolvedValue({ success: true });
const response = await testServer
.publicApiAgentFor(scopedOwner)
.post('/settings/otel/test-trace')
.send(testConnection);
expect(response.status).toBe(403);
expect(response.body).toEqual({ message: 'Forbidden' });
expect(sendTestTrace).not.toHaveBeenCalled();
});
});
});