feat(API): Add public API endpoints for OpenTelemetry configuration (#34429)

This commit is contained in:
Dmitrii
2026-07-22 09:28:47 +00:00
committed by GitHub
parent ca0c5f4938
commit cd1e48e5f5
18 changed files with 785 additions and 4 deletions
+1 -1
View File
@@ -296,6 +296,6 @@ export { CreateWorkflowReviewRequestDto } from './workflow-reviews/create-workfl
export { ListWorkflowReviewRequestsQueryDto } from './workflow-reviews/list-workflow-review-requests-query.dto';
export { UpdateOtelSettingsDto } from './otel/update-otel-settings.dto';
export { TestOtelConnectionDto } from './otel/test-otel-connection.dto';
export { TestOtelTraceDto } from './otel/test-otel-trace.dto';
export { InstanceAiExamplesQueryDto } from './instance-ai-examples/instance-ai-examples-query.dto';
@@ -0,0 +1,103 @@
import { TestOtelTraceDto } from '../test-otel-trace.dto';
import { UpdateOtelSettingsDto } from '../update-otel-settings.dto';
const validSettings = {
enabled: true,
exporterEndpoint: 'http://localhost:4318',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n',
exporterHeaders: '',
tracesSampleRate: 1,
startupConnectivityTimeoutMs: 2_000,
includeNodeSpans: true,
injectOutbound: true,
productionExecutionsOnly: true,
};
describe('UpdateOtelSettingsDto', () => {
it('requires every field (stays strict, so a partial body is rejected)', () => {
const result = UpdateOtelSettingsDto.safeParse({});
assert(!result.success, 'Expected validation to fail for an empty body');
// An empty body must report every field as missing. A field that carries a
// default would parse successfully instead of erroring — this guards the
// public API PUT against silently resetting omitted fields.
const missing = [...new Set(result.error.issues.map((issue) => String(issue.path[0])))].sort();
expect(missing).toEqual(Object.keys(validSettings).sort());
});
it('accepts a full body', () => {
const result = UpdateOtelSettingsDto.safeParse(validSettings);
expect(result.success).toBe(true);
});
it('rejects an invalid exporter endpoint', () => {
const result = UpdateOtelSettingsDto.safeParse({
...validSettings,
exporterEndpoint: 'not-a-url',
});
assert(!result.success, 'Expected validation to fail for an invalid exporter endpoint');
expect(result.error.issues).toContainEqual(
expect.objectContaining({
code: 'invalid_string',
validation: 'url',
path: ['exporterEndpoint'],
}),
);
});
it('rejects a sample rate outside the 0..1 range', () => {
const result = UpdateOtelSettingsDto.safeParse({ ...validSettings, tracesSampleRate: 2 });
assert(!result.success, 'Expected validation to fail for an out-of-range sample rate');
expect(result.error.issues).toContainEqual(
expect.objectContaining({
code: 'too_big',
maximum: 1,
path: ['tracesSampleRate'],
}),
);
});
});
describe('TestOtelTraceDto', () => {
const validConnection = {
exporterEndpoint: 'http://localhost:4318',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n',
exporterHeaders: '',
startupConnectivityTimeoutMs: 2_000,
};
it('requires every connection field (stays strict)', () => {
const result = TestOtelTraceDto.safeParse({});
assert(!result.success, 'Expected validation to fail for an empty body');
const missing = [...new Set(result.error.issues.map((issue) => String(issue.path[0])))].sort();
expect(missing).toEqual(Object.keys(validConnection).sort());
});
it('accepts a full connection body', () => {
const result = TestOtelTraceDto.safeParse(validConnection);
expect(result.success).toBe(true);
});
it('rejects an invalid exporter endpoint', () => {
const result = TestOtelTraceDto.safeParse({
...validConnection,
exporterEndpoint: 'not-a-url',
});
assert(!result.success, 'Expected validation to fail for an invalid exporter endpoint');
expect(result.error.issues).toContainEqual(
expect.objectContaining({
code: 'invalid_string',
validation: 'url',
path: ['exporterEndpoint'],
}),
);
});
});
@@ -1,7 +1,7 @@
import { UpdateOtelSettingsDto } from './update-otel-settings.dto';
import { Z } from '../../zod-class';
export class TestOtelConnectionDto extends Z.class(
export class TestOtelTraceDto extends Z.class(
UpdateOtelSettingsDto.schema.pick({
exporterEndpoint: true,
exporterTracingPath: true,
@@ -92,6 +92,7 @@ export const API_KEY_RESOURCES = {
securityAudit: ['generate'] as const,
securitySettings: ['manage'] as const,
saml: ['manage'] as const,
otel: ['manage'] as const,
project: ['create', 'update', 'delete', 'list', 'export'] as const,
user: ['read', 'list', 'create', 'changeRole', 'delete'] as const,
execution: ['delete', 'read', 'retry', 'list', 'stop'] as const,
@@ -16,6 +16,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
'securityAudit:generate',
'securitySettings:manage',
'saml:manage',
'otel:manage',
'eventBusDestination:list',
'eventBusDestination:read',
'eventBusDestination:create',
@@ -1,4 +1,4 @@
import { TestOtelConnectionDto, UpdateOtelSettingsDto } from '@n8n/api-types';
import { TestOtelTraceDto, UpdateOtelSettingsDto } from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import { AuthenticatedRequest } from '@n8n/db';
import { Body, Get, GlobalScope, Post, Put, RestController } from '@n8n/decorators';
@@ -41,7 +41,7 @@ export class OtelSettingsController {
@Post('/test-trace')
@GlobalScope('otel:manage')
async testTrace(_req: AuthenticatedRequest, _res: Response, @Body dto: TestOtelConnectionDto) {
async testTrace(_req: AuthenticatedRequest, _res: Response, @Body dto: TestOtelTraceDto) {
const connection = this.otelSettingsService.resolveTestConnection(dto);
return await this.otelService.sendTestTrace(connection);
}
+12
View File
@@ -9,6 +9,8 @@ import type {
UpsertDataTableRowDto,
UpdateSecurityPolicyDto,
PublicCreateDestination,
UpdateOtelSettingsDto,
TestOtelTraceDto,
UpdateSamlConfigurationDto,
} from '@n8n/api-types';
import type { AuthenticatedRequest, TagEntity, WorkflowEntity } from '@n8n/db';
@@ -421,3 +423,13 @@ export declare namespace SsoSamlRequest {
type Get = AuthenticatedRequest;
type Update = AuthenticatedRequest<{}, {}, UpdateSamlConfigurationDto>;
}
// ----------------------------------
// /settings/otel
// ----------------------------------
export declare namespace OtelSettingsRequest {
type Get = AuthenticatedRequest;
type Update = AuthenticatedRequest<{}, {}, UpdateOtelSettingsDto>;
type Test = AuthenticatedRequest<{}, {}, TestOtelTraceDto>;
}
@@ -0,0 +1,85 @@
import { TestOtelTraceDto, UpdateOtelSettingsDto } from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import { Container } from '@n8n/di';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { OtelLifecycleHandler } from '@/modules/otel/otel-lifecycle-handler';
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 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 settings = await Container.get(OtelSettingsService).loadSettings();
return res.json(toOtelSettingsResponse(settings));
},
],
updateOtelSettings: [
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'otel:manage' }),
async (req, res) => {
const payload = UpdateOtelSettingsDto.safeParse(req.body);
if (!payload.success) {
throw new BadRequestError(payload.error.errors[0]?.message ?? 'Invalid request body');
}
const settingsService = Container.get(OtelSettingsService);
// Fields managed via environment variables are read-only: the UI greys them
// out, so reject any attempt to change one here instead of silently ignoring
// it. Re-submitting a field's current (env-enforced) value is not a change,
// so a GET -> edit -> PUT round-trip still succeeds.
await settingsService.loadSettings();
const current = settingsService.getSettings();
const conflicts = current.envManagedFields.filter(
(key) => payload.data[key] !== current[key],
);
if (conflicts.length > 0) {
throw new ConflictError(
`The following field(s) are managed by environment variables and cannot be changed through the API: ${conflicts.join(', ')}`,
);
}
await settingsService.saveSettings(payload.data);
await Container.get(OtelLifecycleHandler).onReloadOtelConfig();
await Container.get(ModuleRegistry).refreshModuleSettings('otel');
void Container.get(Publisher).publishCommand({ command: 'reload-otel-config' });
return res.json(toOtelSettingsResponse(settingsService.getSettings()));
},
],
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;
@@ -0,0 +1,18 @@
import type { OtelConfig } from '@/modules/otel/otel.config';
export type OtelSettingsResponse = OtelConfig;
export function toOtelSettingsResponse(config: OtelConfig): OtelSettingsResponse {
return {
enabled: config.enabled,
exporterEndpoint: config.exporterEndpoint,
exporterTracingPath: config.exporterTracingPath,
exporterServiceName: config.exporterServiceName,
exporterHeaders: config.exporterHeaders,
tracesSampleRate: config.tracesSampleRate,
startupConnectivityTimeoutMs: config.startupConnectivityTimeoutMs,
includeNodeSpans: config.includeNodeSpans,
injectOutbound: config.injectOutbound,
productionExecutionsOnly: config.productionExecutionsOnly,
};
}
@@ -0,0 +1,32 @@
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'
@@ -0,0 +1,57 @@
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
x-eov-operation-handler: v1/handlers/otel/otel.handler
tags:
- SettingsOtel
summary: Set the OpenTelemetry configuration
description: >
Set the OpenTelemetry configuration. This is a full replacement: every field must be provided,
and a partial body is rejected. The update takes effect exactly as it would from the UI, using
the same validation, and is applied to the running instance immediately. Fields managed
declaratively via environment variables are read-only: attempting to change one is rejected
with 409, while re-submitting its current value (as returned by GET) is accepted. Requires the
`otel:manage` scope.
requestBody:
description: The OpenTelemetry configuration to set.
required: true
content:
application/json:
schema:
$ref: '../schemas/otel-settings.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/otel-settings.yml'
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'409':
$ref: '../../../../shared/spec/responses/conflict.yml'
@@ -0,0 +1,71 @@
type: object
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. Fields managed declaratively via environment
variables are returned with their effective value and ignored on write.
required:
- enabled
- exporterEndpoint
- exporterTracingPath
- exporterServiceName
- exporterHeaders
- tracesSampleRate
- startupConnectivityTimeoutMs
- includeNodeSpans
- injectOutbound
- productionExecutionsOnly
properties:
enabled:
type: boolean
description: Whether OpenTelemetry tracing is enabled.
example: true
exporterEndpoint:
type: string
format: uri
description: The base URL of the OTLP collector to export traces to.
example: http://localhost:4318
exporterTracingPath:
type: string
description: The path appended to the endpoint for the OTLP traces signal.
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
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:
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
@@ -0,0 +1,15 @@
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'
@@ -0,0 +1,39 @@
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:
exporterEndpoint:
type: string
format: uri
description: The base URL of the OTLP collector to export traces to.
example: http://localhost:4318
exporterTracingPath:
type: string
description: The path appended to the endpoint for the OTLP traces signal.
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
@@ -49,6 +49,8 @@ tags:
description: Operations about projects
- name: SecurityPolicy
description: Operations about the instance security policy settings
- name: SettingsOtel
description: Operations about OpenTelemetry settings
- name: SettingsSsoSaml
description: Operations about SAML SSO settings
- name: SourceControl
@@ -67,6 +69,10 @@ paths:
$ref: './handlers/audit/spec/paths/audit.yml'
/settings/security-policy:
$ref: './handlers/security-policy/spec/paths/security-policy.yml'
/settings/otel:
$ref: './handlers/otel/spec/paths/settings.otel.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'
/credentials:
@@ -0,0 +1,333 @@
import { testDb } from '@n8n/backend-test-utils';
import { SettingsRepository, type User } from '@n8n/db';
import { Container } from '@n8n/di';
import { vi } from 'vitest';
import { OtelSettingsService, OTEL_SETTINGS_KEY } from '@/modules/otel/otel-settings.service';
import { OtelConfig } from '@/modules/otel/otel.config';
import { OTEL_ENV_VARS } from '@/modules/otel/otel.constants';
import { OtelService } from '@/modules/otel/otel.service';
import { createOwnerWithApiKey } from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
const validSettings = {
enabled: false,
exporterEndpoint: 'http://collector.example.com:4318',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n-prod',
exporterHeaders: 'authorization=Bearer my-token',
tracesSampleRate: 0.5,
startupConnectivityTimeoutMs: 3_000,
includeNodeSpans: false,
injectOutbound: false,
productionExecutionsOnly: false,
};
const testConnection = {
exporterEndpoint: 'http://collector.example.com:4318',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n-prod',
exporterHeaders: 'authorization=Bearer my-token',
startupConnectivityTimeoutMs: 3_000,
};
describe('OpenTelemetry settings in Public API', () => {
let owner: User;
const testServer = setupTestServer({
endpointGroups: ['publicApi', 'otel'],
});
// Reset both the persisted and in-memory OTel settings to defaults between tests.
const resetOtelSettings = async () => {
await Container.get(SettingsRepository).delete({ key: OTEL_SETTINGS_KEY });
await Container.get(OtelSettingsService).loadSettings();
};
beforeAll(async () => {
await testDb.init();
});
beforeEach(async () => {
await testDb.truncate(['User']);
await resetOtelSettings();
owner = await createOwnerWithApiKey();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('GET /settings/otel', () => {
it('returns the current OTel settings', async () => {
const response = await testServer.publicApiAgentFor(owner).get('/settings/otel');
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
enabled: false,
exporterServiceName: 'n8n',
exporterTracingPath: '/v1/traces',
});
expect(typeof response.body.exporterEndpoint).toBe('string');
});
it('exposes exactly the fields the UI configures, and nothing more', async () => {
const response = await testServer.publicApiAgentFor(owner).get('/settings/otel');
expect(response.status).toBe(200);
expect(Object.keys(response.body).sort()).toEqual(
[
'enabled',
'exporterEndpoint',
'exporterTracingPath',
'exporterServiceName',
'exporterHeaders',
'tracesSampleRate',
'startupConnectivityTimeoutMs',
'includeNodeSpans',
'injectOutbound',
'productionExecutionsOnly',
].sort(),
);
// Internal-only bookkeeping must never leak through the public API.
expect(response.body).not.toHaveProperty('envManagedFields');
});
it('rejects with 401 without a valid API key', async () => {
const response = await testServer.publicApiAgentWithoutApiKey().get('/settings/otel');
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 response = await testServer.publicApiAgentFor(scopedOwner).get('/settings/otel');
expect(response.status).toBe(403);
});
});
describe('PUT /settings/otel', () => {
it('sets the configuration and returns the updated values', async () => {
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send(validSettings);
expect(response.status).toBe(200);
expect(response.body).toMatchObject(validSettings);
});
it('takes effect the same way as the UI (write via public API, read via internal API)', async () => {
await testServer.publicApiAgentFor(owner).put('/settings/otel').send(validSettings);
// internal REST responses are wrapped in `{ data }`; the UI client unwraps it.
const internal = await testServer.authAgentFor(owner).get('/otel/settings');
expect(internal.status).toBe(200);
expect(internal.body.data).toMatchObject(validSettings);
});
it('reads back a configuration written through the internal API (public API is a faithful stand-in)', async () => {
await testServer.authAgentFor(owner).put('/otel/settings').send(validSettings);
const publicRead = await testServer.publicApiAgentFor(owner).get('/settings/otel');
expect(publicRead.status).toBe(200);
expect(publicRead.body).toMatchObject(validSettings);
});
it('toggles enabled both ways', async () => {
const enabled = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send({ ...validSettings, enabled: true });
expect(enabled.status).toBe(200);
expect(enabled.body.enabled).toBe(true);
const disabled = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send({ ...validSettings, enabled: false });
expect(disabled.status).toBe(200);
expect(disabled.body.enabled).toBe(false);
});
it('accepts a GET response body as a PUT body (clean round-trip)', async () => {
await testServer.publicApiAgentFor(owner).put('/settings/otel').send(validSettings);
const getResponse = await testServer.publicApiAgentFor(owner).get('/settings/otel');
expect(getResponse.status).toBe(200);
const putResponse = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send({ ...getResponse.body, exporterServiceName: 'n8n-updated' });
expect(putResponse.status).toBe(200);
expect(putResponse.body.exporterServiceName).toBe('n8n-updated');
expect(putResponse.body.exporterHeaders).toBe(validSettings.exporterHeaders);
});
it('rejects a partial body with 400', async () => {
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send({ enabled: true });
expect(response.status).toBe(400);
});
it('rejects a body missing a single field with 400', async () => {
const { exporterServiceName: _omitted, ...partial } = validSettings;
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send(partial);
expect(response.status).toBe(400);
});
it('rejects a well-formed body with invalid values with 400', async () => {
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send({ ...validSettings, exporterEndpoint: 'not-a-url' });
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('message');
});
it('rejects with 401 without a valid API key', async () => {
const response = await testServer
.publicApiAgentWithoutApiKey()
.put('/settings/otel')
.send(validSettings);
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 response = await testServer
.publicApiAgentFor(scopedOwner)
.put('/settings/otel')
.send(validSettings);
expect(response.status).toBe(403);
});
});
describe('PUT /settings/otel with an env-managed field', () => {
const ENV_SERVICE_NAME = 'env-managed-service';
let originalServiceName: string;
beforeEach(async () => {
// Simulate `N8N_OTEL_EXPORTER_SERVICE_NAME` being set: mark it env-managed and
// pin its enforced value on the (singleton) config read at boot.
process.env[OTEL_ENV_VARS.exporterServiceName] = ENV_SERVICE_NAME;
originalServiceName = Container.get(OtelConfig).exporterServiceName;
Container.get(OtelConfig).exporterServiceName = ENV_SERVICE_NAME;
await Container.get(OtelSettingsService).loadSettings();
});
afterEach(async () => {
delete process.env[OTEL_ENV_VARS.exporterServiceName];
Container.get(OtelConfig).exporterServiceName = originalServiceName;
await Container.get(OtelSettingsService).loadSettings();
});
it('rejects changing the env-managed field with 409 naming the field', async () => {
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send({ ...validSettings, exporterServiceName: 'a-different-name' });
expect(response.status).toBe(409);
expect(response.body.message).toContain('exporterServiceName');
});
it('does not persist any change when the write is rejected with 409', async () => {
await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send({ ...validSettings, exporterServiceName: 'a-different-name', tracesSampleRate: 0.1 });
const read = await testServer.publicApiAgentFor(owner).get('/settings/otel');
// The non-env field from the rejected body must not have leaked through.
expect(read.body.tracesSampleRate).not.toBe(0.1);
});
it('accepts a write that re-submits the enforced value and changes a non-env field', async () => {
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send({ ...validSettings, exporterServiceName: ENV_SERVICE_NAME, tracesSampleRate: 0.25 });
expect(response.status).toBe(200);
expect(response.body.tracesSampleRate).toBe(0.25);
expect(response.body.exporterServiceName).toBe(ENV_SERVICE_NAME);
});
it('accepts a GET response body echoed straight back (clean round-trip)', async () => {
const getResponse = await testServer.publicApiAgentFor(owner).get('/settings/otel');
expect(getResponse.body.exporterServiceName).toBe(ENV_SERVICE_NAME);
const putResponse = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send(getResponse.body);
expect(putResponse.status).toBe(200);
});
});
describe('POST /settings/otel/test-trace', () => {
it('reports a successful connection', async () => {
vi.spyOn(Container.get(OtelService), 'sendTestTrace').mockResolvedValue({ success: true });
const response = await testServer
.publicApiAgentFor(owner)
.post('/settings/otel/test-trace')
.send(testConnection);
expect(response.status).toBe(200);
expect(response.body).toEqual({ success: true });
});
it('reports a failed connection with the collector error', async () => {
vi.spyOn(Container.get(OtelService), 'sendTestTrace').mockResolvedValue({
success: false,
error: '401 Unauthorized',
});
const response = await testServer
.publicApiAgentFor(owner)
.post('/settings/otel/test-trace')
.send(testConnection);
expect(response.status).toBe(200);
expect(response.body).toEqual({ success: false, error: '401 Unauthorized' });
});
it('rejects a partial body with 400', async () => {
const response = await testServer
.publicApiAgentFor(owner)
.post('/settings/otel/test-trace')
.send({ exporterEndpoint: 'http://collector.example.com:4318' });
expect(response.status).toBe(400);
});
it('rejects with 401 without a valid API key', async () => {
const response = await testServer
.publicApiAgentWithoutApiKey()
.post('/settings/otel/test-trace')
.send(testConnection);
expect(response.status).toBe(401);
});
});
});
@@ -22,6 +22,7 @@ type EndpointGroup =
| 'community-packages'
| 'ldap'
| 'saml'
| 'otel'
| 'sourceControl'
| 'eventBus'
| 'license'
@@ -255,6 +255,13 @@ export const setupTestServer = ({
break;
}
case 'otel': {
const { OtelService } = await import('@/modules/otel/otel.service.js');
await Container.get(OtelService).init();
await import('@/modules/otel/otel-settings.controller.js');
break;
}
case 'sourceControl':
await import('@/modules/source-control.ee/source-control.controller.ee.js');
break;