feat(core): Add OTLP gRPC protocol support for OpenTelemetry tracing (#37491)

Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
ocdejong
2026-09-04 14:19:55 +00:00
committed by GitHub
co-authored by Claude Fable 5
parent e4e78da0d6
commit f748613f9a
33 changed files with 1505 additions and 110 deletions
+7 -1
View File
@@ -431,7 +431,13 @@ export {
type ListWorkflowReviewActivityResponse,
} from './workflow-reviews/workflow-review-activity.dto';
export { UpdateOtelSettingsDto } from './otel/update-otel-settings.dto';
export {
UpdateOtelSettingsDto,
OTLP_PROTOCOLS,
otlpProtocolSchema,
exporterEndpointSchema,
type OtlpProtocol,
} from './otel/update-otel-settings.dto';
export { TestOtelTraceDto } from './otel/test-otel-trace.dto';
export { InstanceAiExamplesQueryDto } from './instance-ai-examples/instance-ai-examples-query.dto';
@@ -3,6 +3,7 @@ import { UpdateOtelSettingsDto } from '../update-otel-settings.dto';
const validSettings = {
enabled: true,
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'http://localhost:4318',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n',
@@ -14,17 +15,29 @@ const validSettings = {
productionExecutionsOnly: true,
};
const defaultedFields = ['exporterProtocol'];
describe('UpdateOtelSettingsDto', () => {
it('requires every field (stays strict, so a partial body is rejected)', () => {
it('requires every field except the defaulted ones (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());
expect(missing).toEqual(
Object.keys(validSettings)
.filter((key) => !defaultedFields.includes(key))
.sort(),
);
});
it('defaults the exporter protocol when omitted (body predates the field)', () => {
const { exporterProtocol: _omitted, ...withoutProtocol } = validSettings;
const result = UpdateOtelSettingsDto.safeParse(withoutProtocol);
assert(result.success, 'Expected a body without exporterProtocol to stay valid');
expect(result.data.exporterProtocol).toBe('http/protobuf');
});
it('accepts a full body', () => {
@@ -48,6 +61,70 @@ describe('UpdateOtelSettingsDto', () => {
);
});
it.each(['http/protobuf', 'grpc'])('accepts the %s exporter protocol', (exporterProtocol) => {
const result = UpdateOtelSettingsDto.safeParse({ ...validSettings, exporterProtocol });
assert(result.success, `Expected ${exporterProtocol} to be a valid exporter protocol`);
expect(result.data.exporterProtocol).toBe(exporterProtocol);
});
it.each(['http/json', 'HTTP/PROTOBUF', 'http', 'gRPC', ''])(
'rejects %p as an exporter protocol',
(exporterProtocol) => {
const result = UpdateOtelSettingsDto.safeParse({ ...validSettings, exporterProtocol });
assert(!result.success, `Expected ${exporterProtocol} to be an invalid exporter protocol`);
expect(result.error.issues).toContainEqual(
expect.objectContaining({
code: 'invalid_enum_value',
path: ['exporterProtocol'],
}),
);
},
);
it.each([
'http://localhost:4318',
'https://collector.example.com:4317',
'http://[::1]:4317',
'HTTP://localhost:4318',
'HttpS://collector.example.com:4317',
])('accepts %p as an exporter endpoint', (exporterEndpoint) => {
const result = UpdateOtelSettingsDto.safeParse({ ...validSettings, exporterEndpoint });
assert(result.success, `Expected ${exporterEndpoint} to be a valid exporter endpoint`);
expect(result.data.exporterEndpoint).toBe(exporterEndpoint);
});
it.each(['localhost:4318', 'grpc://host:4317', 'ftp://x'])(
'rejects %p as an exporter endpoint',
(exporterEndpoint) => {
const result = UpdateOtelSettingsDto.safeParse({ ...validSettings, exporterEndpoint });
assert(!result.success, `Expected ${exporterEndpoint} to be an invalid exporter endpoint`);
expect(result.error.issues).toContainEqual(
expect.objectContaining({ path: ['exporterEndpoint'] }),
);
},
);
it('explains why a non-http endpoint scheme is rejected', () => {
const result = UpdateOtelSettingsDto.safeParse({
...validSettings,
exporterEndpoint: 'grpc://host:4317',
});
assert(!result.success, 'Expected validation to fail for a grpc:// exporter endpoint');
expect(result.error.issues).toContainEqual(
expect.objectContaining({
code: 'invalid_string',
validation: 'regex',
path: ['exporterEndpoint'],
message: 'Endpoint must start with http:// or https://. The scheme selects TLS.',
}),
);
});
it('rejects a sample rate outside the 0..1 range', () => {
const result = UpdateOtelSettingsDto.safeParse({ ...validSettings, tracesSampleRate: 2 });
@@ -64,6 +141,7 @@ describe('UpdateOtelSettingsDto', () => {
describe('TestOtelTraceDto', () => {
const validConnection = {
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'http://localhost:4318',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n',
@@ -71,32 +149,68 @@ describe('TestOtelTraceDto', () => {
startupConnectivityTimeoutMs: 2_000,
};
it('requires every connection field (stays strict)', () => {
it('requires every connection field except the defaulted ones (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());
expect(missing).toEqual(
Object.keys(validConnection)
.filter((key) => !defaultedFields.includes(key))
.sort(),
);
});
it('accepts a full connection body', () => {
const result = TestOtelTraceDto.safeParse(validConnection);
expect(result.success).toBe(true);
});
it('rejects an invalid exporter endpoint', () => {
it('accepts a gRPC connection body', () => {
const result = TestOtelTraceDto.safeParse({
...validConnection,
exporterEndpoint: 'not-a-url',
exporterProtocol: 'grpc',
exporterEndpoint: 'http://localhost:4317',
});
assert(!result.success, 'Expected validation to fail for an invalid exporter endpoint');
assert(result.success, 'Expected a gRPC connection body to be valid');
expect(result.data.exporterProtocol).toBe('grpc');
});
it('rejects an unsupported exporter protocol', () => {
const result = TestOtelTraceDto.safeParse({
...validConnection,
exporterProtocol: 'http/json',
});
assert(!result.success, 'Expected validation to fail for an unsupported exporter protocol');
expect(result.error.issues).toContainEqual(
expect.objectContaining({
code: 'invalid_enum_value',
path: ['exporterProtocol'],
}),
);
});
it('accepts an https exporter endpoint', () => {
const result = TestOtelTraceDto.safeParse({
...validConnection,
exporterEndpoint: 'https://collector.example.com:4317',
});
assert(result.success, 'Expected an https exporter endpoint to be valid');
expect(result.data.exporterEndpoint).toBe('https://collector.example.com:4317');
});
it('rejects a non-http exporter endpoint scheme', () => {
const result = TestOtelTraceDto.safeParse({
...validConnection,
exporterEndpoint: 'grpc://host:4317',
});
assert(!result.success, 'Expected validation to fail for a grpc:// exporter endpoint');
expect(result.error.issues).toContainEqual(
expect.objectContaining({
code: 'invalid_string',
validation: 'url',
validation: 'regex',
path: ['exporterEndpoint'],
message: 'Endpoint must start with http:// or https://. The scheme selects TLS.',
}),
);
});
@@ -3,6 +3,7 @@ import { Z } from '../../zod-class';
export class TestOtelTraceDto extends Z.class(
UpdateOtelSettingsDto.schema.pick({
exporterProtocol: true,
exporterEndpoint: true,
exporterTracingPath: true,
exporterServiceName: true,
@@ -2,9 +2,22 @@ import { z } from 'zod';
import { Z } from '../../zod-class';
export const OTLP_PROTOCOLS = ['http/protobuf', 'grpc'] as const;
export type OtlpProtocol = (typeof OTLP_PROTOCOLS)[number];
export const otlpProtocolSchema = z.enum(OTLP_PROTOCOLS);
export const exporterEndpointSchema = z
.string()
.url()
.regex(/^https?:\/\//i, 'Endpoint must start with http:// or https://. The scheme selects TLS.');
export class UpdateOtelSettingsDto extends Z.class({
enabled: z.boolean(),
exporterEndpoint: z.string().url(),
// Defaulted so a body from before this field existed still parses.
exporterProtocol: otlpProtocolSchema.default('http/protobuf'),
exporterEndpoint: exporterEndpointSchema,
exporterTracingPath: z.string(),
exporterServiceName: z.string().min(1),
exporterHeaders: z.string(),
+2
View File
@@ -140,6 +140,7 @@
"@chat-adapter/telegram": "catalog:",
"@daytona/sdk": "catalog:",
"@google-cloud/secret-manager": "5.6.0",
"@grpc/grpc-js": "^1.14.3",
"@joplin/turndown-plugin-gfm": "catalog:",
"@langchain/core": "catalog:",
"@modelcontextprotocol/node": "catalog:",
@@ -181,6 +182,7 @@
"@n8n_io/license-sdk": "3.0.0",
"@opentelemetry/api": "catalog:",
"@opentelemetry/core": "2.10.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.221.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.221.0",
"@opentelemetry/instrumentation": "^0.221.0",
"@opentelemetry/resources": "^2.10.0",
+50
View File
@@ -135,3 +135,53 @@ Start n8n & point it at the jaeger instance
cd packages/cli
N8N_OTEL_ENABLED=true N8N_OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 pnpm run dev
```
### Wire protocol (OTLP/HTTP vs OTLP/gRPC)
`N8N_OTEL_EXPORTER_OTLP_PROTOCOL` selects how spans are delivered. It mirrors the
upstream `OTEL_EXPORTER_OTLP_PROTOCOL` spec and accepts:
| Value | Exporter | Conventional port |
| ------------------------- | ------------------------------------------ | ----------------- |
| `http/protobuf` (default) | `@opentelemetry/exporter-trace-otlp-proto` | 4318 |
| `grpc` | `@opentelemetry/exporter-trace-otlp-grpc` | 4317 |
```
cd packages/cli
N8N_OTEL_ENABLED=true \
N8N_OTEL_EXPORTER_OTLP_PROTOCOL=grpc \
N8N_OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317 \
pnpm run dev
```
Notes:
- Pick `grpc` only when the collector requires it (or for high span volume through
infrastructure that passes HTTP/2 through cleanly). `http/protobuf` traverses
proxies and firewalls more reliably and is easier to debug.
- The endpoint scheme controls TLS for **both** protocols: `https://` uses TLS,
`http://` does not. There is no `grpc://` scheme.
- Because the scheme is load-bearing, `N8N_OTEL_EXPORTER_OTLP_ENDPOINT` must be an
`http://` or `https://` URL. n8n logs a warning and uses the default endpoint if
the value has another scheme or no scheme, e.g. `localhost:4318`. The scheme is
matched case-insensitively, and n8n lowercases it before it reaches the exporter.
- gRPC endpoints take **no URL path**, so `N8N_OTEL_EXPORTER_OTLP_TRACING_PATH` is
ignored when the protocol is `grpc`.
- `N8N_OTEL_EXPORTER_OTLP_HEADERS` entries are sent as gRPC metadata. Keys are
lowercased (gRPC metadata keys are lowercase ASCII); an entry grpc-js rejects is
skipped with a warning instead of failing startup.
- The startup connectivity check waits for a grpc-js channel to become ready for
`grpc` (an HTTP `HEAD` request is meaningless against an HTTP/2-only server).
The channel dials the host and port of the endpoint, so readiness proves TCP,
the TLS handshake for `https://`, and an HTTP/2 connection. It is not proof that
OTLP/gRPC is served there — use "Send test trace" in Settings → OpenTelemetry
for the real check.
- An endpoint without a port dials the grpc-js default port 443, not 4317. Always
give the port, e.g. `http://127.0.0.1:4317`.
- The check uses the default TLS trust store. It does not use the certificate
material that the exporter reads from `OTEL_EXPORTER_OTLP_CERTIFICATE`,
`OTEL_EXPORTER_OTLP_CLIENT_KEY` and `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE`, so a
collector behind a private CA, or one that needs mTLS, can fail the check and
still receive spans.
- n8n has no setting for a custom CA or mTLS. Use the upstream
`OTEL_EXPORTER_OTLP_*` certificate variables above, or `NODE_EXTRA_CA_CERTS`.
@@ -43,6 +43,7 @@ function makeOtelSettingsService(
enabled: true,
productionExecutionsOnly: false,
includeNodeSpans: true,
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'http://localhost:4318',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n',
@@ -19,6 +19,7 @@ const res = mock<Response>();
const baseSettings: OtelConfig = {
enabled: true,
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'https://collector.example.com',
exporterTracingPath: '/v1/traces',
exporterHeaders: '',
@@ -131,6 +132,7 @@ describe('OtelSettingsController', () => {
describe('testTrace', () => {
const dto: OtelConnectionParams = {
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'https://collector.example.com',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n-prod',
@@ -2,6 +2,7 @@
import type { Settings, SettingsRepository } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import type { OtelConnectionParams } from '../otel-settings.service';
import { OTEL_SETTINGS_KEY, OtelSettingsService } from '../otel-settings.service';
import { OtelConfig } from '../otel.config';
@@ -36,6 +37,7 @@ describe('OtelSettingsService', () => {
expect(result).toEqual({
enabled: false,
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'http://localhost:4318',
exporterTracingPath: '/v1/traces',
exporterHeaders: '',
@@ -93,6 +95,45 @@ describe('OtelSettingsService', () => {
expect(result.envManagedFields).not.toContain('enabled');
});
it('returns the persisted exporter protocol', async () => {
settingsRepository.findByKey.mockResolvedValue({
value: JSON.stringify({ exporterProtocol: 'grpc' }),
} as Settings);
await service.loadSettings();
expect(service.getSettings().exporterProtocol).toBe('grpc');
});
it('falls back to the default protocol for a DB row written before the field existed', async () => {
settingsRepository.findByKey.mockResolvedValue({
value: JSON.stringify({ enabled: true, exporterEndpoint: 'https://from-db' }),
} as Settings);
await service.loadSettings();
const result = service.getSettings();
expect(result.exporterProtocol).toBe('http/protobuf');
expect(result.envManagedFields).not.toContain('exporterProtocol');
});
it('the protocol env var overrides the DB value and is tracked in envManagedFields', async () => {
settingsRepository.findByKey.mockResolvedValue({
value: JSON.stringify({ exporterProtocol: 'http/protobuf' }),
} as Settings);
process.env.N8N_OTEL_EXPORTER_OTLP_PROTOCOL = 'grpc';
const configWithEnv = new OtelConfig();
configWithEnv.exporterProtocol = 'grpc';
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository);
await serviceWithEnv.loadSettings();
const result = serviceWithEnv.getSettings();
expect(result.exporterProtocol).toBe('grpc');
expect(result.envManagedFields).toContain('exporterProtocol');
});
it('env vars win over DB even after UI save', async () => {
settingsRepository.findByKey.mockResolvedValue({
value: JSON.stringify({ enabled: false, exporterEndpoint: 'https://from-db' }),
@@ -134,6 +175,7 @@ describe('OtelSettingsService', () => {
describe('saveSettings', () => {
const settings: OtelConfig = {
enabled: true,
exporterProtocol: 'grpc',
exporterEndpoint: 'https://collector.example.com',
exporterTracingPath: '/v1/traces',
exporterHeaders: 'auth=token',
@@ -170,24 +212,32 @@ describe('OtelSettingsService', () => {
it('replaces env-managed fields with env-var values before persisting', async () => {
process.env.N8N_OTEL_EXPORTER_OTLP_ENDPOINT = 'https://from-env';
process.env.N8N_OTEL_EXPORTER_OTLP_PROTOCOL = 'grpc';
const configWithEnv = new OtelConfig();
configWithEnv.exporterEndpoint = 'https://from-env';
configWithEnv.exporterProtocol = 'grpc';
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository);
settingsRepository.findByKey.mockResolvedValue(null);
const incoming: OtelConfig = { ...settings, exporterEndpoint: 'https://tampered-by-client' };
const incoming: OtelConfig = {
...settings,
exporterEndpoint: 'https://tampered-by-client',
exporterProtocol: 'http/protobuf',
};
await serviceWithEnv.saveSettings(incoming);
const saved = JSON.parse(
(settingsRepository.save.mock.calls[0]?.[0] as { value: string }).value,
) as OtelConfig;
expect(saved.exporterEndpoint).toBe('https://from-env');
expect(saved.exporterProtocol).toBe('grpc');
expect(saved.enabled).toBe(settings.enabled);
});
});
describe('resolveTestConnection', () => {
const incoming = {
const incoming: OtelConnectionParams = {
exporterProtocol: 'grpc',
exporterEndpoint: 'https://collector.example.com',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n-prod',
@@ -213,5 +263,19 @@ describe('OtelSettingsService', () => {
expect(result.exporterEndpoint).toBe('https://from-env');
expect(result.exporterServiceName).toBe('n8n-prod');
});
it('overrides an env-managed exporter protocol with the canonical env-var value', () => {
process.env.N8N_OTEL_EXPORTER_OTLP_PROTOCOL = 'grpc';
const configWithEnv = new OtelConfig();
configWithEnv.exporterProtocol = 'grpc';
const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository);
const result = serviceWithEnv.resolveTestConnection({
...incoming,
exporterProtocol: 'http/protobuf',
});
expect(result.exporterProtocol).toBe('grpc');
});
});
});
@@ -0,0 +1,74 @@
import { Container } from '@n8n/di';
import { OtelConfig } from '../otel.config';
import { OTEL_ENV_VARS } from '../otel.constants';
const DEFAULT_ENDPOINT = 'http://localhost:4318';
describe('OtelConfig', () => {
beforeEach(() => {
Container.reset();
});
afterEach(() => {
delete process.env[OTEL_ENV_VARS.exporterEndpoint];
delete process.env[OTEL_ENV_VARS.exporterProtocol];
});
describe('exporterEndpoint', () => {
it('defaults to the conventional OTLP/HTTP endpoint', () => {
const { exporterEndpoint } = Container.get(OtelConfig);
expect(exporterEndpoint).toBe(DEFAULT_ENDPOINT);
});
it.each([
'http://localhost:4318',
'https://collector.example.com:4317',
'http://[::1]:4317',
'HTTP://localhost:4318',
'HttpS://collector.example.com:4317',
])('accepts %p', (value) => {
process.env[OTEL_ENV_VARS.exporterEndpoint] = value;
const { exporterEndpoint } = Container.get(OtelConfig);
expect(exporterEndpoint).toBe(value);
});
it.each(['localhost:4318', 'grpc://host:4317', 'ftp://x', 'not-a-url'])(
'falls back to the default for %p',
(value) => {
process.env[OTEL_ENV_VARS.exporterEndpoint] = value;
const { exporterEndpoint } = Container.get(OtelConfig);
expect(exporterEndpoint).toBe(DEFAULT_ENDPOINT);
},
);
});
describe('exporterProtocol', () => {
it('defaults to http/protobuf', () => {
const { exporterProtocol } = Container.get(OtelConfig);
expect(exporterProtocol).toBe('http/protobuf');
});
it.each(['http/protobuf', 'grpc'])('accepts %p', (value) => {
process.env[OTEL_ENV_VARS.exporterProtocol] = value;
const { exporterProtocol } = Container.get(OtelConfig);
expect(exporterProtocol).toBe(value);
});
it.each(['http/json', 'gRPC', 'http'])('falls back to the default for %p', (value) => {
process.env[OTEL_ENV_VARS.exporterProtocol] = value;
const { exporterProtocol } = Container.get(OtelConfig);
expect(exporterProtocol).toBe('http/protobuf');
});
});
});
@@ -0,0 +1,109 @@
import type { Metadata } from '@grpc/grpc-js';
import type { Logger } from '@n8n/backend-common';
import type { OutboundHttp } from '@n8n/backend-network';
import type { InstanceSettings } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import type { OtelConnectionParams, OtelSettingsService } from '../otel-settings.service';
import { OtelService } from '../otel.service';
// Only the gRPC exporter is mocked, so grpc-js and the OTLP/HTTP exporter stay real:
// their option and metadata rules are stricter than any stand-in, and warn-and-skip
// and creation-time failures must hold against the real classes.
const { exporterOptions } = vi.hoisted(() => ({
exporterOptions: [] as Array<{ url: string; metadata: Metadata }>,
}));
vi.mock('@opentelemetry/exporter-trace-otlp-grpc', () => ({
OTLPTraceExporter: vi.fn().mockImplementation(function (options: {
url: string;
metadata: Metadata;
}) {
exporterOptions.push(options);
return {
export: (_spans: unknown[], resultCallback: (result: { error?: Error }) => void) =>
resultCallback({}),
shutdown: async () => {},
};
}),
}));
const grpcConnection: OtelConnectionParams = {
exporterProtocol: 'grpc',
exporterEndpoint: 'https://collector.example.com:4317',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n-prod',
exporterHeaders: '',
startupConnectivityTimeoutMs: 3_000,
};
describe('OtelService with real gRPC dependencies', () => {
let logger: ReturnType<typeof mock<Logger>>;
let service: OtelService;
beforeEach(() => {
vi.clearAllMocks();
exporterOptions.length = 0;
logger = mock<Logger>();
service = new OtelService(
mock<OtelSettingsService>(),
mock<InstanceSettings>({ instanceId: 'inst-1', instanceType: 'main' }),
logger,
mock<OutboundHttp>(),
);
});
async function exportWithHeaders(exporterHeaders: string) {
const result = await service.sendTestTrace({ ...grpcConnection, exporterHeaders });
return { result, metadata: exporterOptions[0].metadata };
}
describe('gRPC metadata', () => {
it('lowercases header keys grpc-js accepts', async () => {
const { metadata } = await exportWithHeaders('Authorization=Bearer abc,X-Tenant=acme');
expect(metadata.getMap()).toEqual({ authorization: 'Bearer abc', 'x-tenant': 'acme' });
});
it('warns and skips a header key with illegal characters', async () => {
const { result, metadata } = await exportWithHeaders('bad key=value,x-tenant=acme');
expect(metadata.getMap()).toEqual({ 'x-tenant': 'acme' });
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('bad key'));
expect(result).toEqual({ success: true });
});
it('warns and skips a "-bin" header key, which grpc-js allows only for buffers', async () => {
const { result, metadata } = await exportWithHeaders('x-token-bin=abc,x-tenant=acme');
expect(metadata.getMap()).toEqual({ 'x-tenant': 'acme' });
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('x-token-bin'));
expect(result).toEqual({ success: true });
});
});
it('reports a zero timeout as a failed test trace', async () => {
const result = await service.sendTestTrace({
...grpcConnection,
exporterProtocol: 'http/protobuf',
startupConnectivityTimeoutMs: 0,
});
expect(result).toEqual({
success: false,
error: expect.stringContaining('timeoutMillis is invalid'),
});
});
it('resolves the lazily imported gRPC exporter and grpc-js', async () => {
const exporterModule = await vi.importActual<
typeof import('@opentelemetry/exporter-trace-otlp-grpc')
>('@opentelemetry/exporter-trace-otlp-grpc');
const grpcModule = await vi.importActual<typeof import('@grpc/grpc-js')>('@grpc/grpc-js');
expect(exporterModule.OTLPTraceExporter).toBeInstanceOf(Function);
expect(grpcModule.Metadata).toBeInstanceOf(Function);
expect(grpcModule.Client).toBeInstanceOf(Function);
});
});
@@ -1,6 +1,7 @@
import type { Logger } from '@n8n/backend-common';
import type { OutboundHttp } from '@n8n/backend-network';
import { context, diag, metrics, propagation, trace } from '@opentelemetry/api';
import { OTLPTraceExporter as OTLPGrpcTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
import { mock } from 'vitest-mock-extended';
@@ -21,9 +22,65 @@ const outboundHttp = {
transport: () => ({ asCustomFetch: () => fetchMock }),
} as unknown as OutboundHttp;
const {
metadataEntries,
MetadataMock,
ClientMock,
newClient,
waitForReady,
clientClose,
credentialsMock,
SSL_CREDS,
INSECURE_CREDS,
} = vi.hoisted(() => {
const entries = new Map<string, string>();
class MetadataMock {
set(key: string, value: string) {
entries.set(key, value);
}
}
const sslCreds = { kind: 'ssl' };
const insecureCreds = { kind: 'insecure' };
const newClient = vi.fn();
const waitForReady = vi.fn();
const clientClose = vi.fn();
class ClientMock {
constructor(target: string, credentials: unknown, options: unknown) {
newClient(target, credentials, options);
}
waitForReady(deadline: number, callback: (error?: Error) => void) {
waitForReady(deadline, callback);
}
close() {
clientClose();
}
}
return {
metadataEntries: entries,
MetadataMock,
ClientMock,
newClient,
waitForReady,
clientClose,
credentialsMock: {
createSsl: vi.fn(() => sslCreds),
createInsecure: vi.fn(() => insecureCreds),
},
SSL_CREDS: sslCreds,
INSECURE_CREDS: insecureCreds,
};
});
// Per-test control of what the throwaway exporter reports back, plus span/shutdown spies.
let mockExportImpl: (spans: unknown[], resultCallback: (result: { error?: Error }) => void) => void;
const mockExporterShutdown = vi.fn().mockResolvedValue(undefined);
const mockGrpcExporterShutdown = vi.fn().mockResolvedValue(undefined);
const mockProviderShutdown = vi.fn().mockResolvedValue(undefined);
const mockSpanEnd = vi.fn();
const mockStartSpan = vi.fn();
@@ -48,6 +105,22 @@ vi.mock('@opentelemetry/exporter-trace-otlp-proto', () => ({
}),
}));
vi.mock('@opentelemetry/exporter-trace-otlp-grpc', () => ({
OTLPTraceExporter: vi.fn().mockImplementation(function () {
return {
export: (spans: unknown[], resultCallback: (result: { error?: Error }) => void) =>
mockExportImpl(spans, resultCallback),
shutdown: mockGrpcExporterShutdown,
};
}),
}));
vi.mock('@grpc/grpc-js', () => ({
Metadata: MetadataMock,
Client: ClientMock,
credentials: credentialsMock,
}));
vi.mock('@opentelemetry/sdk-trace-base', () => ({
BasicTracerProvider: vi.fn().mockImplementation(function (config: {
spanProcessors?: unknown[];
@@ -84,6 +157,7 @@ vi.mock('@opentelemetry/api', async () => ({
const enabledSettings: OtelConfig = {
enabled: true,
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'http://localhost:4318',
exporterTracingPath: '/v1/traces',
exporterHeaders: '',
@@ -97,6 +171,12 @@ const enabledSettings: OtelConfig = {
const disabledSettings: OtelConfig = { ...enabledSettings, enabled: false };
const grpcSettings: OtelConfig = {
...enabledSettings,
exporterProtocol: 'grpc',
exporterEndpoint: 'http://collector.example.com:4317',
};
async function flushPromises() {
return await new Promise<void>((resolve) => setImmediate(resolve));
}
@@ -109,10 +189,14 @@ describe('OtelService', () => {
beforeEach(() => {
vi.clearAllMocks();
metadataEntries.clear();
otelSettingsService = mock<OtelSettingsService>();
instanceSettings = mock<InstanceSettings>({ instanceId: 'inst-1', instanceType: 'main' });
logger = mock<Logger>();
fetchMock.mockResolvedValue({ ok: true });
waitForReady.mockImplementation((_deadline: number, callback: (error?: Error) => void) =>
callback(),
);
service = new OtelService(otelSettingsService, instanceSettings, logger, outboundHttp);
});
@@ -133,6 +217,23 @@ describe('OtelService', () => {
expect(start).toHaveBeenCalledTimes(1);
});
it('builds the HTTP exporter with the traces path and no gRPC exporter', async () => {
otelSettingsService.loadSettings.mockResolvedValue({
...enabledSettings,
exporterHeaders: 'auth=token',
});
await service.init();
expect(OTLPTraceExporter).toHaveBeenCalledWith(
expect.objectContaining({
url: 'http://localhost:4318/v1/traces',
headers: { auth: 'token' },
}),
);
expect(OTLPGrpcTraceExporter).not.toHaveBeenCalled();
});
it('logs connectivity failure and still finishes startup', async () => {
otelSettingsService.loadSettings.mockResolvedValue(enabledSettings);
fetchMock.mockRejectedValue(new Error('connect ECONNREFUSED'));
@@ -148,6 +249,192 @@ describe('OtelService', () => {
});
});
describe('gRPC protocol', () => {
it('builds the gRPC exporter with the endpoint as-is and never the HTTP one', async () => {
otelSettingsService.loadSettings.mockResolvedValue(grpcSettings);
await service.init();
expect(OTLPGrpcTraceExporter).toHaveBeenCalledWith(
expect.objectContaining({ url: 'http://collector.example.com:4317' }),
);
expect(OTLPTraceExporter).not.toHaveBeenCalled();
expect(start).toHaveBeenCalledTimes(1);
});
it('lowercases only the endpoint scheme before it reaches the exporter', async () => {
otelSettingsService.loadSettings.mockResolvedValue({
...grpcSettings,
exporterEndpoint: 'HTTP://Collector.Example.com:4317',
});
await service.init();
expect(OTLPGrpcTraceExporter).toHaveBeenCalledWith(
expect.objectContaining({ url: 'http://Collector.Example.com:4317' }),
);
});
it('converts exporter headers into lowercased gRPC metadata', async () => {
otelSettingsService.loadSettings.mockResolvedValue({
...grpcSettings,
exporterHeaders: 'Authorization=Bearer abc,x-tenant=acme',
});
await service.init();
expect(OTLPGrpcTraceExporter).toHaveBeenCalledWith(
expect.objectContaining({ metadata: expect.any(MetadataMock) }),
);
expect(Object.fromEntries(metadataEntries)).toEqual({
authorization: 'Bearer abc',
'x-tenant': 'acme',
});
});
it('probes channel readiness instead of sending an HTTP request', async () => {
otelSettingsService.loadSettings.mockResolvedValue(grpcSettings);
const startedAt = Date.now();
await service.init();
await flushPromises();
expect(newClient).toHaveBeenCalledWith('collector.example.com:4317', INSECURE_CREDS, {});
const [deadline] = waitForReady.mock.calls[0] as [number];
expect(deadline).toBeGreaterThanOrEqual(startedAt + 2_000);
expect(deadline).toBeLessThanOrEqual(Date.now() + 2_000);
expect(fetchMock).not.toHaveBeenCalled();
expect(clientClose).toHaveBeenCalledTimes(1);
expect(logger.error).not.toHaveBeenCalled();
});
it('uses SSL credentials for an https endpoint', async () => {
otelSettingsService.loadSettings.mockResolvedValue({
...grpcSettings,
exporterEndpoint: 'https://collector.example.com:4317',
});
await service.init();
await flushPromises();
expect(newClient).toHaveBeenCalledWith('collector.example.com:4317', SSL_CREDS, {});
});
it('leaves the target port-less so grpc-js applies its own default port', async () => {
otelSettingsService.loadSettings.mockResolvedValue({
...grpcSettings,
exporterEndpoint: 'https://collector.example.com',
});
await service.init();
await flushPromises();
expect(newClient).toHaveBeenCalledWith('collector.example.com', SSL_CREDS, {});
});
it('keeps the brackets of an IPv6 host in the channel target', async () => {
otelSettingsService.loadSettings.mockResolvedValue({
...grpcSettings,
exporterEndpoint: 'http://[::1]:4317',
});
await service.init();
await flushPromises();
expect(newClient).toHaveBeenCalledWith('[::1]:4317', INSECURE_CREDS, {});
});
it('logs a single connectivity failure when the channel does not become ready', async () => {
otelSettingsService.loadSettings.mockResolvedValue(grpcSettings);
waitForReady.mockImplementation((_deadline: number, callback: (error?: Error) => void) => {
callback(new Error('Failed to connect before the deadline'));
callback(new Error('Failed to connect before the deadline'));
});
await service.init();
await flushPromises();
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
'Failed to connect to OpenTelemetry OTLP endpoint during startup',
{
endpoint: 'http://collector.example.com:4317',
error: expect.stringContaining(
'gRPC channel to "collector.example.com:4317" was not ready after 2000ms',
),
},
);
expect(clientClose).toHaveBeenCalledTimes(1);
});
it('names the default port and the ignored TLS material in a port-less https failure', async () => {
otelSettingsService.loadSettings.mockResolvedValue({
...grpcSettings,
exporterEndpoint: 'https://collector.example.com',
});
waitForReady.mockImplementation((_deadline: number, callback: (error?: Error) => void) =>
callback(new Error('Failed to connect before the deadline')),
);
await service.init();
await flushPromises();
const [, logContext] = logger.error.mock.calls[0] as [string, { error: string }];
expect(logContext.error).toContain('grpc-js dials its default 443, not 4317');
expect(logContext.error).toContain('OTEL_EXPORTER_OTLP_CERTIFICATE');
expect(logContext.error).toContain('still receive spans');
});
it('leaves out the TLS-material note for a plaintext endpoint', async () => {
otelSettingsService.loadSettings.mockResolvedValue(grpcSettings);
waitForReady.mockImplementation((_deadline: number, callback: (error?: Error) => void) =>
callback(new Error('Failed to connect before the deadline')),
);
await service.init();
await flushPromises();
const [, logContext] = logger.error.mock.calls[0] as [string, { error: string }];
expect(logContext.error).not.toContain('OTEL_EXPORTER_OTLP_CERTIFICATE');
expect(logContext.error).not.toContain('default 443');
});
it('logs a connectivity failure when the readiness check throws', async () => {
otelSettingsService.loadSettings.mockResolvedValue(grpcSettings);
waitForReady.mockImplementation(() => {
throw new Error('The channel has been closed');
});
await service.init();
await flushPromises();
expect(start).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
'Failed to connect to OpenTelemetry OTLP endpoint during startup',
expect.objectContaining({ error: 'The channel has been closed' }),
);
expect(clientClose).toHaveBeenCalledTimes(1);
});
it('logs a connectivity failure instead of throwing when the endpoint is unparseable', async () => {
otelSettingsService.loadSettings.mockResolvedValue({
...grpcSettings,
exporterEndpoint: 'not a valid endpoint',
});
await service.init();
await flushPromises();
expect(newClient).not.toHaveBeenCalled();
expect(start).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
'Failed to connect to OpenTelemetry OTLP endpoint during startup',
expect.objectContaining({ endpoint: 'not a valid endpoint' }),
);
});
});
describe('restart', () => {
it('shuts down existing SDK then reloads settings and starts a new one', async () => {
otelSettingsService.loadSettings.mockResolvedValue(enabledSettings);
@@ -176,6 +463,62 @@ describe('OtelService', () => {
});
});
describe('SDK startup failure', () => {
const exporterFailure = () => {
vi.mocked(OTLPTraceExporter).mockImplementationOnce(function () {
throw new Error('exporter unavailable');
});
};
it('logs once and leaves tracing off instead of failing module init', async () => {
otelSettingsService.loadSettings.mockResolvedValue(enabledSettings);
exporterFailure();
await expect(service.init()).resolves.not.toThrow();
expect(start).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
'Failed to start OpenTelemetry tracing, so tracing stays off',
{ error: 'exporter unavailable' },
);
});
it('tears down partially installed providers when the SDK start call throws', async () => {
otelSettingsService.loadSettings.mockResolvedValue(enabledSettings);
start.mockImplementationOnce(() => {
throw new Error('provider registration failed');
});
await expect(service.init()).resolves.not.toThrow();
expect(logger.error).toHaveBeenCalledWith(
'Failed to start OpenTelemetry tracing, so tracing stays off',
{ error: 'provider registration failed' },
);
expect(shutdown).toHaveBeenCalledTimes(1);
expect(trace.disable).toHaveBeenCalledTimes(1);
});
it('leaves the service in a non-exporting state when a restart fails', async () => {
otelSettingsService.loadSettings.mockResolvedValue(enabledSettings);
await service.init();
vi.clearAllMocks();
exporterFailure();
await expect(service.restart()).resolves.not.toThrow();
expect(start).not.toHaveBeenCalled();
expect(shutdown).toHaveBeenCalledTimes(1);
shutdown.mockClear();
await service.shutdown();
expect(shutdown).not.toHaveBeenCalled();
});
});
describe('shutdown', () => {
it('disables all four OTel globals so the next SDK start can re-register providers', async () => {
otelSettingsService.loadSettings.mockResolvedValue(enabledSettings);
@@ -263,6 +606,53 @@ describe('OtelService', () => {
expect(logger.error).toHaveBeenCalledTimes(1);
});
it('discards a probe of the previous start and still logs the current failure', async () => {
const rejectProbes: Array<(error: Error) => void> = [];
fetchMock.mockImplementation(
async () => await new Promise<never>((_, reject) => rejectProbes.push(reject)),
);
otelSettingsService.loadSettings.mockResolvedValue(enabledSettings);
await service.init();
otelSettingsService.loadSettings.mockResolvedValue({
...enabledSettings,
exporterEndpoint: 'http://new-collector:4318',
});
await service.restart();
rejectProbes[0](new Error('ECONNREFUSED on the old endpoint'));
await flushPromises();
expect(logger.error).not.toHaveBeenCalled();
rejectProbes[1](new Error('ECONNREFUSED on the new endpoint'));
await flushPromises();
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
'Failed to connect to OpenTelemetry OTLP endpoint during startup',
{
endpoint: 'http://new-collector:4318/v1/traces',
error: 'ECONNREFUSED on the new endpoint',
},
);
});
it('discards a probe that fails while the service is shutting down', async () => {
let rejectProbe!: (error: Error) => void;
fetchMock.mockImplementation(
async () => await new Promise<never>((_, reject) => (rejectProbe = reject)),
);
otelSettingsService.loadSettings.mockResolvedValue(enabledSettings);
await service.init();
await service.shutdown();
rejectProbe(new Error('ECONNREFUSED on the endpoint being left'));
await flushPromises();
expect(logger.error).not.toHaveBeenCalled();
});
it('logs string errors that are not Error instances', async () => {
otelSettingsService.loadSettings.mockResolvedValue(enabledSettings);
fetchMock.mockRejectedValue('string-error');
@@ -320,6 +710,7 @@ describe('OtelService', () => {
describe('sendTestTrace', () => {
const connection: OtelConnectionParams = {
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'https://collector.example.com',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n-prod',
@@ -346,6 +737,46 @@ describe('OtelService', () => {
expect(result).toEqual({ success: false, error: '401 Unauthorized' });
});
it('strips a trailing empty resolution note from the error message', async () => {
mockExportImpl = (_spans, resultCallback) =>
resultCallback({
error: new Error(
'14 UNAVAILABLE: No connection established. Last error: Protocol error. Resolution note: ',
),
});
const result = await service.sendTestTrace(connection);
expect(result).toEqual({
success: false,
error: '14 UNAVAILABLE: No connection established. Last error: Protocol error.',
});
});
it('keeps a resolution note that carries text', async () => {
mockExportImpl = (_spans, resultCallback) =>
resultCallback({
error: new Error('14 UNAVAILABLE: … Resolution note: DNS lookup used a proxy'),
});
const result = await service.sendTestTrace(connection);
expect(result).toEqual({
success: false,
error: '14 UNAVAILABLE: … Resolution note: DNS lookup used a proxy',
});
});
it('returns failure instead of throwing when the exporter cannot be built', async () => {
vi.mocked(OTLPTraceExporter).mockImplementationOnce(function () {
throw new Error('Configuration: timeoutMillis is invalid');
});
const result = await service.sendTestTrace(connection);
expect(result).toEqual({ success: false, error: 'Configuration: timeoutMillis is invalid' });
});
it('builds the exporter with the OTLP url, parsed headers and supplied timeout', async () => {
await service.sendTestTrace(connection);
@@ -377,5 +808,34 @@ describe('OtelService', () => {
expect(BasicTracerProvider).toHaveBeenCalledTimes(1);
expect(start).not.toHaveBeenCalled();
});
describe('over gRPC', () => {
const grpcConnection: OtelConnectionParams = {
...connection,
exporterProtocol: 'grpc',
exporterEndpoint: 'https://collector.example.com:4317',
};
it('builds the gRPC exporter with the endpoint as-is, metadata and supplied timeout', async () => {
const result = await service.sendTestTrace(grpcConnection);
expect(OTLPGrpcTraceExporter).toHaveBeenCalledWith({
url: 'https://collector.example.com:4317',
metadata: expect.any(MetadataMock),
timeoutMillis: 3_000,
});
expect(Object.fromEntries(metadataEntries)).toEqual({ auth: 'token' });
expect(OTLPTraceExporter).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
it('shuts down the throwaway provider and gRPC exporter when done', async () => {
await service.sendTestTrace(grpcConnection);
expect(mockProviderShutdown).toHaveBeenCalledTimes(1);
expect(mockGrpcExporterShutdown).toHaveBeenCalledTimes(1);
expect(mockExporterShutdown).not.toHaveBeenCalled();
});
});
});
});
@@ -13,6 +13,7 @@ export type OtelSettingsResponse = OtelConfig & {
export type OtelConnectionParams = Pick<
OtelConfig,
| 'exporterProtocol'
| 'exporterEndpoint'
| 'exporterTracingPath'
| 'exporterServiceName'
@@ -83,6 +84,7 @@ export class OtelSettingsService {
const pick = <K extends keyof OtelConnectionParams>(key: K): OtelConnectionParams[K] =>
this.isEnvManaged(key) ? this.config[key] : incoming[key];
return {
exporterProtocol: pick('exporterProtocol'),
exporterEndpoint: pick('exporterEndpoint'),
exporterTracingPath: pick('exporterTracingPath'),
exporterServiceName: pick('exporterServiceName'),
@@ -98,6 +100,7 @@ export class OtelSettingsService {
private buildConfig(pick: <K extends keyof OtelConfig>(key: K) => OtelConfig[K]): OtelConfig {
return {
enabled: pick('enabled'),
exporterProtocol: pick('exporterProtocol'),
exporterEndpoint: pick('exporterEndpoint'),
exporterTracingPath: pick('exporterTracingPath'),
exporterServiceName: pick('exporterServiceName'),
+6 -1
View File
@@ -1,5 +1,7 @@
import { exporterEndpointSchema, otlpProtocolSchema } from '@n8n/api-types';
import { Config, Env } from '@n8n/config';
import type { OtlpProtocol } from './otel.constants';
import { OTEL_ENV_VARS } from './otel.constants';
@Config
@@ -7,7 +9,10 @@ export class OtelConfig {
@Env(OTEL_ENV_VARS.enabled)
enabled: boolean = false;
@Env(OTEL_ENV_VARS.exporterEndpoint)
@Env(OTEL_ENV_VARS.exporterProtocol, otlpProtocolSchema)
exporterProtocol: OtlpProtocol = 'http/protobuf';
@Env(OTEL_ENV_VARS.exporterEndpoint, exporterEndpointSchema)
exporterEndpoint: string = 'http://localhost:4318';
@Env(OTEL_ENV_VARS.exporterTracingPath)
@@ -1,7 +1,11 @@
import { OTLP_PROTOCOLS, type OtlpProtocol } from '@n8n/api-types';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
export { OTLP_PROTOCOLS, type OtlpProtocol };
export const OTEL_ENV_VARS = {
enabled: 'N8N_OTEL_ENABLED',
exporterProtocol: 'N8N_OTEL_EXPORTER_OTLP_PROTOCOL',
exporterEndpoint: 'N8N_OTEL_EXPORTER_OTLP_ENDPOINT',
exporterTracingPath: 'N8N_OTEL_EXPORTER_OTLP_TRACING_PATH',
exporterHeaders: 'N8N_OTEL_EXPORTER_OTLP_HEADERS',
+198 -75
View File
@@ -1,3 +1,4 @@
import type { Metadata } from '@grpc/grpc-js';
import { Logger } from '@n8n/backend-common';
import { OutboundHttp } from '@n8n/backend-network';
import { Service } from '@n8n/di';
@@ -9,10 +10,12 @@ import { NodeSDK } from '@opentelemetry/sdk-node';
import {
BasicTracerProvider,
type ReadableSpan,
type SpanExporter,
type SpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import { TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-node';
import { InstanceSettings } from 'n8n-core';
import { OperationalError } from 'n8n-workflow';
import type { OtelConnectionParams } from './otel-settings.service';
import { OtelSettingsService } from './otel-settings.service';
@@ -23,11 +26,13 @@ import { N8N_VERSION } from '@/constants';
export type OtelTestTraceResult = { success: true } | { success: false; error: string };
const stripEmptyResolutionNote = (message: string) =>
message.replace(/\s*Resolution note:\s*$/, '');
@Service()
export class OtelService {
private static isDiagnosticsLoggerConfigured = false;
private sdk?: NodeSDK;
private hasLoggedStartupConnectivityFailure = false;
constructor(
private readonly otelSettingsService: OtelSettingsService,
@@ -38,90 +43,100 @@ export class OtelService {
async init(): Promise<void> {
const settings = await this.otelSettingsService.loadSettings();
this.start(settings);
await this.start(settings);
}
async restart(): Promise<void> {
await this.shutdown();
const settings = await this.otelSettingsService.loadSettings();
this.start(settings);
await this.start(settings);
}
/**
* Sends a single `n8n.test_trace` span to the given OTLP endpoint and waits
* for the exporter's result. Unlike the long-running SDK (which batches spans
* fire-and-forget), this uses a throwaway provider/exporter so the collector's
* response — success, an HTTP rejection, or a network error — can be reported
* back to the caller. Runs independently of the active OTel configuration.
* response — success, a rejection (HTTP status or gRPC status code), or a
* network error — can be reported back to the caller. Runs independently of
* the active OTel configuration.
*/
async sendTestTrace(connection: OtelConnectionParams): Promise<OtelTestTraceResult> {
const url = this.buildOtlpTracesUrl(
connection.exporterEndpoint,
connection.exporterTracingPath,
);
const exporter = new OTLPTraceExporter({
url,
headers: this.parseOtlpHeaders(connection.exporterHeaders),
timeoutMillis: connection.startupConnectivityTimeoutMs,
});
let provider: BasicTracerProvider | undefined;
try {
return await new Promise<OtelTestTraceResult>((resolve) => {
const processor: SpanProcessor = {
onStart: () => {},
onEnd: (span: ReadableSpan) =>
exporter.export([span], (result) =>
resolve(
result.error ? { success: false, error: result.error.message } : { success: true },
const exporter = await this.createTraceExporter(
connection,
connection.startupConnectivityTimeoutMs,
);
try {
return await new Promise<OtelTestTraceResult>((resolve) => {
const processor: SpanProcessor = {
onStart: () => {},
onEnd: (span: ReadableSpan) =>
exporter.export([span], (result) =>
resolve(
result.error
? { success: false, error: stripEmptyResolutionNote(result.error.message) }
: { success: true },
),
),
),
forceFlush: async () => {},
shutdown: async () => {},
};
provider = new BasicTracerProvider({
resource: resourceFromAttributes({
[ATTR.OTEL_SERVICE_NAME]: connection.exporterServiceName,
[ATTR.OTEL_SERVICE_VERSION]: N8N_VERSION,
[ATTR.INSTANCE_ID]: this.instanceSettings.instanceId,
[ATTR.INSTANCE_ROLE]: this.instanceSettings.instanceType,
}),
sampler: new TraceIdRatioBasedSampler(1),
spanProcessors: [processor],
forceFlush: async () => {},
shutdown: async () => {},
};
provider = new BasicTracerProvider({
resource: resourceFromAttributes({
[ATTR.OTEL_SERVICE_NAME]: connection.exporterServiceName,
[ATTR.OTEL_SERVICE_VERSION]: N8N_VERSION,
[ATTR.INSTANCE_ID]: this.instanceSettings.instanceId,
[ATTR.INSTANCE_ROLE]: this.instanceSettings.instanceType,
}),
sampler: new TraceIdRatioBasedSampler(1),
spanProcessors: [processor],
});
const span = provider
.getTracer('n8n-otel-test')
.startSpan(OTEL_TEST_SPAN_NAME, { attributes: { [ATTR.IS_TEST_TRACE]: true } });
span.end();
});
const span = provider
.getTracer('n8n-otel-test')
.startSpan(OTEL_TEST_SPAN_NAME, { attributes: { [ATTR.IS_TEST_TRACE]: true } });
span.end();
});
} finally {
await provider?.shutdown().catch(() => {});
await exporter.shutdown().catch(() => {});
} finally {
await provider?.shutdown().catch(() => {});
await exporter.shutdown().catch(() => {});
}
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
private start(settings: OtelConfig): void {
this.hasLoggedStartupConnectivityFailure = false;
private async start(settings: OtelConfig): Promise<void> {
if (!settings.enabled) return;
this.configureDiagnosticsLogger();
void this.checkEndpointReachability(
this.startSdk(settings),
settings.startupConnectivityTimeoutMs,
);
let sdk: NodeSDK;
try {
sdk = await this.startSdk(settings);
} catch (error) {
this.logger.error('Failed to start OpenTelemetry tracing, so tracing stays off', {
error: error instanceof Error ? error.message : String(error),
});
await this.shutdown();
return;
}
void this.checkEndpointReachability(settings, sdk);
}
async shutdown(): Promise<void> {
// Cleared before the flush, so a probe that fails meanwhile sees that its SDK is gone.
const sdk = this.sdk;
this.sdk = undefined;
try {
await this.sdk?.shutdown();
await sdk?.shutdown();
} catch (error) {
this.logger.warn(
'Failed to cleanly shut down OpenTelemetry SDK (exporter flush may have failed)',
{ error: error instanceof Error ? error.message : String(error) },
);
} finally {
this.sdk = undefined;
// Unregister the global providers so the next NodeSDK.start() can register
// new ones. Without this, OTel's allowOverride=false guard blocks
// re-registration and the restart silently fails.
@@ -132,12 +147,8 @@ export class OtelService {
}
}
private startSdk(settings: OtelConfig): string {
const otlpTracesUrl = this.buildOtlpTracesUrl(
settings.exporterEndpoint,
settings.exporterTracingPath,
);
const otlpHeaders = this.parseOtlpHeaders(settings.exporterHeaders);
private async startSdk(settings: OtelConfig): Promise<NodeSDK> {
const traceExporter = await this.createTraceExporter(settings);
this.sdk = new NodeSDK({
resource: resourceFromAttributes({
@@ -146,15 +157,82 @@ export class OtelService {
[ATTR.INSTANCE_ID]: this.instanceSettings.instanceId,
[ATTR.INSTANCE_ROLE]: this.instanceSettings.instanceType,
}),
traceExporter: new OTLPTraceExporter({
url: otlpTracesUrl,
headers: otlpHeaders,
}),
traceExporter,
sampler: new TraceIdRatioBasedSampler(settings.tracesSampleRate),
});
this.sdk.start();
return otlpTracesUrl;
return this.sdk;
}
private async createTraceExporter(
connection: OtelConnectionParams,
timeoutMillis?: number,
): Promise<SpanExporter> {
if (connection.exporterProtocol === 'grpc') {
return await this.createGrpcTraceExporter(connection, timeoutMillis);
}
return this.createHttpTraceExporter(connection, timeoutMillis);
}
private createHttpTraceExporter(
connection: OtelConnectionParams,
timeoutMillis?: number,
): SpanExporter {
const headers = this.parseOtlpHeaders(connection.exporterHeaders);
const url = this.resolveExporterUrl(connection);
return new OTLPTraceExporter({ url, headers, timeoutMillis });
}
private async createGrpcTraceExporter(
connection: OtelConnectionParams,
timeoutMillis?: number,
): Promise<SpanExporter> {
const headers = this.parseOtlpHeaders(connection.exporterHeaders);
const url = this.resolveExporterUrl(connection);
const [{ OTLPTraceExporter: OTLPGrpcTraceExporter }, { Metadata }] = await Promise.all([
import('@opentelemetry/exporter-trace-otlp-grpc'),
import('@grpc/grpc-js'),
]);
return new OTLPGrpcTraceExporter({
url,
metadata: this.toGrpcMetadata(headers, new Metadata()),
timeoutMillis,
});
}
private resolveExporterUrl(
connection: Pick<
OtelConnectionParams,
'exporterProtocol' | 'exporterEndpoint' | 'exporterTracingPath'
>,
): string {
// The gRPC exporter selects TLS with a raw-string `startsWith('http://')`, so
// an uppercase scheme silently picks TLS against a plaintext collector.
const endpoint = connection.exporterEndpoint.replace(/^https?:\/\//i, (scheme) =>
scheme.toLowerCase(),
);
return connection.exporterProtocol === 'grpc'
? endpoint
: this.buildOtlpTracesUrl(endpoint, connection.exporterTracingPath);
}
private toGrpcMetadata(headers: Record<string, string>, metadata: Metadata): Metadata {
for (const [key, value] of Object.entries(headers)) {
try {
metadata.set(key.toLowerCase(), value);
} catch (error) {
this.logger.warn(
`Skipping invalid OTEL exporter header "${key}": ${error instanceof Error ? error.message : String(error)}.`,
);
}
}
return metadata;
}
parseOtlpHeaders(headersToSplit: string): Record<string, string> {
@@ -204,20 +282,22 @@ export class OtelService {
return `${exporterEndpointWithoutTrailingSlash}${path}`;
}
private async checkEndpointReachability(url: string, timeoutMs: number): Promise<void> {
private async checkEndpointReachability(
settings: OtelConfig,
startedSdk: NodeSDK,
): Promise<void> {
const url = this.resolveExporterUrl(settings);
const timeoutMs = settings.startupConnectivityTimeoutMs;
try {
// HEAD is used for a cheap connectivity check (no request/response body).
// OTLP endpoints are POST-only, so this will often return 4xx, but any
// HTTP response means the server is reachable. We only catch network errors.
// SSRF is disabled: the OTLP endpoint is admin-configured observability
// infrastructure and is commonly an internal/localhost collector.
await this.outboundHttp.transport({ useDefaultSsrfPolicy: 'unsafe' }).asCustomFetch()(url, {
method: 'HEAD',
signal: AbortSignal.timeout(timeoutMs),
});
if (settings.exporterProtocol === 'grpc') {
await this.probeGrpcEndpoint(url, timeoutMs);
} else {
await this.probeHttpEndpoint(url, timeoutMs);
}
} catch (error) {
if (this.hasLoggedStartupConnectivityFailure) return;
this.hasLoggedStartupConnectivityFailure = true;
// A restart or shutdown replaced the SDK this probe belongs to.
if (this.sdk !== startedSdk) return;
this.logger.error('Failed to connect to OpenTelemetry OTLP endpoint during startup', {
endpoint: url,
@@ -225,4 +305,47 @@ export class OtelService {
});
}
}
private async probeHttpEndpoint(url: string, timeoutMs: number): Promise<void> {
// SSRF is disabled: the OTLP endpoint is admin-configured observability
// infrastructure and is commonly an internal/localhost collector.
await this.outboundHttp.transport({ useDefaultSsrfPolicy: 'unsafe' }).asCustomFetch()(url, {
method: 'HEAD',
signal: AbortSignal.timeout(timeoutMs),
});
}
private async probeGrpcEndpoint(endpoint: string, timeoutMs: number): Promise<void> {
const { host, port, protocol } = new URL(endpoint);
const { Client, credentials } = await import('@grpc/grpc-js');
const client = new Client(
host,
protocol === 'https:' ? credentials.createSsl() : credentials.createInsecure(),
{},
);
try {
await new Promise<void>((resolve, reject) => {
client.waitForReady(Date.now() + timeoutMs, (error) => {
if (!error) return resolve();
const portHint = port
? ''
: ' The endpoint has no port, so grpc-js dials its default 443, not 4317.';
const tlsHint =
protocol === 'https:'
? ' This check ignores the OTEL_EXPORTER_OTLP_CERTIFICATE, client key and client certificate variables, so a private-CA or mTLS collector can fail it and still receive spans.'
: '';
reject(
new OperationalError(
`gRPC channel to "${host}" was not ready after ${timeoutMs}ms (${error.message}): nothing listens there, or it does not speak gRPC, or its TLS mode does not match the "${protocol}" scheme.${portHint}${tlsHint}`,
),
);
});
});
} finally {
client.close();
}
}
}
@@ -5,6 +5,7 @@ export type OtelSettingsResponse = OtelConfig;
export function toOtelSettingsResponse(config: OtelConfig): OtelSettingsResponse {
return {
enabled: config.enabled,
exporterProtocol: config.exporterProtocol,
exporterEndpoint: config.exporterEndpoint,
exporterTracingPath: config.exporterTracingPath,
exporterServiceName: config.exporterServiceName,
@@ -28,7 +28,8 @@ put:
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
and a partial body is rejected. The one exception is `exporterProtocol`, which defaults to
`http/protobuf` when omitted. 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
@@ -2,8 +2,9 @@ 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.
full replacement: every field must be provided, except `exporterProtocol`, which defaults to
`http/protobuf` when omitted. Fields managed declaratively via environment variables are
returned with their effective value and ignored on write.
required:
- enabled
- exporterEndpoint
@@ -20,14 +21,32 @@ properties:
type: boolean
description: Whether OpenTelemetry tracing is enabled.
example: true
exporterProtocol:
type: string
enum:
- http/protobuf
- grpc
default: http/protobuf
description: >
The wire protocol used to export spans. Collectors conventionally serve OTLP/HTTP on port
4318 and OTLP/gRPC on port 4317. The endpoint scheme (`http://` or `https://`) controls
TLS for both protocols. Optional: a write that omits it selects `http/protobuf`, but an
instance that sets this field to another value with an environment variable rejects that
write with 409.
example: http/protobuf
exporterEndpoint:
type: string
format: uri
description: The base URL of the OTLP collector to export traces to.
description: >
The base URL of the OTLP collector to export traces to. The value must be an `http://` or
`https://` URL, because the scheme selects TLS — for gRPC, `https://` uses SSL and
`http://` is plaintext.
example: http://localhost:4318
exporterTracingPath:
type: string
description: The path appended to the endpoint for the OTLP traces signal.
description: >
The path appended to the endpoint for the OTLP traces signal. Ignored when
`exporterProtocol` is `grpc`, which takes no URL path.
example: /v1/traces
exporterServiceName:
type: string
@@ -10,14 +10,31 @@ required:
- exporterHeaders
- startupConnectivityTimeoutMs
properties:
exporterProtocol:
type: string
enum:
- http/protobuf
- grpc
default: http/protobuf
description: >
The wire protocol used to send the test span. Collectors conventionally serve OTLP/HTTP on
port 4318 and OTLP/gRPC on port 4317. The endpoint scheme (`http://` or `https://`)
controls TLS for both protocols. Optional: omitting it selects `http/protobuf`, unless an
environment variable manages this field, which then supplies the value.
example: http/protobuf
exporterEndpoint:
type: string
format: uri
description: The base URL of the OTLP collector to export traces to.
description: >
The base URL of the OTLP collector to export traces to. The value must be an `http://` or
`https://` URL, because the scheme selects TLS — for gRPC, `https://` uses SSL and
`http://` is plaintext.
example: http://localhost:4318
exporterTracingPath:
type: string
description: The path appended to the endpoint for the OTLP traces signal.
description: >
The path appended to the endpoint for the OTLP traces signal. Ignored when
`exporterProtocol` is `grpc`, which takes no URL path.
example: /v1/traces
exporterServiceName:
type: string
@@ -12,6 +12,7 @@ import { setupTestServer } from '@test-integration/utils';
const validSettings = {
enabled: false,
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'http://collector.example.com:4318',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n-prod',
@@ -24,6 +25,7 @@ const validSettings = {
};
const testConnection = {
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'http://collector.example.com:4318',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n-prod',
@@ -64,6 +66,7 @@ describe('OpenTelemetry settings in Public API', () => {
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
enabled: false,
exporterProtocol: 'http/protobuf',
exporterServiceName: 'n8n',
exporterTracingPath: '/v1/traces',
});
@@ -77,6 +80,7 @@ describe('OpenTelemetry settings in Public API', () => {
expect(Object.keys(response.body).sort()).toEqual(
[
'enabled',
'exporterProtocol',
'exporterEndpoint',
'exporterTracingPath',
'exporterServiceName',
@@ -169,6 +173,57 @@ describe('OpenTelemetry settings in Public API', () => {
expect(putResponse.body.exporterHeaders).toBe(validSettings.exporterHeaders);
});
it('switches the exporter protocol to gRPC and back', async () => {
const grpc = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send({
...validSettings,
exporterProtocol: 'grpc',
exporterEndpoint: 'http://collector.example.com:4317',
});
expect(grpc.status).toBe(200);
expect(grpc.body.exporterProtocol).toBe('grpc');
expect(grpc.body.exporterTracingPath).toBe(validSettings.exporterTracingPath);
const http = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send(validSettings);
expect(http.status).toBe(200);
expect(http.body.exporterProtocol).toBe('http/protobuf');
});
it('resets an omitted exporterProtocol to the default (PUT is a full replacement)', async () => {
const grpc = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send({
...validSettings,
exporterProtocol: 'grpc',
exporterEndpoint: 'http://collector.example.com:4317',
});
expect(grpc.body.exporterProtocol).toBe('grpc');
const { exporterProtocol: _omitted, ...bodyWithoutProtocol } = validSettings;
const replaced = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send(bodyWithoutProtocol);
expect(replaced.status).toBe(200);
expect(replaced.body.exporterProtocol).toBe('http/protobuf');
});
it('rejects an unsupported exporter protocol with 400', async () => {
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send({ ...validSettings, exporterProtocol: 'http/json' });
expect(response.status).toBe(400);
});
it('rejects a partial body with 400', async () => {
const response = await testServer
.publicApiAgentFor(owner)
@@ -284,6 +339,35 @@ describe('OpenTelemetry settings in Public API', () => {
});
});
describe('PUT /settings/otel with an env-managed exporter protocol', () => {
let originalProtocol: OtelConfig['exporterProtocol'];
beforeEach(async () => {
process.env[OTEL_ENV_VARS.exporterProtocol] = 'grpc';
originalProtocol = Container.get(OtelConfig).exporterProtocol;
Container.get(OtelConfig).exporterProtocol = 'grpc';
await Container.get(OtelSettingsService).loadSettings();
});
afterEach(async () => {
delete process.env[OTEL_ENV_VARS.exporterProtocol];
Container.get(OtelConfig).exporterProtocol = originalProtocol;
await Container.get(OtelSettingsService).loadSettings();
});
it('rejects a body that omits the env-managed protocol with 409', async () => {
const { exporterProtocol: _omitted, ...bodyWithoutProtocol } = validSettings;
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/otel')
.send(bodyWithoutProtocol);
expect(response.status).toBe(409);
expect(response.body.message).toContain('exporterProtocol');
});
});
describe('POST /settings/otel/test-trace', () => {
it('reports a successful connection', async () => {
vi.spyOn(Container.get(OtelService), 'sendTestTrace').mockResolvedValue({ success: true });
@@ -321,6 +405,23 @@ describe('OpenTelemetry settings in Public API', () => {
expect(response.status).toBe(400);
});
it('accepts a connection body written before exporterProtocol existed', async () => {
const sendTestTrace = vi
.spyOn(Container.get(OtelService), 'sendTestTrace')
.mockResolvedValue({ success: true });
const { exporterProtocol: _omitted, ...connectionWithoutProtocol } = testConnection;
const response = await testServer
.publicApiAgentFor(owner)
.post('/settings/otel/test-trace')
.send(connectionWithoutProtocol);
expect(response.status).toBe(200);
expect(sendTestTrace).toHaveBeenCalledWith(
expect.objectContaining({ exporterProtocol: 'http/protobuf' }),
);
});
it('rejects with 401 without a valid API key', async () => {
const response = await testServer
.publicApiAgentWithoutApiKey()
@@ -3279,14 +3279,21 @@
"settings.opentelemetry.enable.action.enableAriaLabel": "Enable OpenTelemetry",
"settings.opentelemetry.enable.action.enabledAriaLabel": "OpenTelemetry enabled. Open status menu",
"settings.opentelemetry.collectorConnection.title": "Collector connection",
"settings.opentelemetry.exporterProtocol.label": "Protocol",
"settings.opentelemetry.exporterProtocol.description": "How spans are sent to the collector. Most collectors accept HTTP on port 4318 and gRPC on port 4317.",
"settings.opentelemetry.exporterProtocol.option.httpProtobuf": "HTTP (protobuf)",
"settings.opentelemetry.exporterProtocol.option.grpc": "gRPC",
"settings.opentelemetry.exporterEndpoint.label": "OTLP endpoint",
"settings.opentelemetry.exporterEndpoint.description": "The base URL of your OTLP collector (e.g. https://collector.example.com).",
"settings.opentelemetry.exporterEndpoint.placeholder": "https://collector.example.com",
"settings.opentelemetry.exporterEndpoint.grpcDescription": "The URL of your OTLP collector, including the gRPC port. Use https:// for TLS.",
"settings.opentelemetry.exporterEndpoint.grpcPlaceholder": "http://collector.example.com:4317",
"settings.opentelemetry.exporterServiceName.label": "Service name",
"settings.opentelemetry.exporterServiceName.description": "How this instance appears in your collector.",
"settings.opentelemetry.exporterServiceName.placeholder": "n8n-production",
"settings.opentelemetry.exporterHeaders.label": "Custom headers",
"settings.opentelemetry.exporterHeaders.description": "Sent with every OTLP export. Use for Bearer tokens or tenant IDs.",
"settings.opentelemetry.exporterHeaders.grpcDescription": "Sent as gRPC metadata with every export. Use for Bearer tokens or tenant IDs.",
"settings.opentelemetry.exporterHeaders.keyLabel": "Key",
"settings.opentelemetry.exporterHeaders.valueLabel": "Value",
"settings.opentelemetry.exporterHeaders.remove": "Remove header",
@@ -20,6 +20,7 @@
"format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore"
},
"dependencies": {
"@n8n/api-types": "workspace:*",
"@n8n/composables": "workspace:*",
"@n8n/design-system": "workspace:*",
"@n8n/frontend-module-sdk": "workspace:*",
@@ -1,7 +1,7 @@
import { createComponentRenderer, mockedStore } from '@n8n/frontend-test-utils';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import { screen, waitFor } from '@testing-library/vue';
import type { OtelSettingsResponse } from './otel.api';
import { useOtelStore } from './otel.store';
@@ -51,6 +51,7 @@ vi.mock('./otel.api', () => ({
const makeSettings = (overrides: Partial<OtelSettingsResponse> = {}): OtelSettingsResponse => ({
enabled: false,
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'http://localhost:4318',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n',
@@ -85,6 +86,12 @@ const typeIntoUnitInput = async (input: HTMLElement, value: string) => {
await userEvent.tab();
};
const selectProtocol = async (optionLabel: string) => {
const select = screen.getByTestId('otel-exporter-protocol');
await userEvent.click(select.querySelector('input')!);
await userEvent.click(await screen.findByText(optionLabel));
};
// ── tests ─────────────────────────────────────────────────────────────────────
describe('SettingsOpenTelemetryView', () => {
@@ -113,6 +120,7 @@ describe('SettingsOpenTelemetryView', () => {
expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument();
});
expect(getByTestId('otel-exporter-protocol')).toBeInTheDocument();
expect(getByTestId('otel-service-name')).toBeInTheDocument();
expect(getByTestId('otel-tracing-path')).toBeInTheDocument();
expect(getByTestId('otel-sample-rate')).toBeInTheDocument();
@@ -300,7 +308,11 @@ describe('SettingsOpenTelemetryView', () => {
await waitFor(() => {
expect(telemetryTrack).toHaveBeenCalledWith(
'Updated otel via UI',
expect.objectContaining({ enabled: true, tracesSampleRate: 0.5 }),
expect.objectContaining({
enabled: true,
tracesSampleRate: 0.5,
protocol: 'http/protobuf',
}),
);
expect(telemetryTrack).not.toHaveBeenCalledWith('Activated otel via UI', expect.anything());
expect(telemetryTrack).not.toHaveBeenCalledWith('Disabled otel via UI');
@@ -463,6 +475,100 @@ describe('SettingsOpenTelemetryView', () => {
});
});
// ── exporter protocol ─────────────────────────────────────────────────────
it('shows the saved protocol in the select', async () => {
getOtelSettingsMock.mockResolvedValue(makeSettings({ exporterProtocol: 'grpc' }));
const { getByTestId } = render();
await waitFor(() => expect(getByTestId('otel-exporter-protocol')).toBeInTheDocument());
const input = getByTestId('otel-exporter-protocol').querySelector('input');
expect(input).toHaveValue('gRPC');
expect(input).toHaveAttribute('aria-label', 'Protocol');
});
it('hides the trace path row and shows gRPC endpoint copy when the protocol is gRPC', async () => {
getOtelSettingsMock.mockResolvedValue(makeSettings({ exporterProtocol: 'grpc' }));
const { getByTestId, queryByTestId } = render();
await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
expect(queryByTestId('otel-tracing-path')).not.toBeInTheDocument();
expect(getByTestId('otel-exporter-endpoint')).toHaveAttribute(
'placeholder',
'http://collector.example.com:4317',
);
});
it('hides the trace path row after switching the protocol to gRPC', async () => {
const { getByTestId, queryByTestId, store } = render();
await waitFor(() => expect(getByTestId('otel-tracing-path')).toBeInTheDocument());
await selectProtocol('gRPC');
await waitFor(() => {
expect(store.settings.exporterProtocol).toBe('grpc');
expect(queryByTestId('otel-tracing-path')).not.toBeInTheDocument();
});
});
it('keeps the trace path across a switch to gRPC and back', async () => {
getOtelSettingsMock.mockResolvedValue(makeSettings({ exporterTracingPath: '/custom/traces' }));
const { getByTestId, queryByTestId, store } = render();
await waitFor(() => expect(getByTestId('otel-tracing-path')).toBeInTheDocument());
await selectProtocol('gRPC');
await waitFor(() => expect(queryByTestId('otel-tracing-path')).not.toBeInTheDocument());
await selectProtocol('HTTP (protobuf)');
await waitFor(() => expect(getByTestId('otel-tracing-path')).toBeInTheDocument());
expect(getByTestId('otel-tracing-path')).toHaveValue('/custom/traces');
expect(store.settings.exporterTracingPath).toBe('/custom/traces');
});
it('saves the protocol picked in the select', async () => {
const { getByTestId } = render();
await waitFor(() => expect(getByTestId('otel-exporter-protocol')).toBeInTheDocument());
await selectProtocol('gRPC');
await waitFor(() => expect(getByTestId('settings-save-bar-save')).toBeInTheDocument());
await userEvent.click(getByTestId('settings-save-bar-save'));
await waitFor(() => {
expect(updateOtelSettingsMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ exporterProtocol: 'grpc' }),
);
});
});
it('disables the protocol select when it is managed by an env var', async () => {
getOtelSettingsMock.mockResolvedValue(makeSettings({ envManagedFields: ['exporterProtocol'] }));
const { getByTestId } = render();
await waitFor(() => expect(getByTestId('otel-exporter-protocol')).toBeInTheDocument());
expect(getByTestId('otel-exporter-protocol').querySelector('input')).toBeDisabled();
});
it('invalidates a previous test result when the protocol changes', async () => {
const { getByTestId, getByText, queryByText } = render();
await waitFor(() => expect(getByTestId('otel-test-trace-button')).toBeInTheDocument());
await userEvent.click(getByTestId('otel-test-trace-button'));
await waitFor(() => expect(getByText(/span sent to collector at/)).toBeInTheDocument());
await selectProtocol('gRPC');
await waitFor(() => {
expect(queryByText(/span sent to collector at/)).not.toBeInTheDocument();
});
});
// ── test trace ────────────────────────────────────────────────────────────
it('renders the test trace button after fetch', async () => {
@@ -11,6 +11,8 @@ import {
N8nIcon,
N8nInput,
N8nInputLabel,
N8nOption,
N8nSelect,
N8nSettingsLayout,
N8nSettingsPageHeader,
N8nSettingsRowGroup,
@@ -24,7 +26,7 @@ import { onBeforeRouteLeave, type NavigationGuardNext } from 'vue-router';
import { OTEL_FIELD_ENV_VARS, OTEL_TEST_SPAN_NAME } from './otel.constants';
import { useOtelStore, headersStringToPairs, headersPairsToString } from './otel.store';
import { createSampleRateFormat } from './otel.utils';
import { createSampleRateFormat, isOtlpProtocol } from './otel.utils';
import OtelSettingsRow from './OtelSettingsRow.vue';
import OtelStatusControl from './OtelStatusControl.vue';
@@ -83,6 +85,38 @@ const statusDescription = computed(() =>
: i18n.baseText('settings.opentelemetry.status.disabledDescription'),
);
const isGrpc = computed(() => otelStore.settings.exporterProtocol === 'grpc');
const endpointDescription = computed(() =>
i18n.baseText(
isGrpc.value
? 'settings.opentelemetry.exporterEndpoint.grpcDescription'
: 'settings.opentelemetry.exporterEndpoint.description',
),
);
const endpointPlaceholder = computed(() =>
i18n.baseText(
isGrpc.value
? 'settings.opentelemetry.exporterEndpoint.grpcPlaceholder'
: 'settings.opentelemetry.exporterEndpoint.placeholder',
),
);
const headersDescription = computed(() =>
i18n.baseText(
isGrpc.value
? 'settings.opentelemetry.exporterHeaders.grpcDescription'
: 'settings.opentelemetry.exporterHeaders.description',
),
);
function onProtocolChange(value: unknown) {
if (isOtlpProtocol(value)) {
otelStore.settings.exporterProtocol = value;
}
}
function envTooltip(field: keyof typeof OTEL_FIELD_ENV_VARS): string {
const envVariable = i18n.baseText('settings.opentelemetry.envVarTooltip', {
interpolate: { envVar: OTEL_FIELD_ENV_VARS[field] },
@@ -101,6 +135,7 @@ async function save(): Promise<boolean> {
if (!wasEnabled && isNowEnabled) {
telemetry.track('Activated otel via UI', {
protocol: otelStore.settings.exporterProtocol,
includeNodeSpans: otelStore.settings.includeNodeSpans,
productionExecutionsOnly: otelStore.settings.productionExecutionsOnly,
tracesSampleRate: otelStore.settings.tracesSampleRate,
@@ -111,6 +146,7 @@ async function save(): Promise<boolean> {
} else {
telemetry.track('Updated otel via UI', {
enabled: isNowEnabled,
protocol: otelStore.settings.exporterProtocol,
includeNodeSpans: otelStore.settings.includeNodeSpans,
productionExecutionsOnly: otelStore.settings.productionExecutionsOnly,
tracesSampleRate: otelStore.settings.tracesSampleRate,
@@ -323,6 +359,7 @@ async function onSendTestTrace() {
// Connection changes invalidate the previous test result
watch(
() => [
otelStore.settings.exporterProtocol,
otelStore.settings.exporterEndpoint,
otelStore.settings.exporterTracingPath,
otelStore.settings.exporterServiceName,
@@ -373,9 +410,38 @@ watch(
:title="i18n.baseText('settings.opentelemetry.collectorConnection.title')"
>
<N8nSettingsRowGroup>
<OtelSettingsRow
:title="i18n.baseText('settings.opentelemetry.exporterProtocol.label')"
:description="i18n.baseText('settings.opentelemetry.exporterProtocol.description')"
:env-tooltip="envTooltip('exporterProtocol')"
action-fill
>
<template #action>
<N8nSelect
:class="$style.control"
:model-value="otelStore.settings.exporterProtocol"
:disabled="isEnvManaged('exporterProtocol')"
:aria-label="i18n.baseText('settings.opentelemetry.exporterProtocol.label')"
data-test-id="otel-exporter-protocol"
@update:model-value="onProtocolChange"
>
<N8nOption
value="http/protobuf"
:label="
i18n.baseText('settings.opentelemetry.exporterProtocol.option.httpProtobuf')
"
/>
<N8nOption
value="grpc"
:label="i18n.baseText('settings.opentelemetry.exporterProtocol.option.grpc')"
/>
</N8nSelect>
</template>
</OtelSettingsRow>
<OtelSettingsRow
:title="i18n.baseText('settings.opentelemetry.exporterEndpoint.label')"
:description="i18n.baseText('settings.opentelemetry.exporterEndpoint.description')"
:description="endpointDescription"
:env-tooltip="envTooltip('exporterEndpoint')"
action-fill
>
@@ -383,7 +449,7 @@ watch(
<N8nInput
v-model="otelStore.settings.exporterEndpoint"
:class="$style.control"
:placeholder="i18n.baseText('settings.opentelemetry.exporterEndpoint.placeholder')"
:placeholder="endpointPlaceholder"
:disabled="isEnvManaged('exporterEndpoint')"
data-test-id="otel-exporter-endpoint"
/>
@@ -411,7 +477,7 @@ watch(
<OtelSettingsRow
:title="i18n.baseText('settings.opentelemetry.exporterHeaders.label')"
:description="i18n.baseText('settings.opentelemetry.exporterHeaders.description')"
:description="headersDescription"
:env-tooltip="envTooltip('exporterHeaders')"
layout="vertical"
action-fill
@@ -486,6 +552,7 @@ watch(
</OtelSettingsRow>
<OtelSettingsRow
v-if="!isGrpc"
:title="i18n.baseText('settings.opentelemetry.exporterTracingPath.label')"
:description="i18n.baseText('settings.opentelemetry.exporterTracingPath.description')"
:env-tooltip="envTooltip('exporterTracingPath')"
@@ -14,6 +14,7 @@ const context: IRestApiContext = { baseUrl: 'http://localhost:5678', pushRef: ''
const makeSettings = (overrides: Partial<OtelSettingsResponse> = {}): OtelSettingsResponse => ({
enabled: false,
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'http://localhost:4318',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n',
@@ -59,6 +60,7 @@ describe('otel.api', () => {
describe('sendOtelTestTrace', () => {
const connection: OtelTestConnection = {
exporterProtocol: 'grpc',
exporterEndpoint: 'https://collector.io',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n',
@@ -1,8 +1,11 @@
import type { IRestApiContext } from '@n8n/rest-api-client';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { OtlpProtocol } from './otel.constants';
export type OtelSettings = {
enabled: boolean;
exporterProtocol: OtlpProtocol;
exporterEndpoint: string;
exporterTracingPath: string;
exporterServiceName: string;
@@ -31,6 +34,7 @@ export async function updateOtelSettings(
export type OtelTestConnection = Pick<
OtelSettings,
| 'exporterProtocol'
| 'exporterEndpoint'
| 'exporterTracingPath'
| 'exporterServiceName'
@@ -1,3 +1,7 @@
import { OTLP_PROTOCOLS, type OtlpProtocol } from '@n8n/api-types';
export { OTLP_PROTOCOLS, type OtlpProtocol };
export const OTEL_STORE = 'otel';
/**
@@ -14,6 +18,7 @@ export const OTEL_TEST_SPAN_NAME = 'n8n.test_trace';
/** Maps each settings field to its env-var name — shown in per-field tooltips. */
export const OTEL_FIELD_ENV_VARS = {
enabled: 'N8N_OTEL_ENABLED',
exporterProtocol: 'N8N_OTEL_EXPORTER_OTLP_PROTOCOL',
exporterEndpoint: 'N8N_OTEL_EXPORTER_OTLP_ENDPOINT',
exporterTracingPath: 'N8N_OTEL_EXPORTER_OTLP_TRACING_PATH',
exporterServiceName: 'N8N_OTEL_EXPORTER_SERVICE_NAME',
@@ -22,6 +22,7 @@ const testTraceMock = vi.mocked(otelApi.sendOtelTestTrace);
const makeSettings = (overrides: Partial<OtelSettingsResponse> = {}): OtelSettingsResponse => ({
enabled: false,
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'http://localhost:4318',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n',
@@ -287,6 +288,7 @@ describe('useOtelStore', () => {
it('sends only the connection fields from the current settings', async () => {
fetchMock.mockResolvedValueOnce(
makeSettings({
exporterProtocol: 'grpc',
exporterEndpoint: 'https://collector.io',
exporterTracingPath: '/custom',
exporterServiceName: 'n8n-prod',
@@ -301,6 +303,7 @@ describe('useOtelStore', () => {
await store.sendTestTrace();
expect(testTraceMock).toHaveBeenCalledWith(expect.anything(), {
exporterProtocol: 'grpc',
exporterEndpoint: 'https://collector.io',
exporterTracingPath: '/custom',
exporterServiceName: 'n8n-prod',
@@ -29,6 +29,7 @@ export function headersPairsToString(pairs: Array<{ key: string; value: string }
const defaultSettings: OtelSettings = {
enabled: false,
exporterProtocol: 'http/protobuf',
exporterEndpoint: 'http://localhost:4318',
exporterTracingPath: '/v1/traces',
exporterServiceName: 'n8n',
@@ -105,6 +106,7 @@ export const useOtelStore = defineStore(OTEL_STORE, () => {
testError.value = '';
try {
const result = await sendOtelTestTrace(rootStore.restApiContext, {
exporterProtocol: settings.value.exporterProtocol,
exporterEndpoint: settings.value.exporterEndpoint,
exporterTracingPath: settings.value.exporterTracingPath,
exporterServiceName: settings.value.exporterServiceName,
@@ -1,4 +1,17 @@
import { createSampleRateFormat } from './otel.utils';
import { createSampleRateFormat, isOtlpProtocol } from './otel.utils';
describe('isOtlpProtocol', () => {
it.each(['http/protobuf', 'grpc'])('accepts the supported protocol %s', (value) => {
expect(isOtlpProtocol(value)).toBe(true);
});
it.each(['http/json', 'HTTP/PROTOBUF', '', undefined, null, 1])(
'rejects %s',
(value: unknown) => {
expect(isOtlpProtocol(value)).toBe(false);
},
);
});
describe('createSampleRateFormat', () => {
describe.each(['en-US', 'de-DE', 'fr-FR', 'ar-EG', 'fa-IR'])('locale %s', (locale) => {
@@ -1,3 +1,9 @@
import { OTLP_PROTOCOLS, type OtlpProtocol } from './otel.constants';
export function isOtlpProtocol(value: unknown): value is OtlpProtocol {
return OTLP_PROTOCOLS.some((protocol) => protocol === value);
}
/**
* Locale-aware formatting and parsing for the traces sample rate (0..1).
*
+9
View File
@@ -4191,6 +4191,9 @@ importers:
'@google-cloud/secret-manager':
specifier: 5.6.0
version: 5.6.0([email protected])([email protected])
'@grpc/grpc-js':
specifier: ^1.14.3
version: 1.14.4
'@joplin/turndown-plugin-gfm':
specifier: 'catalog:'
version: 1.0.64
@@ -4314,6 +4317,9 @@ importers:
'@opentelemetry/core':
specifier: 2.10.0
version: 2.10.0(@opentelemetry/[email protected])
'@opentelemetry/exporter-trace-otlp-grpc':
specifier: ^0.221.0
version: 0.221.0(@opentelemetry/[email protected])
'@opentelemetry/exporter-trace-otlp-proto':
specifier: ^0.221.0
version: 0.221.0(@opentelemetry/[email protected])
@@ -6586,6 +6592,9 @@ importers:
packages/modules/otel/frontend:
dependencies:
'@n8n/api-types':
specifier: workspace:*
version: link:../../../@n8n/api-types
'@n8n/composables':
specifier: workspace:*
version: link:../../../frontend/@n8n/composables