mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
feat(core): Add OTLP gRPC protocol support for OpenTelemetry tracing (#37491)
Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e4e78da0d6
commit
f748613f9a
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
Generated
+9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user