@n8n/frontend-module-otel 0.2.1 → 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 +18 -17
- package/src/SettingsOpenTelemetryView.test.ts +151 -2
- package/src/SettingsOpenTelemetryView.vue +97 -11
- package/src/otel.api.test.ts +2 -0
- package/src/otel.api.ts +4 -0
- package/src/otel.constants.ts +12 -0
- package/src/otel.module.test.ts +39 -0
- package/src/otel.module.ts +3 -5
- package/src/otel.store.test.ts +3 -0
- package/src/otel.store.ts +2 -0
- package/src/otel.utils.test.ts +14 -1
- package/src/otel.utils.ts +6 -0
package/package.json
CHANGED
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@n8n/frontend-module-otel",
|
|
3
|
-
"version": "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/design-system": "2.37.1",
|
|
14
|
-
"@n8n/composables": "1.28.1",
|
|
15
|
-
"@n8n/frontend-module-sdk": "0.8.1",
|
|
16
|
-
"@n8n/rest-api-client": "2.38.1",
|
|
17
|
-
"@n8n/i18n": "2.38.1",
|
|
18
|
-
"@n8n/stores": "2.38.1"
|
|
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/eslint-config": "0.0.1",
|
|
36
|
-
"@n8n/frontend-vite-config": "0.1.0",
|
|
37
|
-
"@n8n/frontend-test-utils": "0.1.0",
|
|
38
|
-
"@n8n/permissions": "0.75.1",
|
|
39
|
-
"@n8n/stylelint-config": "0.0.1",
|
|
40
|
-
"@n8n/vitest-config": "1.21.0",
|
|
41
|
-
"@n8n/typescript-config": "1.11.0",
|
|
42
|
-
"@n8n/playwright-janitor": "0.1.0"
|
|
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({
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
|
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')"
|
package/src/otel.api.test.ts
CHANGED
|
@@ -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'
|
package/src/otel.constants.ts
CHANGED
|
@@ -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',
|
package/src/otel.module.test.ts
CHANGED
|
@@ -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');
|
package/src/otel.module.ts
CHANGED
|
@@ -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
|
|
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
|
},
|
package/src/otel.store.test.ts
CHANGED
|
@@ -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,
|
package/src/otel.utils.test.ts
CHANGED
|
@@ -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
|
*
|