@n8n/frontend-module-otel 0.1.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.
@@ -0,0 +1,791 @@
1
+ <script setup lang="ts">
2
+ import { useDocumentTitle } from '@n8n/composables/useDocumentTitle';
3
+ import { useTelemetry } from '@n8n/composables/useTelemetry';
4
+ import { useToast } from '@n8n/composables/useToast';
5
+ import {
6
+ N8nButton,
7
+ N8nCheckbox,
8
+ N8nDialog,
9
+ N8nDialogClose,
10
+ N8nDialogFooter,
11
+ N8nIcon,
12
+ N8nInput,
13
+ N8nInputLabel,
14
+ N8nSettingsLayout,
15
+ N8nSettingsPageHeader,
16
+ N8nSettingsRowGroup,
17
+ N8nSettingsSaveBar,
18
+ N8nSettingsSection,
19
+ } from '@n8n/design-system';
20
+ import { useI18n } from '@n8n/i18n';
21
+ import { useSettingsStore } from '@n8n/stores/settings.store';
22
+ import { computed, ref, watch, onMounted } from 'vue';
23
+ import { onBeforeRouteLeave, type NavigationGuardNext } from 'vue-router';
24
+
25
+ import { OTEL_FIELD_ENV_VARS, OTEL_TEST_SPAN_NAME } from './otel.constants';
26
+ import { useOtelStore, headersStringToPairs, headersPairsToString } from './otel.store';
27
+ import { createSampleRateFormat } from './otel.utils';
28
+ import OtelSettingsRow from './OtelSettingsRow.vue';
29
+ import OtelStatusControl from './OtelStatusControl.vue';
30
+
31
+ const OTEL_DOCS_URL = 'https://docs.n8n.io/hosting/logging-monitoring/opentelemetry/';
32
+
33
+ const i18n = useI18n();
34
+ const telemetry = useTelemetry();
35
+ const toast = useToast();
36
+ // The shell's wrapper adds a claim guard for `setDocumentTitle`, which only the
37
+ // canvas calls. This view calls `set`, so it uses the platform composable directly
38
+ // and passes the release channel the wrapper would have supplied.
39
+ const documentTitle = useDocumentTitle({
40
+ releaseChannel: useSettingsStore().settings.releaseChannel,
41
+ });
42
+ const otelStore = useOtelStore();
43
+
44
+ const headerPairs = ref<Array<{ key: string; value: string }>>([]);
45
+
46
+ const showUnsavedChangesDialog = ref(false);
47
+ const pendingNext = ref<NavigationGuardNext | null>(null);
48
+
49
+ function syncHeaderPairsFromStore() {
50
+ headerPairs.value = headersStringToPairs(otelStore.settings.exporterHeaders);
51
+ }
52
+
53
+ function syncHeaderPairsToStore() {
54
+ otelStore.settings.exporterHeaders = headersPairsToString(headerPairs.value);
55
+ }
56
+
57
+ function addHeader() {
58
+ headerPairs.value.push({ key: '', value: '' });
59
+ syncHeaderPairsToStore();
60
+ }
61
+
62
+ function removeHeader(index: number) {
63
+ headerPairs.value.splice(index, 1);
64
+ syncHeaderPairsToStore();
65
+ }
66
+
67
+ function onHeaderChange(index: number, field: 'key' | 'value', value: string) {
68
+ headerPairs.value = headerPairs.value.map((pair, i) =>
69
+ i === index ? { ...pair, [field]: value } : pair,
70
+ );
71
+ syncHeaderPairsToStore();
72
+ }
73
+
74
+ function isEnvManaged(field: keyof typeof OTEL_FIELD_ENV_VARS): boolean {
75
+ return otelStore.envManagedFields.includes(field);
76
+ }
77
+
78
+ // State-first copy: the row tells the admin whether tracing is live right now,
79
+ // instead of describing what the disabled state would mean hypothetically.
80
+ const statusDescription = computed(() =>
81
+ otelStore.settings.enabled
82
+ ? i18n.baseText('settings.opentelemetry.status.enabledDescription')
83
+ : i18n.baseText('settings.opentelemetry.status.disabledDescription'),
84
+ );
85
+
86
+ function envTooltip(field: keyof typeof OTEL_FIELD_ENV_VARS): string {
87
+ const envVariable = i18n.baseText('settings.opentelemetry.envVarTooltip', {
88
+ interpolate: { envVar: OTEL_FIELD_ENV_VARS[field] },
89
+ });
90
+
91
+ return isEnvManaged(field)
92
+ ? `${i18n.baseText('settings.opentelemetry.envVarManagedTooltip')}. ${envVariable}`
93
+ : envVariable;
94
+ }
95
+
96
+ async function save(): Promise<boolean> {
97
+ try {
98
+ const wasEnabled = otelStore.savedSettings.enabled;
99
+ await otelStore.saveSettings();
100
+ const isNowEnabled = otelStore.settings.enabled;
101
+
102
+ if (!wasEnabled && isNowEnabled) {
103
+ telemetry.track('Activated otel via UI', {
104
+ includeNodeSpans: otelStore.settings.includeNodeSpans,
105
+ productionExecutionsOnly: otelStore.settings.productionExecutionsOnly,
106
+ tracesSampleRate: otelStore.settings.tracesSampleRate,
107
+ injectOutbound: otelStore.settings.injectOutbound,
108
+ });
109
+ } else if (wasEnabled && !isNowEnabled) {
110
+ telemetry.track('Disabled otel via UI');
111
+ } else {
112
+ telemetry.track('Updated otel via UI', {
113
+ enabled: isNowEnabled,
114
+ includeNodeSpans: otelStore.settings.includeNodeSpans,
115
+ productionExecutionsOnly: otelStore.settings.productionExecutionsOnly,
116
+ tracesSampleRate: otelStore.settings.tracesSampleRate,
117
+ injectOutbound: otelStore.settings.injectOutbound,
118
+ });
119
+ }
120
+
121
+ toast.showMessage({
122
+ title: i18n.baseText('settings.opentelemetry.savedSuccess'),
123
+ type: 'success',
124
+ });
125
+
126
+ return true;
127
+ } catch (error) {
128
+ toast.showError(error, i18n.baseText('settings.opentelemetry.savedError'));
129
+ return false;
130
+ }
131
+ }
132
+
133
+ function discard() {
134
+ otelStore.discardChanges();
135
+ syncHeaderPairsFromStore();
136
+ }
137
+
138
+ const statusSaving = ref(false);
139
+
140
+ /*
141
+ * Enabling/disabling is a live status change, not a form draft: it applies
142
+ * immediately by committing the current on-screen draft with the flag set.
143
+ * Saving only the flag would silently enable with stale, previously-saved
144
+ * config while the screen shows unsaved edits.
145
+ */
146
+ async function onToggleEnabled(enabled: boolean) {
147
+ otelStore.settings.enabled = enabled;
148
+ statusSaving.value = true;
149
+ try {
150
+ const saved = await save();
151
+ if (!saved) {
152
+ otelStore.settings.enabled = !enabled;
153
+ }
154
+ } finally {
155
+ statusSaving.value = false;
156
+ }
157
+ }
158
+
159
+ function onLeaveWithoutSaving() {
160
+ showUnsavedChangesDialog.value = false;
161
+ pendingNext.value?.();
162
+ pendingNext.value = null;
163
+ }
164
+
165
+ async function onSaveAndLeave() {
166
+ const saved = await save();
167
+ if (!saved) return;
168
+ showUnsavedChangesDialog.value = false;
169
+ pendingNext.value?.();
170
+ pendingNext.value = null;
171
+ }
172
+
173
+ function onKeepEditing() {
174
+ showUnsavedChangesDialog.value = false;
175
+ pendingNext.value?.(false);
176
+ pendingNext.value = null;
177
+ }
178
+
179
+ onBeforeRouteLeave((_to, _from, next) => {
180
+ if (!otelStore.isDirty) {
181
+ next();
182
+ return;
183
+ }
184
+ pendingNext.value = next;
185
+ showUnsavedChangesDialog.value = true;
186
+ });
187
+
188
+ onMounted(async () => {
189
+ documentTitle.set(i18n.baseText('settings.opentelemetry.title'));
190
+ await otelStore.fetchSettings();
191
+ syncHeaderPairsFromStore();
192
+ syncSampleRateInput();
193
+ syncConnectivityTimeoutInput();
194
+ });
195
+
196
+ watch(
197
+ () => otelStore.settings?.exporterHeaders,
198
+ (newVal) => {
199
+ const currentString = headersPairsToString(headerPairs.value);
200
+ if (newVal !== currentString) {
201
+ headerPairs.value = headersStringToPairs(newVal ?? '');
202
+ }
203
+ },
204
+ );
205
+
206
+ const canTestTrace = computed(
207
+ () => !!otelStore.settings.exporterEndpoint && otelStore.testState !== 'sending',
208
+ );
209
+
210
+ /*
211
+ * The sample rate is a text input formatted by us, not a native number input:
212
+ * native number inputs render their value with the OS-region decimal separator,
213
+ * which JS can neither read nor override, so the "of 1.00" copy next to it
214
+ * could never be guaranteed to match. Formatting both the input display and the
215
+ * copy through the same Intl formatter makes them consistent by construction.
216
+ */
217
+ const { format: formatSampleRate, parse: parseSampleRate } = createSampleRateFormat();
218
+ const sampleRateMax = formatSampleRate(1);
219
+
220
+ const sampleRateInput = ref('');
221
+ const connectivityTimeoutInput = ref('');
222
+
223
+ function syncSampleRateInput() {
224
+ sampleRateInput.value = formatSampleRate(otelStore.settings.tracesSampleRate);
225
+ }
226
+
227
+ function syncConnectivityTimeoutInput() {
228
+ connectivityTimeoutInput.value = String(otelStore.settings.startupConnectivityTimeoutMs);
229
+ }
230
+
231
+ function parseConnectivityTimeout(text: string): number | null {
232
+ const trimmed = text.trim();
233
+ if (!trimmed) return null;
234
+ const parsed = Number(trimmed);
235
+ return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : null;
236
+ }
237
+
238
+ function commitSampleRate() {
239
+ const parsed = parseSampleRate(sampleRateInput.value);
240
+ if (parsed !== null) {
241
+ otelStore.settings.tracesSampleRate = parsed;
242
+ }
243
+ syncSampleRateInput();
244
+ }
245
+
246
+ function commitConnectivityTimeout() {
247
+ const parsed = parseConnectivityTimeout(connectivityTimeoutInput.value);
248
+ if (parsed !== null) {
249
+ otelStore.settings.startupConnectivityTimeoutMs = parsed;
250
+ }
251
+ syncConnectivityTimeoutInput();
252
+ }
253
+
254
+ // Arrow-key stepping, mirroring the native number-input affordance these text inputs replaced
255
+ // (steps match the previous N8nInputNumber config: 0.01 for the rate, 100ms for the timeout).
256
+ function stepSampleRate(direction: 1 | -1) {
257
+ const current = parseSampleRate(sampleRateInput.value) ?? otelStore.settings.tracesSampleRate;
258
+ const next = Math.min(1, Math.max(0, Math.round((current + direction * 0.01) * 100) / 100));
259
+ otelStore.settings.tracesSampleRate = next;
260
+ syncSampleRateInput();
261
+ }
262
+
263
+ function stepConnectivityTimeout(direction: 1 | -1) {
264
+ const current =
265
+ parseConnectivityTimeout(connectivityTimeoutInput.value) ??
266
+ otelStore.settings.startupConnectivityTimeoutMs;
267
+ otelStore.settings.startupConnectivityTimeoutMs = Math.max(0, current + direction * 100);
268
+ syncConnectivityTimeoutInput();
269
+ }
270
+
271
+ // Commit parseable drafts to the store as the user types, so dirty state (and the save bar)
272
+ // reacts live like every other field. The display is NOT reformatted here — that would fight
273
+ // the caret mid-edit; blur/Enter does the reformat.
274
+ watch(sampleRateInput, (text) => {
275
+ const parsed = parseSampleRate(text);
276
+ if (parsed !== null) {
277
+ otelStore.settings.tracesSampleRate = parsed;
278
+ }
279
+ });
280
+ watch(connectivityTimeoutInput, (text) => {
281
+ const parsed = parseConnectivityTimeout(text);
282
+ if (parsed !== null) {
283
+ otelStore.settings.startupConnectivityTimeoutMs = parsed;
284
+ }
285
+ });
286
+
287
+ // Keep the formatted display in sync when the store changes underneath (discard, save
288
+ // response, env-managed refresh) — but not when the change came from the draft being typed
289
+ // above, which would clobber the caret with a reformat on every keystroke.
290
+ watch(
291
+ () => otelStore.settings.tracesSampleRate,
292
+ (value) => {
293
+ if (parseSampleRate(sampleRateInput.value) !== value) syncSampleRateInput();
294
+ },
295
+ );
296
+ watch(
297
+ () => otelStore.settings.startupConnectivityTimeoutMs,
298
+ (value) => {
299
+ if (parseConnectivityTimeout(connectivityTimeoutInput.value) !== value) {
300
+ syncConnectivityTimeoutInput();
301
+ }
302
+ },
303
+ );
304
+
305
+ const testTraceSubtitle = computed(() => {
306
+ if (otelStore.testState === 'sent') {
307
+ return i18n.baseText('settings.opentelemetry.testTrace.success', {
308
+ interpolate: { spanName: OTEL_TEST_SPAN_NAME, time: otelStore.testTimestamp },
309
+ });
310
+ }
311
+ if (otelStore.testState === 'error') {
312
+ return i18n.baseText('settings.opentelemetry.testTrace.error', {
313
+ interpolate: { error: otelStore.testError },
314
+ });
315
+ }
316
+ return i18n.baseText('settings.opentelemetry.testTrace.description');
317
+ });
318
+
319
+ async function onSendTestTrace() {
320
+ await otelStore.sendTestTrace();
321
+ }
322
+
323
+ // Connection changes invalidate the previous test result
324
+ watch(
325
+ () => [
326
+ otelStore.settings.exporterEndpoint,
327
+ otelStore.settings.exporterTracingPath,
328
+ otelStore.settings.exporterServiceName,
329
+ otelStore.settings.exporterHeaders,
330
+ otelStore.settings.startupConnectivityTimeoutMs,
331
+ ],
332
+ () => {
333
+ if (otelStore.testState !== 'idle') {
334
+ otelStore.resetTestState();
335
+ }
336
+ },
337
+ );
338
+ </script>
339
+
340
+ <template>
341
+ <N8nSettingsLayout :class="$style.layout">
342
+ <N8nSettingsPageHeader
343
+ :title="i18n.baseText('settings.opentelemetry.title')"
344
+ :description="i18n.baseText('settings.opentelemetry.description')"
345
+ :docs-url="OTEL_DOCS_URL"
346
+ />
347
+
348
+ <div v-if="otelStore.loading" :class="$style.loading" data-test-id="otel-loading">
349
+ <N8nIcon icon="spinner" spin />
350
+ </div>
351
+
352
+ <div v-else :class="$style.settingsContent">
353
+ <N8nSettingsSection>
354
+ <N8nSettingsRowGroup>
355
+ <OtelSettingsRow
356
+ :title="i18n.baseText('settings.opentelemetry.status.label')"
357
+ :description="statusDescription"
358
+ :env-tooltip="envTooltip('enabled')"
359
+ >
360
+ <template #action>
361
+ <OtelStatusControl
362
+ :enabled="otelStore.settings.enabled"
363
+ :disabled="isEnvManaged('enabled')"
364
+ :loading="statusSaving"
365
+ @update:enabled="onToggleEnabled"
366
+ />
367
+ </template>
368
+ </OtelSettingsRow>
369
+ </N8nSettingsRowGroup>
370
+ </N8nSettingsSection>
371
+
372
+ <N8nSettingsSection
373
+ :title="i18n.baseText('settings.opentelemetry.collectorConnection.title')"
374
+ >
375
+ <N8nSettingsRowGroup>
376
+ <OtelSettingsRow
377
+ :title="i18n.baseText('settings.opentelemetry.exporterEndpoint.label')"
378
+ :description="i18n.baseText('settings.opentelemetry.exporterEndpoint.description')"
379
+ :env-tooltip="envTooltip('exporterEndpoint')"
380
+ action-fill
381
+ >
382
+ <template #action>
383
+ <N8nInput
384
+ v-model="otelStore.settings.exporterEndpoint"
385
+ :class="$style.control"
386
+ :placeholder="i18n.baseText('settings.opentelemetry.exporterEndpoint.placeholder')"
387
+ :disabled="isEnvManaged('exporterEndpoint')"
388
+ data-test-id="otel-exporter-endpoint"
389
+ />
390
+ </template>
391
+ </OtelSettingsRow>
392
+
393
+ <OtelSettingsRow
394
+ :title="i18n.baseText('settings.opentelemetry.exporterServiceName.label')"
395
+ :description="i18n.baseText('settings.opentelemetry.exporterServiceName.description')"
396
+ :env-tooltip="envTooltip('exporterServiceName')"
397
+ action-fill
398
+ >
399
+ <template #action>
400
+ <N8nInput
401
+ v-model="otelStore.settings.exporterServiceName"
402
+ :class="$style.control"
403
+ :placeholder="
404
+ i18n.baseText('settings.opentelemetry.exporterServiceName.placeholder')
405
+ "
406
+ :disabled="isEnvManaged('exporterServiceName')"
407
+ data-test-id="otel-service-name"
408
+ />
409
+ </template>
410
+ </OtelSettingsRow>
411
+
412
+ <OtelSettingsRow
413
+ :title="i18n.baseText('settings.opentelemetry.exporterHeaders.label')"
414
+ :description="i18n.baseText('settings.opentelemetry.exporterHeaders.description')"
415
+ :env-tooltip="envTooltip('exporterHeaders')"
416
+ layout="vertical"
417
+ action-fill
418
+ :action-max-width="false"
419
+ >
420
+ <template #action>
421
+ <div :class="$style.headersBlock">
422
+ <div v-for="(pair, index) in headerPairs" :key="index" :class="$style.headerRow">
423
+ <N8nInputLabel
424
+ :label="
425
+ index === 0
426
+ ? i18n.baseText('settings.opentelemetry.exporterHeaders.keyLabel')
427
+ : undefined
428
+ "
429
+ size="small"
430
+ >
431
+ <N8nInput
432
+ :model-value="pair.key"
433
+ :placeholder="
434
+ i18n.baseText('settings.opentelemetry.exporterHeaders.keyPlaceholder')
435
+ "
436
+ :disabled="isEnvManaged('exporterHeaders')"
437
+ data-test-id="otel-header-key"
438
+ @update:model-value="(v: string) => onHeaderChange(index, 'key', v)"
439
+ />
440
+ </N8nInputLabel>
441
+ <N8nInputLabel
442
+ :label="
443
+ index === 0
444
+ ? i18n.baseText('settings.opentelemetry.exporterHeaders.valueLabel')
445
+ : undefined
446
+ "
447
+ size="small"
448
+ >
449
+ <N8nInput
450
+ :model-value="pair.value"
451
+ :placeholder="
452
+ i18n.baseText('settings.opentelemetry.exporterHeaders.valuePlaceholder')
453
+ "
454
+ :disabled="isEnvManaged('exporterHeaders')"
455
+ data-test-id="otel-header-value"
456
+ @update:model-value="(v: string) => onHeaderChange(index, 'value', v)"
457
+ />
458
+ </N8nInputLabel>
459
+ <div :class="$style.headerRemove">
460
+ <N8nButton
461
+ icon="trash-2"
462
+ variant="ghost"
463
+ size="small"
464
+ native-type="button"
465
+ :disabled="isEnvManaged('exporterHeaders')"
466
+ :aria-label="i18n.baseText('settings.opentelemetry.exporterHeaders.remove')"
467
+ data-test-id="otel-header-remove"
468
+ @click.stop.prevent="removeHeader(index)"
469
+ />
470
+ </div>
471
+ </div>
472
+ <N8nButton
473
+ icon="plus"
474
+ variant="subtle"
475
+ size="small"
476
+ native-type="button"
477
+ :disabled="isEnvManaged('exporterHeaders')"
478
+ :class="$style.addHeaderButton"
479
+ data-test-id="otel-header-add"
480
+ @click.stop.prevent="addHeader"
481
+ >
482
+ {{ i18n.baseText('settings.opentelemetry.exporterHeaders.addHeader') }}
483
+ </N8nButton>
484
+ </div>
485
+ </template>
486
+ </OtelSettingsRow>
487
+
488
+ <OtelSettingsRow
489
+ :title="i18n.baseText('settings.opentelemetry.exporterTracingPath.label')"
490
+ :description="i18n.baseText('settings.opentelemetry.exporterTracingPath.description')"
491
+ :env-tooltip="envTooltip('exporterTracingPath')"
492
+ action-fill
493
+ >
494
+ <template #action>
495
+ <N8nInput
496
+ v-model="otelStore.settings.exporterTracingPath"
497
+ :class="$style.control"
498
+ :placeholder="
499
+ i18n.baseText('settings.opentelemetry.exporterTracingPath.placeholder')
500
+ "
501
+ :disabled="isEnvManaged('exporterTracingPath')"
502
+ data-test-id="otel-tracing-path"
503
+ />
504
+ </template>
505
+ </OtelSettingsRow>
506
+
507
+ <OtelSettingsRow
508
+ :title="i18n.baseText('settings.opentelemetry.startupConnectivityTimeoutMs.label')"
509
+ :description="
510
+ i18n.baseText('settings.opentelemetry.startupConnectivityTimeoutMs.description')
511
+ "
512
+ :env-tooltip="envTooltip('startupConnectivityTimeoutMs')"
513
+ >
514
+ <template #action>
515
+ <div :class="$style.inputWithSlug">
516
+ <N8nInput
517
+ v-model="connectivityTimeoutInput"
518
+ :disabled="isEnvManaged('startupConnectivityTimeoutMs')"
519
+ :aria-label="
520
+ i18n.baseText('settings.opentelemetry.startupConnectivityTimeoutMs.label')
521
+ "
522
+ data-test-id="otel-connectivity-timeout"
523
+ @blur="commitConnectivityTimeout"
524
+ @keydown.enter="commitConnectivityTimeout"
525
+ @keydown.up.prevent="stepConnectivityTimeout(1)"
526
+ @keydown.down.prevent="stepConnectivityTimeout(-1)"
527
+ />
528
+ <span :class="$style.slug">
529
+ {{ i18n.baseText('settings.opentelemetry.startupConnectivityTimeoutMs.slug') }}
530
+ </span>
531
+ </div>
532
+ </template>
533
+ </OtelSettingsRow>
534
+
535
+ <OtelSettingsRow
536
+ :title="i18n.baseText('settings.opentelemetry.testTrace.label')"
537
+ :description="testTraceSubtitle"
538
+ :description-error="otelStore.testState === 'error'"
539
+ >
540
+ <template #action>
541
+ <N8nButton
542
+ v-if="otelStore.testState === 'sent'"
543
+ variant="outline"
544
+ icon="check"
545
+ native-type="button"
546
+ data-test-id="otel-test-trace-button"
547
+ @click.stop.prevent="onSendTestTrace"
548
+ >
549
+ {{ i18n.baseText('settings.opentelemetry.testTrace.sent') }}
550
+ </N8nButton>
551
+ <N8nButton
552
+ v-else
553
+ variant="outline"
554
+ :loading="otelStore.testState === 'sending'"
555
+ :disabled="!canTestTrace"
556
+ native-type="button"
557
+ data-test-id="otel-test-trace-button"
558
+ @click.stop.prevent="onSendTestTrace"
559
+ >
560
+ {{
561
+ otelStore.testState === 'sending'
562
+ ? i18n.baseText('settings.opentelemetry.testTrace.sending')
563
+ : i18n.baseText('settings.opentelemetry.testTrace.send')
564
+ }}
565
+ </N8nButton>
566
+ </template>
567
+ </OtelSettingsRow>
568
+ </N8nSettingsRowGroup>
569
+ </N8nSettingsSection>
570
+
571
+ <N8nSettingsSection :title="i18n.baseText('settings.opentelemetry.tracing.title')">
572
+ <N8nSettingsRowGroup>
573
+ <OtelSettingsRow
574
+ :title="i18n.baseText('settings.opentelemetry.tracesSampleRate.label')"
575
+ :description="
576
+ i18n.baseText('settings.opentelemetry.tracesSampleRate.description', {
577
+ interpolate: { max: sampleRateMax },
578
+ })
579
+ "
580
+ :env-tooltip="envTooltip('tracesSampleRate')"
581
+ >
582
+ <template #action>
583
+ <div :class="$style.inputWithSlug">
584
+ <N8nInput
585
+ v-model="sampleRateInput"
586
+ :disabled="isEnvManaged('tracesSampleRate')"
587
+ :aria-label="i18n.baseText('settings.opentelemetry.tracesSampleRate.label')"
588
+ data-test-id="otel-sample-rate"
589
+ @blur="commitSampleRate"
590
+ @keydown.enter="commitSampleRate"
591
+ @keydown.up.prevent="stepSampleRate(1)"
592
+ @keydown.down.prevent="stepSampleRate(-1)"
593
+ />
594
+ <span :class="$style.slug">
595
+ {{
596
+ i18n.baseText('settings.opentelemetry.tracesSampleRate.slug', {
597
+ interpolate: { max: sampleRateMax },
598
+ })
599
+ }}
600
+ </span>
601
+ </div>
602
+ </template>
603
+ </OtelSettingsRow>
604
+
605
+ <OtelSettingsRow
606
+ :title="i18n.baseText('settings.opentelemetry.includeNodeSpans.label')"
607
+ :description="i18n.baseText('settings.opentelemetry.includeNodeSpans.description')"
608
+ :env-tooltip="envTooltip('includeNodeSpans')"
609
+ >
610
+ <template #action>
611
+ <N8nCheckbox
612
+ :model-value="otelStore.settings.includeNodeSpans"
613
+ :disabled="isEnvManaged('includeNodeSpans')"
614
+ data-test-id="otel-include-node-spans"
615
+ @update:model-value="otelStore.settings.includeNodeSpans = Boolean($event)"
616
+ />
617
+ </template>
618
+ </OtelSettingsRow>
619
+
620
+ <OtelSettingsRow
621
+ :title="i18n.baseText('settings.opentelemetry.injectOutbound.label')"
622
+ :description="i18n.baseText('settings.opentelemetry.injectOutbound.description')"
623
+ :env-tooltip="envTooltip('injectOutbound')"
624
+ >
625
+ <template #action>
626
+ <N8nCheckbox
627
+ :model-value="otelStore.settings.injectOutbound"
628
+ :disabled="isEnvManaged('injectOutbound')"
629
+ data-test-id="otel-inject-outbound"
630
+ @update:model-value="otelStore.settings.injectOutbound = Boolean($event)"
631
+ />
632
+ </template>
633
+ </OtelSettingsRow>
634
+
635
+ <OtelSettingsRow
636
+ :title="i18n.baseText('settings.opentelemetry.productionExecutionsOnly.label')"
637
+ :description="
638
+ i18n.baseText('settings.opentelemetry.productionExecutionsOnly.description')
639
+ "
640
+ :env-tooltip="envTooltip('productionExecutionsOnly')"
641
+ >
642
+ <template #action>
643
+ <N8nCheckbox
644
+ :model-value="otelStore.settings.productionExecutionsOnly"
645
+ :disabled="isEnvManaged('productionExecutionsOnly')"
646
+ data-test-id="otel-production-only"
647
+ @update:model-value="otelStore.settings.productionExecutionsOnly = Boolean($event)"
648
+ />
649
+ </template>
650
+ </OtelSettingsRow>
651
+ </N8nSettingsRowGroup>
652
+ </N8nSettingsSection>
653
+
654
+ <N8nSettingsSaveBar
655
+ :class="$style.saveBar"
656
+ :visible="otelStore.isDirty"
657
+ :message="i18n.baseText('settings.opentelemetry.unsavedChanges.title')"
658
+ :save-label="i18n.baseText('settings.opentelemetry.save')"
659
+ :discard-label="i18n.baseText('settings.opentelemetry.discard')"
660
+ :saving="otelStore.saving"
661
+ floating
662
+ @save="save"
663
+ @discard="discard"
664
+ />
665
+ </div>
666
+
667
+ <N8nDialog
668
+ v-model:open="showUnsavedChangesDialog"
669
+ :header="i18n.baseText('settings.opentelemetry.unsavedChanges.title')"
670
+ :description="i18n.baseText('settings.opentelemetry.unsavedChanges.message')"
671
+ size="medium"
672
+ >
673
+ <div data-test-id="otel-unsaved-changes-dialog">
674
+ <N8nDialogFooter>
675
+ <N8nDialogClose as-child>
676
+ <N8nButton
677
+ variant="outline"
678
+ :label="i18n.baseText('settings.opentelemetry.unsavedChanges.cancel')"
679
+ @click="onKeepEditing"
680
+ />
681
+ </N8nDialogClose>
682
+ <N8nButton
683
+ variant="outline"
684
+ :label="i18n.baseText('settings.opentelemetry.unsavedChanges.leaveWithoutSaving')"
685
+ @click="onLeaveWithoutSaving"
686
+ />
687
+ <N8nButton
688
+ variant="solid"
689
+ :label="i18n.baseText('settings.opentelemetry.unsavedChanges.saveAndLeave')"
690
+ :loading="otelStore.saving"
691
+ @click="onSaveAndLeave"
692
+ />
693
+ </N8nDialogFooter>
694
+ </div>
695
+ </N8nDialog>
696
+ </N8nSettingsLayout>
697
+ </template>
698
+
699
+ <style lang="scss" module>
700
+ .layout {
701
+ padding-top: 0;
702
+ }
703
+
704
+ .settingsContent {
705
+ display: flex;
706
+ flex-direction: column;
707
+ width: 100%;
708
+ }
709
+
710
+ .saveBar {
711
+ /*
712
+ * Same separation sections keep between each other (32px), so the bar reads as a
713
+ * page-level action rather than part of the preceding Tracing section.
714
+ */
715
+ margin-block-start: var(--spacing--xl);
716
+ }
717
+
718
+ .loading {
719
+ display: flex;
720
+ align-items: center;
721
+ justify-content: center;
722
+ padding: var(--spacing--2xl);
723
+ color: var(--icon-color--subtle);
724
+ }
725
+
726
+ .control {
727
+ width: 100%;
728
+ }
729
+
730
+ .headersBlock {
731
+ display: flex;
732
+ flex-direction: column;
733
+ align-items: flex-start;
734
+ gap: var(--spacing--2xs);
735
+ width: 100%;
736
+ }
737
+
738
+ .headerRow {
739
+ display: flex;
740
+ align-items: flex-end;
741
+ gap: var(--spacing--2xs);
742
+ width: 100%;
743
+
744
+ > div {
745
+ flex: 1;
746
+ min-width: 0;
747
+ }
748
+ }
749
+
750
+ .headerRemove {
751
+ display: flex;
752
+ flex: 0 0 auto !important;
753
+ align-items: center;
754
+ justify-content: center;
755
+ height: var(--height--lg);
756
+ }
757
+
758
+ .addHeaderButton {
759
+ margin-top: var(--spacing--4xs);
760
+ }
761
+
762
+ .inputWithSlug {
763
+ display: flex;
764
+ align-items: stretch;
765
+
766
+ // Compact numeric field: hug short values instead of filling the action area.
767
+ // N8nInput exposes per-corner radius custom properties; square the right side
768
+ // so the unit slug visually continues the input.
769
+ > :first-child {
770
+ --input--radius--top-right: 0;
771
+ --input--radius--bottom-right: 0;
772
+
773
+ width: var(--spacing--4xl);
774
+ min-width: 0;
775
+ }
776
+ }
777
+
778
+ .slug {
779
+ display: inline-flex;
780
+ align-items: center;
781
+ padding: 0 var(--spacing--2xs);
782
+ border: var(--border-width) solid var(--border-color);
783
+ border-left: none;
784
+ border-top-right-radius: var(--radius);
785
+ border-bottom-right-radius: var(--radius);
786
+ background: var(--background--hover);
787
+ color: var(--text-color--subtle);
788
+ font-size: var(--font-size--xs);
789
+ white-space: nowrap;
790
+ }
791
+ </style>