@n8n/frontend-module-otel 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,24 +1,33 @@
1
1
  {
2
2
  "name": "@n8n/frontend-module-otel",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "dependencies": {
10
+ "@n8n/api-types": "1.39.0",
11
+ "@n8n/composables": "1.29.0",
12
+ "@n8n/design-system": "2.38.0",
13
+ "@n8n/frontend-module-sdk": "0.9.0",
14
+ "@n8n/i18n": "2.39.0",
15
+ "@n8n/rest-api-client": "2.39.0",
16
+ "@n8n/stores": "2.39.0",
10
17
  "pinia": "^2.2.4",
11
18
  "vue": "^3.5.13",
12
- "vue-router": "^4.5.0",
13
- "@n8n/composables": "1.28.0",
14
- "@n8n/frontend-module-sdk": "0.8.0",
15
- "@n8n/design-system": "2.37.0",
16
- "@n8n/i18n": "2.38.0",
17
- "@n8n/rest-api-client": "2.38.0",
18
- "@n8n/stores": "2.38.0"
19
+ "vue-router": "^4.5.0"
19
20
  },
20
21
  "devDependencies": {
21
22
  "@iconify/json": "^2.2.488",
23
+ "@n8n/eslint-config": "0.0.1",
24
+ "@n8n/frontend-test-utils": "0.1.0",
25
+ "@n8n/frontend-vite-config": "0.1.0",
26
+ "@n8n/permissions": "0.76.0",
27
+ "@n8n/playwright-janitor": "0.1.0",
28
+ "@n8n/stylelint-config": "0.0.1",
29
+ "@n8n/typescript-config": "1.11.0",
30
+ "@n8n/vitest-config": "1.21.0",
22
31
  "@pinia/testing": "^0.1.6",
23
32
  "@testing-library/jest-dom": "^6.6.3",
24
33
  "@testing-library/user-event": "^14.6.1",
@@ -31,15 +40,7 @@
31
40
  "vite": "^8.0.2",
32
41
  "vite-svg-loader": "5.1.0",
33
42
  "vitest": "^4.1.9",
34
- "vue-tsc": "^2.2.8",
35
- "@n8n/frontend-vite-config": "0.1.0",
36
- "@n8n/eslint-config": "0.0.1",
37
- "@n8n/permissions": "0.75.0",
38
- "@n8n/playwright-janitor": "0.1.0",
39
- "@n8n/frontend-test-utils": "0.1.0",
40
- "@n8n/typescript-config": "1.11.0",
41
- "@n8n/vitest-config": "1.21.0",
42
- "@n8n/stylelint-config": "0.0.1"
43
+ "vue-tsc": "^2.2.8"
43
44
  },
44
45
  "license": "SEE LICENSE IN LICENSE.md",
45
46
  "homepage": "https://n8n.io",
@@ -1,7 +1,7 @@
1
1
  import { createComponentRenderer, mockedStore } from '@n8n/frontend-test-utils';
2
2
  import { createTestingPinia } from '@pinia/testing';
3
3
  import userEvent from '@testing-library/user-event';
4
- import { waitFor } from '@testing-library/vue';
4
+ import { screen, waitFor } from '@testing-library/vue';
5
5
 
6
6
  import type { OtelSettingsResponse } from './otel.api';
7
7
  import { useOtelStore } from './otel.store';
@@ -51,6 +51,7 @@ vi.mock('./otel.api', () => ({
51
51
 
52
52
  const makeSettings = (overrides: Partial<OtelSettingsResponse> = {}): OtelSettingsResponse => ({
53
53
  enabled: false,
54
+ exporterProtocol: 'http/protobuf',
54
55
  exporterEndpoint: 'http://localhost:4318',
55
56
  exporterTracingPath: '/v1/traces',
56
57
  exporterServiceName: 'n8n',
@@ -85,6 +86,12 @@ const typeIntoUnitInput = async (input: HTMLElement, value: string) => {
85
86
  await userEvent.tab();
86
87
  };
87
88
 
89
+ const selectProtocol = async (optionLabel: string) => {
90
+ const select = screen.getByTestId('otel-exporter-protocol');
91
+ await userEvent.click(select.querySelector('input')!);
92
+ await userEvent.click(await screen.findByText(optionLabel));
93
+ };
94
+
88
95
  // ── tests ─────────────────────────────────────────────────────────────────────
89
96
 
90
97
  describe('SettingsOpenTelemetryView', () => {
@@ -113,6 +120,7 @@ describe('SettingsOpenTelemetryView', () => {
113
120
  expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument();
114
121
  });
115
122
 
123
+ expect(getByTestId('otel-exporter-protocol')).toBeInTheDocument();
116
124
  expect(getByTestId('otel-service-name')).toBeInTheDocument();
117
125
  expect(getByTestId('otel-tracing-path')).toBeInTheDocument();
118
126
  expect(getByTestId('otel-sample-rate')).toBeInTheDocument();
@@ -300,7 +308,11 @@ describe('SettingsOpenTelemetryView', () => {
300
308
  await waitFor(() => {
301
309
  expect(telemetryTrack).toHaveBeenCalledWith(
302
310
  'Updated otel via UI',
303
- expect.objectContaining({ enabled: true, tracesSampleRate: 0.5 }),
311
+ expect.objectContaining({
312
+ enabled: true,
313
+ tracesSampleRate: 0.5,
314
+ protocol: 'http/protobuf',
315
+ }),
304
316
  );
305
317
  expect(telemetryTrack).not.toHaveBeenCalledWith('Activated otel via UI', expect.anything());
306
318
  expect(telemetryTrack).not.toHaveBeenCalledWith('Disabled otel via UI');
@@ -463,6 +475,143 @@ describe('SettingsOpenTelemetryView', () => {
463
475
  });
464
476
  });
465
477
 
478
+ it('renders header keys with masked values when stored headers are redacted', async () => {
479
+ getOtelSettingsMock.mockResolvedValue(
480
+ makeSettings({
481
+ exporterHeaders: 'authorization=__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6',
482
+ envManagedFields: [],
483
+ }),
484
+ );
485
+
486
+ const { getAllByTestId, getByTestId } = render();
487
+
488
+ await waitFor(() => {
489
+ expect(getAllByTestId('otel-header-key')).toHaveLength(1);
490
+ });
491
+ expect(getByTestId('otel-header-key')).toHaveValue('authorization');
492
+ // The blanked value renders empty with a hint instead of the placeholder text
493
+ expect(getByTestId('otel-header-value')).toHaveValue('');
494
+ expect(getByTestId('otel-header-value')).toHaveAttribute(
495
+ 'placeholder',
496
+ 'Configured value not shown',
497
+ );
498
+ // Field stays editable when not env-managed, so adding a header can replace the set
499
+ expect(getByTestId('otel-header-add')).toBeEnabled();
500
+ });
501
+
502
+ it('renders header keys with masked, disabled values when headers are env-managed', async () => {
503
+ getOtelSettingsMock.mockResolvedValue(
504
+ makeSettings({
505
+ exporterHeaders: 'authorization=__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6',
506
+ envManagedFields: ['exporterHeaders'],
507
+ }),
508
+ );
509
+
510
+ const { getAllByTestId, getByTestId } = render();
511
+
512
+ await waitFor(() => {
513
+ expect(getAllByTestId('otel-header-key')).toHaveLength(1);
514
+ });
515
+ expect(getByTestId('otel-header-key')).toHaveValue('authorization');
516
+ expect(getByTestId('otel-header-key')).toBeDisabled();
517
+ expect(getByTestId('otel-header-value')).toHaveValue('');
518
+ expect(getByTestId('otel-header-value')).toBeDisabled();
519
+ });
520
+
521
+ // ── exporter protocol ─────────────────────────────────────────────────────
522
+
523
+ it('shows the saved protocol in the select', async () => {
524
+ getOtelSettingsMock.mockResolvedValue(makeSettings({ exporterProtocol: 'grpc' }));
525
+
526
+ const { getByTestId } = render();
527
+ await waitFor(() => expect(getByTestId('otel-exporter-protocol')).toBeInTheDocument());
528
+
529
+ const input = getByTestId('otel-exporter-protocol').querySelector('input');
530
+ expect(input).toHaveValue('gRPC');
531
+ expect(input).toHaveAttribute('aria-label', 'Protocol');
532
+ });
533
+
534
+ it('hides the trace path row and shows gRPC endpoint copy when the protocol is gRPC', async () => {
535
+ getOtelSettingsMock.mockResolvedValue(makeSettings({ exporterProtocol: 'grpc' }));
536
+
537
+ const { getByTestId, queryByTestId } = render();
538
+ await waitFor(() => expect(getByTestId('otel-exporter-endpoint')).toBeInTheDocument());
539
+
540
+ expect(queryByTestId('otel-tracing-path')).not.toBeInTheDocument();
541
+ expect(getByTestId('otel-exporter-endpoint')).toHaveAttribute(
542
+ 'placeholder',
543
+ 'http://collector.example.com:4317',
544
+ );
545
+ });
546
+
547
+ it('hides the trace path row after switching the protocol to gRPC', async () => {
548
+ const { getByTestId, queryByTestId, store } = render();
549
+ await waitFor(() => expect(getByTestId('otel-tracing-path')).toBeInTheDocument());
550
+
551
+ await selectProtocol('gRPC');
552
+
553
+ await waitFor(() => {
554
+ expect(store.settings.exporterProtocol).toBe('grpc');
555
+ expect(queryByTestId('otel-tracing-path')).not.toBeInTheDocument();
556
+ });
557
+ });
558
+
559
+ it('keeps the trace path across a switch to gRPC and back', async () => {
560
+ getOtelSettingsMock.mockResolvedValue(makeSettings({ exporterTracingPath: '/custom/traces' }));
561
+
562
+ const { getByTestId, queryByTestId, store } = render();
563
+ await waitFor(() => expect(getByTestId('otel-tracing-path')).toBeInTheDocument());
564
+
565
+ await selectProtocol('gRPC');
566
+ await waitFor(() => expect(queryByTestId('otel-tracing-path')).not.toBeInTheDocument());
567
+
568
+ await selectProtocol('HTTP (protobuf)');
569
+
570
+ await waitFor(() => expect(getByTestId('otel-tracing-path')).toBeInTheDocument());
571
+ expect(getByTestId('otel-tracing-path')).toHaveValue('/custom/traces');
572
+ expect(store.settings.exporterTracingPath).toBe('/custom/traces');
573
+ });
574
+
575
+ it('saves the protocol picked in the select', async () => {
576
+ const { getByTestId } = render();
577
+ await waitFor(() => expect(getByTestId('otel-exporter-protocol')).toBeInTheDocument());
578
+
579
+ await selectProtocol('gRPC');
580
+
581
+ await waitFor(() => expect(getByTestId('settings-save-bar-save')).toBeInTheDocument());
582
+ await userEvent.click(getByTestId('settings-save-bar-save'));
583
+
584
+ await waitFor(() => {
585
+ expect(updateOtelSettingsMock).toHaveBeenCalledWith(
586
+ expect.anything(),
587
+ expect.objectContaining({ exporterProtocol: 'grpc' }),
588
+ );
589
+ });
590
+ });
591
+
592
+ it('disables the protocol select when it is managed by an env var', async () => {
593
+ getOtelSettingsMock.mockResolvedValue(makeSettings({ envManagedFields: ['exporterProtocol'] }));
594
+
595
+ const { getByTestId } = render();
596
+ await waitFor(() => expect(getByTestId('otel-exporter-protocol')).toBeInTheDocument());
597
+
598
+ expect(getByTestId('otel-exporter-protocol').querySelector('input')).toBeDisabled();
599
+ });
600
+
601
+ it('invalidates a previous test result when the protocol changes', async () => {
602
+ const { getByTestId, getByText, queryByText } = render();
603
+ await waitFor(() => expect(getByTestId('otel-test-trace-button')).toBeInTheDocument());
604
+
605
+ await userEvent.click(getByTestId('otel-test-trace-button'));
606
+ await waitFor(() => expect(getByText(/span sent to collector at/)).toBeInTheDocument());
607
+
608
+ await selectProtocol('gRPC');
609
+
610
+ await waitFor(() => {
611
+ expect(queryByText(/span sent to collector at/)).not.toBeInTheDocument();
612
+ });
613
+ });
614
+
466
615
  // ── test trace ────────────────────────────────────────────────────────────
467
616
 
468
617
  it('renders the test trace button after fetch', async () => {
@@ -11,6 +11,8 @@ import {
11
11
  N8nIcon,
12
12
  N8nInput,
13
13
  N8nInputLabel,
14
+ N8nOption,
15
+ N8nSelect,
14
16
  N8nSettingsLayout,
15
17
  N8nSettingsPageHeader,
16
18
  N8nSettingsRowGroup,
@@ -22,9 +24,13 @@ import { useSettingsStore } from '@n8n/stores/settings.store';
22
24
  import { computed, ref, watch, onMounted } from 'vue';
23
25
  import { onBeforeRouteLeave, type NavigationGuardNext } from 'vue-router';
24
26
 
25
- import { OTEL_FIELD_ENV_VARS, OTEL_TEST_SPAN_NAME } from './otel.constants';
27
+ import {
28
+ CREDENTIALS_BLANKING_VALUE,
29
+ OTEL_FIELD_ENV_VARS,
30
+ OTEL_TEST_SPAN_NAME,
31
+ } from './otel.constants';
26
32
  import { useOtelStore, headersStringToPairs, headersPairsToString } from './otel.store';
27
- import { createSampleRateFormat } from './otel.utils';
33
+ import { createSampleRateFormat, isOtlpProtocol } from './otel.utils';
28
34
  import OtelSettingsRow from './OtelSettingsRow.vue';
29
35
  import OtelStatusControl from './OtelStatusControl.vue';
30
36
 
@@ -47,7 +53,7 @@ const showUnsavedChangesDialog = ref(false);
47
53
  const pendingNext = ref<NavigationGuardNext | null>(null);
48
54
 
49
55
  function syncHeaderPairsFromStore() {
50
- headerPairs.value = headersStringToPairs(otelStore.settings.exporterHeaders);
56
+ headerPairs.value = headerPairsFromStore();
51
57
  }
52
58
 
53
59
  function syncHeaderPairsToStore() {
@@ -75,6 +81,23 @@ function isEnvManaged(field: keyof typeof OTEL_FIELD_ENV_VARS): boolean {
75
81
  return otelStore.envManagedFields.includes(field);
76
82
  }
77
83
 
84
+ // Keys come back from the API as-is; values come back blanked, so the value
85
+ // inputs below render them empty until the user types a replacement. The pair
86
+ // keeps the blanked value in state, so an untouched save preserves the stored one.
87
+ function headerPairsFromStore(): Array<{ key: string; value: string }> {
88
+ return headersStringToPairs(otelStore.settings.exporterHeaders ?? '');
89
+ }
90
+
91
+ function headerValueDisplay(pair: { key: string; value: string }): string {
92
+ return pair.value === CREDENTIALS_BLANKING_VALUE ? '' : pair.value;
93
+ }
94
+
95
+ function headerValuePlaceholder(pair: { key: string; value: string }): string {
96
+ return pair.value === CREDENTIALS_BLANKING_VALUE
97
+ ? i18n.baseText('settings.opentelemetry.exporterHeaders.hiddenValuePlaceholder')
98
+ : i18n.baseText('settings.opentelemetry.exporterHeaders.valuePlaceholder');
99
+ }
100
+
78
101
  // State-first copy: the row tells the admin whether tracing is live right now,
79
102
  // instead of describing what the disabled state would mean hypothetically.
80
103
  const statusDescription = computed(() =>
@@ -83,6 +106,38 @@ const statusDescription = computed(() =>
83
106
  : i18n.baseText('settings.opentelemetry.status.disabledDescription'),
84
107
  );
85
108
 
109
+ const isGrpc = computed(() => otelStore.settings.exporterProtocol === 'grpc');
110
+
111
+ const endpointDescription = computed(() =>
112
+ i18n.baseText(
113
+ isGrpc.value
114
+ ? 'settings.opentelemetry.exporterEndpoint.grpcDescription'
115
+ : 'settings.opentelemetry.exporterEndpoint.description',
116
+ ),
117
+ );
118
+
119
+ const endpointPlaceholder = computed(() =>
120
+ i18n.baseText(
121
+ isGrpc.value
122
+ ? 'settings.opentelemetry.exporterEndpoint.grpcPlaceholder'
123
+ : 'settings.opentelemetry.exporterEndpoint.placeholder',
124
+ ),
125
+ );
126
+
127
+ const headersDescription = computed(() =>
128
+ i18n.baseText(
129
+ isGrpc.value
130
+ ? 'settings.opentelemetry.exporterHeaders.grpcDescription'
131
+ : 'settings.opentelemetry.exporterHeaders.description',
132
+ ),
133
+ );
134
+
135
+ function onProtocolChange(value: unknown) {
136
+ if (isOtlpProtocol(value)) {
137
+ otelStore.settings.exporterProtocol = value;
138
+ }
139
+ }
140
+
86
141
  function envTooltip(field: keyof typeof OTEL_FIELD_ENV_VARS): string {
87
142
  const envVariable = i18n.baseText('settings.opentelemetry.envVarTooltip', {
88
143
  interpolate: { envVar: OTEL_FIELD_ENV_VARS[field] },
@@ -101,6 +156,7 @@ async function save(): Promise<boolean> {
101
156
 
102
157
  if (!wasEnabled && isNowEnabled) {
103
158
  telemetry.track('Activated otel via UI', {
159
+ protocol: otelStore.settings.exporterProtocol,
104
160
  includeNodeSpans: otelStore.settings.includeNodeSpans,
105
161
  productionExecutionsOnly: otelStore.settings.productionExecutionsOnly,
106
162
  tracesSampleRate: otelStore.settings.tracesSampleRate,
@@ -111,6 +167,7 @@ async function save(): Promise<boolean> {
111
167
  } else {
112
168
  telemetry.track('Updated otel via UI', {
113
169
  enabled: isNowEnabled,
170
+ protocol: otelStore.settings.exporterProtocol,
114
171
  includeNodeSpans: otelStore.settings.includeNodeSpans,
115
172
  productionExecutionsOnly: otelStore.settings.productionExecutionsOnly,
116
173
  tracesSampleRate: otelStore.settings.tracesSampleRate,
@@ -198,7 +255,7 @@ watch(
198
255
  (newVal) => {
199
256
  const currentString = headersPairsToString(headerPairs.value);
200
257
  if (newVal !== currentString) {
201
- headerPairs.value = headersStringToPairs(newVal ?? '');
258
+ headerPairs.value = headerPairsFromStore();
202
259
  }
203
260
  },
204
261
  );
@@ -323,6 +380,7 @@ async function onSendTestTrace() {
323
380
  // Connection changes invalidate the previous test result
324
381
  watch(
325
382
  () => [
383
+ otelStore.settings.exporterProtocol,
326
384
  otelStore.settings.exporterEndpoint,
327
385
  otelStore.settings.exporterTracingPath,
328
386
  otelStore.settings.exporterServiceName,
@@ -373,9 +431,38 @@ watch(
373
431
  :title="i18n.baseText('settings.opentelemetry.collectorConnection.title')"
374
432
  >
375
433
  <N8nSettingsRowGroup>
434
+ <OtelSettingsRow
435
+ :title="i18n.baseText('settings.opentelemetry.exporterProtocol.label')"
436
+ :description="i18n.baseText('settings.opentelemetry.exporterProtocol.description')"
437
+ :env-tooltip="envTooltip('exporterProtocol')"
438
+ action-fill
439
+ >
440
+ <template #action>
441
+ <N8nSelect
442
+ :class="$style.control"
443
+ :model-value="otelStore.settings.exporterProtocol"
444
+ :disabled="isEnvManaged('exporterProtocol')"
445
+ :aria-label="i18n.baseText('settings.opentelemetry.exporterProtocol.label')"
446
+ data-test-id="otel-exporter-protocol"
447
+ @update:model-value="onProtocolChange"
448
+ >
449
+ <N8nOption
450
+ value="http/protobuf"
451
+ :label="
452
+ i18n.baseText('settings.opentelemetry.exporterProtocol.option.httpProtobuf')
453
+ "
454
+ />
455
+ <N8nOption
456
+ value="grpc"
457
+ :label="i18n.baseText('settings.opentelemetry.exporterProtocol.option.grpc')"
458
+ />
459
+ </N8nSelect>
460
+ </template>
461
+ </OtelSettingsRow>
462
+
376
463
  <OtelSettingsRow
377
464
  :title="i18n.baseText('settings.opentelemetry.exporterEndpoint.label')"
378
- :description="i18n.baseText('settings.opentelemetry.exporterEndpoint.description')"
465
+ :description="endpointDescription"
379
466
  :env-tooltip="envTooltip('exporterEndpoint')"
380
467
  action-fill
381
468
  >
@@ -383,7 +470,7 @@ watch(
383
470
  <N8nInput
384
471
  v-model="otelStore.settings.exporterEndpoint"
385
472
  :class="$style.control"
386
- :placeholder="i18n.baseText('settings.opentelemetry.exporterEndpoint.placeholder')"
473
+ :placeholder="endpointPlaceholder"
387
474
  :disabled="isEnvManaged('exporterEndpoint')"
388
475
  data-test-id="otel-exporter-endpoint"
389
476
  />
@@ -411,7 +498,7 @@ watch(
411
498
 
412
499
  <OtelSettingsRow
413
500
  :title="i18n.baseText('settings.opentelemetry.exporterHeaders.label')"
414
- :description="i18n.baseText('settings.opentelemetry.exporterHeaders.description')"
501
+ :description="headersDescription"
415
502
  :env-tooltip="envTooltip('exporterHeaders')"
416
503
  layout="vertical"
417
504
  action-fill
@@ -447,10 +534,8 @@ watch(
447
534
  size="small"
448
535
  >
449
536
  <N8nInput
450
- :model-value="pair.value"
451
- :placeholder="
452
- i18n.baseText('settings.opentelemetry.exporterHeaders.valuePlaceholder')
453
- "
537
+ :model-value="headerValueDisplay(pair)"
538
+ :placeholder="headerValuePlaceholder(pair)"
454
539
  :disabled="isEnvManaged('exporterHeaders')"
455
540
  data-test-id="otel-header-value"
456
541
  @update:model-value="(v: string) => onHeaderChange(index, 'value', v)"
@@ -486,6 +571,7 @@ watch(
486
571
  </OtelSettingsRow>
487
572
 
488
573
  <OtelSettingsRow
574
+ v-if="!isGrpc"
489
575
  :title="i18n.baseText('settings.opentelemetry.exporterTracingPath.label')"
490
576
  :description="i18n.baseText('settings.opentelemetry.exporterTracingPath.description')"
491
577
  :env-tooltip="envTooltip('exporterTracingPath')"
@@ -14,6 +14,7 @@ const context: IRestApiContext = { baseUrl: 'http://localhost:5678', pushRef: ''
14
14
 
15
15
  const makeSettings = (overrides: Partial<OtelSettingsResponse> = {}): OtelSettingsResponse => ({
16
16
  enabled: false,
17
+ exporterProtocol: 'http/protobuf',
17
18
  exporterEndpoint: 'http://localhost:4318',
18
19
  exporterTracingPath: '/v1/traces',
19
20
  exporterServiceName: 'n8n',
@@ -59,6 +60,7 @@ describe('otel.api', () => {
59
60
 
60
61
  describe('sendOtelTestTrace', () => {
61
62
  const connection: OtelTestConnection = {
63
+ exporterProtocol: 'grpc',
62
64
  exporterEndpoint: 'https://collector.io',
63
65
  exporterTracingPath: '/v1/traces',
64
66
  exporterServiceName: 'n8n',
package/src/otel.api.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { IRestApiContext } from '@n8n/rest-api-client';
2
2
  import { makeRestApiRequest } from '@n8n/rest-api-client';
3
3
 
4
+ import type { OtlpProtocol } from './otel.constants';
5
+
4
6
  export type OtelSettings = {
5
7
  enabled: boolean;
8
+ exporterProtocol: OtlpProtocol;
6
9
  exporterEndpoint: string;
7
10
  exporterTracingPath: string;
8
11
  exporterServiceName: string;
@@ -31,6 +34,7 @@ export async function updateOtelSettings(
31
34
 
32
35
  export type OtelTestConnection = Pick<
33
36
  OtelSettings,
37
+ | 'exporterProtocol'
34
38
  | 'exporterEndpoint'
35
39
  | 'exporterTracingPath'
36
40
  | 'exporterServiceName'
@@ -1,5 +1,16 @@
1
+ import { OTLP_PROTOCOLS, type OtlpProtocol } from '@n8n/api-types';
2
+
3
+ export { OTLP_PROTOCOLS, type OtlpProtocol };
4
+
1
5
  export const OTEL_STORE = 'otel';
2
6
 
7
+ /**
8
+ * Mirrors CREDENTIAL_BLANKING_VALUE from n8n-workflow (not a dependency of this
9
+ * module) — the placeholder the API returns in place of each header value.
10
+ * Keys come back as-is; only values are blanked.
11
+ */
12
+ export const CREDENTIALS_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6';
13
+
3
14
  /**
4
15
  * Route name for the OpenTelemetry settings page. Owned by this module rather
5
16
  * than by the shared `VIEWS` enum, so the module can be packaged without the
@@ -14,6 +25,7 @@ export const OTEL_TEST_SPAN_NAME = 'n8n.test_trace';
14
25
  /** Maps each settings field to its env-var name — shown in per-field tooltips. */
15
26
  export const OTEL_FIELD_ENV_VARS = {
16
27
  enabled: 'N8N_OTEL_ENABLED',
28
+ exporterProtocol: 'N8N_OTEL_EXPORTER_OTLP_PROTOCOL',
17
29
  exporterEndpoint: 'N8N_OTEL_EXPORTER_OTLP_ENDPOINT',
18
30
  exporterTracingPath: 'N8N_OTEL_EXPORTER_OTLP_TRACING_PATH',
19
31
  exporterServiceName: 'N8N_OTEL_EXPORTER_SERVICE_NAME',
@@ -1,3 +1,4 @@
1
+ import { i18nInstance, setLanguage } from '@n8n/i18n';
1
2
  import type { Scope } from '@n8n/permissions';
2
3
  import { useRBACStore } from '@n8n/stores/rbac.store';
3
4
  import { createPinia, setActivePinia } from 'pinia';
@@ -51,6 +52,44 @@ describe('OtelModule', () => {
51
52
  });
52
53
  });
53
54
 
55
+ describe('label', () => {
56
+ const OTEL_LABEL_KEY = 'settings.opentelemetry';
57
+
58
+ afterEach(() => {
59
+ setLanguage('en');
60
+ });
61
+
62
+ it('should read the label from i18n', () => {
63
+ expect(settingsPage()?.label).toBe('OpenTelemetry');
64
+ });
65
+
66
+ it('should follow a language change after registration', () => {
67
+ const item = settingsPage();
68
+ expect(item?.label).toBe('OpenTelemetry');
69
+
70
+ // `Object.fromEntries`, because a dotted i18n key is not a lintable
71
+ // object-literal property name.
72
+ i18nInstance.global.mergeLocaleMessage(
73
+ 'de',
74
+ Object.fromEntries([[OTEL_LABEL_KEY, 'OpenTelemetrie']]),
75
+ );
76
+ setLanguage('de');
77
+
78
+ expect(item?.label).toBe('OpenTelemetrie');
79
+ });
80
+
81
+ it('should read i18n lazily, not when the descriptor is imported', () => {
82
+ // The descriptor is imported by the shell manifest at boot, before `App.vue`
83
+ // calls `setLanguage`. A resolved string here would freeze the boot locale,
84
+ // and would put an i18n read at module scope — which the import-light rule
85
+ // for `*.module.ts` bans.
86
+ const descriptor = Object.getOwnPropertyDescriptor(settingsPage(), 'label');
87
+
88
+ expect(typeof descriptor?.get).toBe('function');
89
+ expect(descriptor?.value).toBeUndefined();
90
+ });
91
+ });
92
+
54
93
  describe('route', () => {
55
94
  it('should keep routing to the unchanged SettingsOpenTelemetryView route name', () => {
56
95
  expect(OTEL_SETTINGS_VIEW).toBe('SettingsOpenTelemetryView');
@@ -4,8 +4,6 @@ import { useRBACStore } from '@n8n/stores/rbac.store';
4
4
 
5
5
  import { OTEL_SETTINGS_VIEW } from './otel.constants';
6
6
 
7
- const i18n = useI18n();
8
-
9
7
  // typescript-eslint reads an SFC import as `any`, because only vue-tsc can type one.
10
8
  // `pnpm turbo typecheck` is what checks this component for real.
11
9
  // eslint-disable-next-line @typescript-eslint/no-unsafe-return
@@ -39,11 +37,11 @@ export const OtelModule: FrontendModuleDescription = {
39
37
  {
40
38
  id: 'settings-opentelemetry',
41
39
  icon: 'telescope',
42
- label: i18n.baseText('settings.opentelemetry'),
40
+ get label() {
41
+ return useI18n().baseText('settings.opentelemetry');
42
+ },
43
43
  position: 'top',
44
44
  route: { to: { name: OTEL_SETTINGS_VIEW } },
45
- // Getter, not a value: the item is registered once at init, but the
46
- // scope check must re-run whenever the sidebar recomputes.
47
45
  get available() {
48
46
  return useRBACStore().hasScope('otel:manage');
49
47
  },
@@ -22,6 +22,7 @@ const testTraceMock = vi.mocked(otelApi.sendOtelTestTrace);
22
22
 
23
23
  const makeSettings = (overrides: Partial<OtelSettingsResponse> = {}): OtelSettingsResponse => ({
24
24
  enabled: false,
25
+ exporterProtocol: 'http/protobuf',
25
26
  exporterEndpoint: 'http://localhost:4318',
26
27
  exporterTracingPath: '/v1/traces',
27
28
  exporterServiceName: 'n8n',
@@ -287,6 +288,7 @@ describe('useOtelStore', () => {
287
288
  it('sends only the connection fields from the current settings', async () => {
288
289
  fetchMock.mockResolvedValueOnce(
289
290
  makeSettings({
291
+ exporterProtocol: 'grpc',
290
292
  exporterEndpoint: 'https://collector.io',
291
293
  exporterTracingPath: '/custom',
292
294
  exporterServiceName: 'n8n-prod',
@@ -301,6 +303,7 @@ describe('useOtelStore', () => {
301
303
  await store.sendTestTrace();
302
304
 
303
305
  expect(testTraceMock).toHaveBeenCalledWith(expect.anything(), {
306
+ exporterProtocol: 'grpc',
304
307
  exporterEndpoint: 'https://collector.io',
305
308
  exporterTracingPath: '/custom',
306
309
  exporterServiceName: 'n8n-prod',
package/src/otel.store.ts CHANGED
@@ -29,6 +29,7 @@ export function headersPairsToString(pairs: Array<{ key: string; value: string }
29
29
 
30
30
  const defaultSettings: OtelSettings = {
31
31
  enabled: false,
32
+ exporterProtocol: 'http/protobuf',
32
33
  exporterEndpoint: 'http://localhost:4318',
33
34
  exporterTracingPath: '/v1/traces',
34
35
  exporterServiceName: 'n8n',
@@ -105,6 +106,7 @@ export const useOtelStore = defineStore(OTEL_STORE, () => {
105
106
  testError.value = '';
106
107
  try {
107
108
  const result = await sendOtelTestTrace(rootStore.restApiContext, {
109
+ exporterProtocol: settings.value.exporterProtocol,
108
110
  exporterEndpoint: settings.value.exporterEndpoint,
109
111
  exporterTracingPath: settings.value.exporterTracingPath,
110
112
  exporterServiceName: settings.value.exporterServiceName,
@@ -1,4 +1,17 @@
1
- import { createSampleRateFormat } from './otel.utils';
1
+ import { createSampleRateFormat, isOtlpProtocol } from './otel.utils';
2
+
3
+ describe('isOtlpProtocol', () => {
4
+ it.each(['http/protobuf', 'grpc'])('accepts the supported protocol %s', (value) => {
5
+ expect(isOtlpProtocol(value)).toBe(true);
6
+ });
7
+
8
+ it.each(['http/json', 'HTTP/PROTOBUF', '', undefined, null, 1])(
9
+ 'rejects %s',
10
+ (value: unknown) => {
11
+ expect(isOtlpProtocol(value)).toBe(false);
12
+ },
13
+ );
14
+ });
2
15
 
3
16
  describe('createSampleRateFormat', () => {
4
17
  describe.each(['en-US', 'de-DE', 'fr-FR', 'ar-EG', 'fa-IR'])('locale %s', (locale) => {
package/src/otel.utils.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import { OTLP_PROTOCOLS, type OtlpProtocol } from './otel.constants';
2
+
3
+ export function isOtlpProtocol(value: unknown): value is OtlpProtocol {
4
+ return OTLP_PROTOCOLS.some((protocol) => protocol === value);
5
+ }
6
+
1
7
  /**
2
8
  * Locale-aware formatting and parsing for the traces sample rate (0..1).
3
9
  *