diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 154b7dd1469..08d0bb5beba 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/dto/otel/__tests__/update-otel-settings.dto.test.ts b/packages/@n8n/api-types/src/dto/otel/__tests__/update-otel-settings.dto.test.ts new file mode 100644 index 00000000000..fe3f9c28c36 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/otel/__tests__/update-otel-settings.dto.test.ts @@ -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'], + }), + ); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/otel/test-otel-connection.dto.ts b/packages/@n8n/api-types/src/dto/otel/test-otel-trace.dto.ts similarity index 85% rename from packages/@n8n/api-types/src/dto/otel/test-otel-connection.dto.ts rename to packages/@n8n/api-types/src/dto/otel/test-otel-trace.dto.ts index f670ad00f66..1aade410d4d 100644 --- a/packages/@n8n/api-types/src/dto/otel/test-otel-connection.dto.ts +++ b/packages/@n8n/api-types/src/dto/otel/test-otel-trace.dto.ts @@ -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, diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 85496a61214..7950cb337af 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -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, diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index fded3f65747..9951e9fd80c 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -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', diff --git a/packages/cli/src/modules/otel/otel-settings.controller.ts b/packages/cli/src/modules/otel/otel-settings.controller.ts index e309f7ac105..9dd5acdc7f2 100644 --- a/packages/cli/src/modules/otel/otel-settings.controller.ts +++ b/packages/cli/src/modules/otel/otel-settings.controller.ts @@ -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); } diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 05e11a377d2..875aae1ecf4 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -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>; +} 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 new file mode 100644 index 00000000000..3b46652b44e --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts @@ -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; + updateOtelSettings: PublicAPIEndpoint; + testOtelTrace: PublicAPIEndpoint; +}; + +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; diff --git a/packages/cli/src/public-api/v1/handlers/otel/otel.mapper.ts b/packages/cli/src/public-api/v1/handlers/otel/otel.mapper.ts new file mode 100644 index 00000000000..70a3728f928 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/otel/otel.mapper.ts @@ -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, + }; +} diff --git a/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.test-trace.yml b/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.test-trace.yml new file mode 100644 index 00000000000..cacc92e48e9 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.test-trace.yml @@ -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' 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 new file mode 100644 index 00000000000..52ac86ffb26 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/otel/spec/paths/settings.otel.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' 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 new file mode 100644 index 00000000000..0ce1216b92c --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-settings.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 diff --git a/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace-result.yml b/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace-result.yml new file mode 100644 index 00000000000..6b914b53bee --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace-result.yml @@ -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' diff --git a/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace.yml b/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace.yml new file mode 100644 index 00000000000..d0dc06ef1f1 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/otel/spec/schemas/otel-test-trace.yml @@ -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 diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 79062d53146..a181b632452 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -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: diff --git a/packages/cli/test/integration/public-api/otel-settings.test.ts b/packages/cli/test/integration/public-api/otel-settings.test.ts new file mode 100644 index 00000000000..cf57a818e8e --- /dev/null +++ b/packages/cli/test/integration/public-api/otel-settings.test.ts @@ -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); + }); + }); +}); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 489413d2074..d2ae7f97e62 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -22,6 +22,7 @@ type EndpointGroup = | 'community-packages' | 'ldap' | 'saml' + | 'otel' | 'sourceControl' | 'eventBus' | 'license' diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index e508f41277b..327ef8cfa69 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -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;