Skip to content

Commit f748613

Browse files
ocdejongclaude
andauthored
feat(core): Add OTLP gRPC protocol support for OpenTelemetry tracing (#37491)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e4e78da commit f748613

33 files changed

Lines changed: 1503 additions & 108 deletions

packages/@n8n/api-types/src/dto/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,13 @@ export {
431431
type ListWorkflowReviewActivityResponse,
432432
} from './workflow-reviews/workflow-review-activity.dto';
433433

434-
export { UpdateOtelSettingsDto } from './otel/update-otel-settings.dto';
434+
export {
435+
UpdateOtelSettingsDto,
436+
OTLP_PROTOCOLS,
437+
otlpProtocolSchema,
438+
exporterEndpointSchema,
439+
type OtlpProtocol,
440+
} from './otel/update-otel-settings.dto';
435441
export { TestOtelTraceDto } from './otel/test-otel-trace.dto';
436442

437443
export { InstanceAiExamplesQueryDto } from './instance-ai-examples/instance-ai-examples-query.dto';

packages/@n8n/api-types/src/dto/otel/__tests__/update-otel-settings.dto.test.ts

Lines changed: 128 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { UpdateOtelSettingsDto } from '../update-otel-settings.dto';
33

44
const validSettings = {
55
enabled: true,
6+
exporterProtocol: 'http/protobuf',
67
exporterEndpoint: 'http://localhost:4318',
78
exporterTracingPath: '/v1/traces',
89
exporterServiceName: 'n8n',
@@ -14,17 +15,29 @@ const validSettings = {
1415
productionExecutionsOnly: true,
1516
};
1617

18+
const defaultedFields = ['exporterProtocol'];
19+
1720
describe('UpdateOtelSettingsDto', () => {
18-
it('requires every field (stays strict, so a partial body is rejected)', () => {
21+
it('requires every field except the defaulted ones (so a partial body is rejected)', () => {
1922
const result = UpdateOtelSettingsDto.safeParse({});
2023

2124
assert(!result.success, 'Expected validation to fail for an empty body');
2225

23-
// An empty body must report every field as missing. A field that carries a
24-
// default would parse successfully instead of erroring — this guards the
25-
// public API PUT against silently resetting omitted fields.
2626
const missing = [...new Set(result.error.issues.map((issue) => String(issue.path[0])))].sort();
27-
expect(missing).toEqual(Object.keys(validSettings).sort());
27+
expect(missing).toEqual(
28+
Object.keys(validSettings)
29+
.filter((key) => !defaultedFields.includes(key))
30+
.sort(),
31+
);
32+
});
33+
34+
it('defaults the exporter protocol when omitted (body predates the field)', () => {
35+
const { exporterProtocol: _omitted, ...withoutProtocol } = validSettings;
36+
37+
const result = UpdateOtelSettingsDto.safeParse(withoutProtocol);
38+
39+
assert(result.success, 'Expected a body without exporterProtocol to stay valid');
40+
expect(result.data.exporterProtocol).toBe('http/protobuf');
2841
});
2942

3043
it('accepts a full body', () => {
@@ -48,6 +61,70 @@ describe('UpdateOtelSettingsDto', () => {
4861
);
4962
});
5063

64+
it.each(['http/protobuf', 'grpc'])('accepts the %s exporter protocol', (exporterProtocol) => {
65+
const result = UpdateOtelSettingsDto.safeParse({ ...validSettings, exporterProtocol });
66+
67+
assert(result.success, `Expected ${exporterProtocol} to be a valid exporter protocol`);
68+
expect(result.data.exporterProtocol).toBe(exporterProtocol);
69+
});
70+
71+
it.each(['http/json', 'HTTP/PROTOBUF', 'http', 'gRPC', ''])(
72+
'rejects %p as an exporter protocol',
73+
(exporterProtocol) => {
74+
const result = UpdateOtelSettingsDto.safeParse({ ...validSettings, exporterProtocol });
75+
76+
assert(!result.success, `Expected ${exporterProtocol} to be an invalid exporter protocol`);
77+
expect(result.error.issues).toContainEqual(
78+
expect.objectContaining({
79+
code: 'invalid_enum_value',
80+
path: ['exporterProtocol'],
81+
}),
82+
);
83+
},
84+
);
85+
86+
it.each([
87+
'http://localhost:4318',
88+
'https://collector.example.com:4317',
89+
'http://[::1]:4317',
90+
'HTTP://localhost:4318',
91+
'HttpS://collector.example.com:4317',
92+
])('accepts %p as an exporter endpoint', (exporterEndpoint) => {
93+
const result = UpdateOtelSettingsDto.safeParse({ ...validSettings, exporterEndpoint });
94+
95+
assert(result.success, `Expected ${exporterEndpoint} to be a valid exporter endpoint`);
96+
expect(result.data.exporterEndpoint).toBe(exporterEndpoint);
97+
});
98+
99+
it.each(['localhost:4318', 'grpc://host:4317', 'ftp://x'])(
100+
'rejects %p as an exporter endpoint',
101+
(exporterEndpoint) => {
102+
const result = UpdateOtelSettingsDto.safeParse({ ...validSettings, exporterEndpoint });
103+
104+
assert(!result.success, `Expected ${exporterEndpoint} to be an invalid exporter endpoint`);
105+
expect(result.error.issues).toContainEqual(
106+
expect.objectContaining({ path: ['exporterEndpoint'] }),
107+
);
108+
},
109+
);
110+
111+
it('explains why a non-http endpoint scheme is rejected', () => {
112+
const result = UpdateOtelSettingsDto.safeParse({
113+
...validSettings,
114+
exporterEndpoint: 'grpc://host:4317',
115+
});
116+
117+
assert(!result.success, 'Expected validation to fail for a grpc:// exporter endpoint');
118+
expect(result.error.issues).toContainEqual(
119+
expect.objectContaining({
120+
code: 'invalid_string',
121+
validation: 'regex',
122+
path: ['exporterEndpoint'],
123+
message: 'Endpoint must start with http:// or https://. The scheme selects TLS.',
124+
}),
125+
);
126+
});
127+
51128
it('rejects a sample rate outside the 0..1 range', () => {
52129
const result = UpdateOtelSettingsDto.safeParse({ ...validSettings, tracesSampleRate: 2 });
53130

@@ -64,39 +141,76 @@ describe('UpdateOtelSettingsDto', () => {
64141

65142
describe('TestOtelTraceDto', () => {
66143
const validConnection = {
144+
exporterProtocol: 'http/protobuf',
67145
exporterEndpoint: 'http://localhost:4318',
68146
exporterTracingPath: '/v1/traces',
69147
exporterServiceName: 'n8n',
70148
exporterHeaders: '',
71149
startupConnectivityTimeoutMs: 2_000,
72150
};
73151

74-
it('requires every connection field (stays strict)', () => {
152+
it('requires every connection field except the defaulted ones (stays strict)', () => {
75153
const result = TestOtelTraceDto.safeParse({});
76154

77155
assert(!result.success, 'Expected validation to fail for an empty body');
78156

79157
const missing = [...new Set(result.error.issues.map((issue) => String(issue.path[0])))].sort();
80-
expect(missing).toEqual(Object.keys(validConnection).sort());
158+
expect(missing).toEqual(
159+
Object.keys(validConnection)
160+
.filter((key) => !defaultedFields.includes(key))
161+
.sort(),
162+
);
81163
});
82164

83-
it('accepts a full connection body', () => {
84-
const result = TestOtelTraceDto.safeParse(validConnection);
85-
expect(result.success).toBe(true);
165+
it('accepts a gRPC connection body', () => {
166+
const result = TestOtelTraceDto.safeParse({
167+
...validConnection,
168+
exporterProtocol: 'grpc',
169+
exporterEndpoint: 'http://localhost:4317',
170+
});
171+
172+
assert(result.success, 'Expected a gRPC connection body to be valid');
173+
expect(result.data.exporterProtocol).toBe('grpc');
86174
});
87175

88-
it('rejects an invalid exporter endpoint', () => {
176+
it('rejects an unsupported exporter protocol', () => {
89177
const result = TestOtelTraceDto.safeParse({
90178
...validConnection,
91-
exporterEndpoint: 'not-a-url',
179+
exporterProtocol: 'http/json',
92180
});
93181

94-
assert(!result.success, 'Expected validation to fail for an invalid exporter endpoint');
182+
assert(!result.success, 'Expected validation to fail for an unsupported exporter protocol');
183+
expect(result.error.issues).toContainEqual(
184+
expect.objectContaining({
185+
code: 'invalid_enum_value',
186+
path: ['exporterProtocol'],
187+
}),
188+
);
189+
});
190+
191+
it('accepts an https exporter endpoint', () => {
192+
const result = TestOtelTraceDto.safeParse({
193+
...validConnection,
194+
exporterEndpoint: 'https://collector.example.com:4317',
195+
});
196+
197+
assert(result.success, 'Expected an https exporter endpoint to be valid');
198+
expect(result.data.exporterEndpoint).toBe('https://collector.example.com:4317');
199+
});
200+
201+
it('rejects a non-http exporter endpoint scheme', () => {
202+
const result = TestOtelTraceDto.safeParse({
203+
...validConnection,
204+
exporterEndpoint: 'grpc://host:4317',
205+
});
206+
207+
assert(!result.success, 'Expected validation to fail for a grpc:// exporter endpoint');
95208
expect(result.error.issues).toContainEqual(
96209
expect.objectContaining({
97210
code: 'invalid_string',
98-
validation: 'url',
211+
validation: 'regex',
99212
path: ['exporterEndpoint'],
213+
message: 'Endpoint must start with http:// or https://. The scheme selects TLS.',
100214
}),
101215
);
102216
});

packages/@n8n/api-types/src/dto/otel/test-otel-trace.dto.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Z } from '../../zod-class';
33

44
export class TestOtelTraceDto extends Z.class(
55
UpdateOtelSettingsDto.schema.pick({
6+
exporterProtocol: true,
67
exporterEndpoint: true,
78
exporterTracingPath: true,
89
exporterServiceName: true,

packages/@n8n/api-types/src/dto/otel/update-otel-settings.dto.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,22 @@ import { z } from 'zod';
22

33
import { Z } from '../../zod-class';
44

5+
export const OTLP_PROTOCOLS = ['http/protobuf', 'grpc'] as const;
6+
7+
export type OtlpProtocol = (typeof OTLP_PROTOCOLS)[number];
8+
9+
export const otlpProtocolSchema = z.enum(OTLP_PROTOCOLS);
10+
11+
export const exporterEndpointSchema = z
12+
.string()
13+
.url()
14+
.regex(/^https?:\/\//i, 'Endpoint must start with http:// or https://. The scheme selects TLS.');
15+
516
export class UpdateOtelSettingsDto extends Z.class({
617
enabled: z.boolean(),
7-
exporterEndpoint: z.string().url(),
18+
// Defaulted so a body from before this field existed still parses.
19+
exporterProtocol: otlpProtocolSchema.default('http/protobuf'),
20+
exporterEndpoint: exporterEndpointSchema,
821
exporterTracingPath: z.string(),
922
exporterServiceName: z.string().min(1),
1023
exporterHeaders: z.string(),

packages/cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
"@chat-adapter/telegram": "catalog:",
141141
"@daytona/sdk": "catalog:",
142142
"@google-cloud/secret-manager": "5.6.0",
143+
"@grpc/grpc-js": "^1.14.3",
143144
"@joplin/turndown-plugin-gfm": "catalog:",
144145
"@langchain/core": "catalog:",
145146
"@modelcontextprotocol/node": "catalog:",
@@ -181,6 +182,7 @@
181182
"@n8n_io/license-sdk": "3.0.0",
182183
"@opentelemetry/api": "catalog:",
183184
"@opentelemetry/core": "2.10.0",
185+
"@opentelemetry/exporter-trace-otlp-grpc": "^0.221.0",
184186
"@opentelemetry/exporter-trace-otlp-proto": "^0.221.0",
185187
"@opentelemetry/instrumentation": "^0.221.0",
186188
"@opentelemetry/resources": "^2.10.0",

packages/cli/src/modules/otel/README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,53 @@ Start n8n & point it at the jaeger instance
135135
cd packages/cli
136136
N8N_OTEL_ENABLED=true N8N_OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 pnpm run dev
137137
```
138+
139+
### Wire protocol (OTLP/HTTP vs OTLP/gRPC)
140+
141+
`N8N_OTEL_EXPORTER_OTLP_PROTOCOL` selects how spans are delivered. It mirrors the
142+
upstream `OTEL_EXPORTER_OTLP_PROTOCOL` spec and accepts:
143+
144+
| Value | Exporter | Conventional port |
145+
| ------------------------- | ------------------------------------------ | ----------------- |
146+
| `http/protobuf` (default) | `@opentelemetry/exporter-trace-otlp-proto` | 4318 |
147+
| `grpc` | `@opentelemetry/exporter-trace-otlp-grpc` | 4317 |
148+
149+
```
150+
cd packages/cli
151+
N8N_OTEL_ENABLED=true \
152+
N8N_OTEL_EXPORTER_OTLP_PROTOCOL=grpc \
153+
N8N_OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317 \
154+
pnpm run dev
155+
```
156+
157+
Notes:
158+
159+
- Pick `grpc` only when the collector requires it (or for high span volume through
160+
infrastructure that passes HTTP/2 through cleanly). `http/protobuf` traverses
161+
proxies and firewalls more reliably and is easier to debug.
162+
- The endpoint scheme controls TLS for **both** protocols: `https://` uses TLS,
163+
`http://` does not. There is no `grpc://` scheme.
164+
- Because the scheme is load-bearing, `N8N_OTEL_EXPORTER_OTLP_ENDPOINT` must be an
165+
`http://` or `https://` URL. n8n logs a warning and uses the default endpoint if
166+
the value has another scheme or no scheme, e.g. `localhost:4318`. The scheme is
167+
matched case-insensitively, and n8n lowercases it before it reaches the exporter.
168+
- gRPC endpoints take **no URL path**, so `N8N_OTEL_EXPORTER_OTLP_TRACING_PATH` is
169+
ignored when the protocol is `grpc`.
170+
- `N8N_OTEL_EXPORTER_OTLP_HEADERS` entries are sent as gRPC metadata. Keys are
171+
lowercased (gRPC metadata keys are lowercase ASCII); an entry grpc-js rejects is
172+
skipped with a warning instead of failing startup.
173+
- The startup connectivity check waits for a grpc-js channel to become ready for
174+
`grpc` (an HTTP `HEAD` request is meaningless against an HTTP/2-only server).
175+
The channel dials the host and port of the endpoint, so readiness proves TCP,
176+
the TLS handshake for `https://`, and an HTTP/2 connection. It is not proof that
177+
OTLP/gRPC is served there — use "Send test trace" in Settings → OpenTelemetry
178+
for the real check.
179+
- An endpoint without a port dials the grpc-js default port 443, not 4317. Always
180+
give the port, e.g. `http://127.0.0.1:4317`.
181+
- The check uses the default TLS trust store. It does not use the certificate
182+
material that the exporter reads from `OTEL_EXPORTER_OTLP_CERTIFICATE`,
183+
`OTEL_EXPORTER_OTLP_CLIENT_KEY` and `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE`, so a
184+
collector behind a private CA, or one that needs mTLS, can fail the check and
185+
still receive spans.
186+
- n8n has no setting for a custom CA or mTLS. Use the upstream
187+
`OTEL_EXPORTER_OTLP_*` certificate variables above, or `NODE_EXTRA_CA_CERTS`.

packages/cli/src/modules/otel/__tests__/otel-lifecycle-handler.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function makeOtelSettingsService(
4343
enabled: true,
4444
productionExecutionsOnly: false,
4545
includeNodeSpans: true,
46+
exporterProtocol: 'http/protobuf',
4647
exporterEndpoint: 'http://localhost:4318',
4748
exporterTracingPath: '/v1/traces',
4849
exporterServiceName: 'n8n',

packages/cli/src/modules/otel/__tests__/otel-settings.controller.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const res = mock<Response>();
1919

2020
const baseSettings: OtelConfig = {
2121
enabled: true,
22+
exporterProtocol: 'http/protobuf',
2223
exporterEndpoint: 'https://collector.example.com',
2324
exporterTracingPath: '/v1/traces',
2425
exporterHeaders: '',
@@ -131,6 +132,7 @@ describe('OtelSettingsController', () => {
131132

132133
describe('testTrace', () => {
133134
const dto: OtelConnectionParams = {
135+
exporterProtocol: 'http/protobuf',
134136
exporterEndpoint: 'https://collector.example.com',
135137
exporterTracingPath: '/v1/traces',
136138
exporterServiceName: 'n8n-prod',

0 commit comments

Comments
 (0)