mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 12:51:16 +08:00
feat(API): Add public API endpoints for OpenTelemetry configuration (#34429)
This commit is contained in:
@@ -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
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user