diff --git a/packages/cli/src/modules/otel/__tests__/otel-settings.service.test.ts b/packages/cli/src/modules/otel/__tests__/otel-settings.service.test.ts index f83448f1dab..a3fa693cea0 100644 --- a/packages/cli/src/modules/otel/__tests__/otel-settings.service.test.ts +++ b/packages/cli/src/modules/otel/__tests__/otel-settings.service.test.ts @@ -1,5 +1,10 @@ /* eslint-disable @typescript-eslint/unbound-method */ +import type { Logger } from '@n8n/backend-common'; import type { Settings, SettingsRepository } from '@n8n/db'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { mock } from 'vitest-mock-extended'; import type { OtelConnectionParams } from '../otel-settings.service'; @@ -8,6 +13,7 @@ import { OtelConfig } from '../otel.config'; describe('OtelSettingsService', () => { const settingsRepository = mock(); + const logger = mock(); const config = new OtelConfig(); // env-defaults (no env vars set in test runtime) let service: OtelSettingsService; @@ -21,7 +27,7 @@ describe('OtelSettingsService', () => { for (const key of Object.keys(process.env)) { if (key.startsWith('N8N_OTEL_')) delete process.env[key]; } - service = new OtelSettingsService(config, settingsRepository); + service = new OtelSettingsService(config, settingsRepository, logger); }); afterAll(() => { @@ -29,6 +35,43 @@ describe('OtelSettingsService', () => { }); describe('loadSettings', () => { + it('warns and falls back to defaults when persisted exporterHeaders is not a string', async () => { + settingsRepository.findByKey.mockResolvedValue({ + value: JSON.stringify({ exporterHeaders: 123 }), + } as Settings); + + await service.loadSettings(); + const result = service.getSettings(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('non-string exporterHeaders'), + ); + expect(result.exporterHeaders).toBe(''); + }); + + it('warns and falls back to defaults when the persisted row is not a settings object', async () => { + settingsRepository.findByKey.mockResolvedValue({ value: '"a-string"' } as Settings); + + await service.loadSettings(); + const result = service.getSettings(); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('not a settings object')); + expect(result.exporterServiceName).toBe('n8n'); + }); + + it('warns and falls back to defaults when the persisted row contains invalid JSON', async () => { + settingsRepository.findByKey.mockResolvedValue({ value: 'not-valid-json' } as Settings); + + await service.loadSettings(); + const result = service.getSettings(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('invalid JSON'), + expect.objectContaining({ error: expect.any(String) }), + ); + expect(result.exporterEndpoint).toBe('http://localhost:4318'); + }); + it('returns defaults when DB row is absent and no env vars are set', async () => { settingsRepository.findByKey.mockResolvedValue(null); @@ -84,7 +127,7 @@ describe('OtelSettingsService', () => { const configWithEnv = new OtelConfig(); configWithEnv.exporterEndpoint = 'https://from-env'; - const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository); + const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger); await serviceWithEnv.loadSettings(); const result = serviceWithEnv.getSettings(); @@ -125,7 +168,7 @@ describe('OtelSettingsService', () => { const configWithEnv = new OtelConfig(); configWithEnv.exporterProtocol = 'grpc'; - const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository); + const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger); await serviceWithEnv.loadSettings(); const result = serviceWithEnv.getSettings(); @@ -144,7 +187,7 @@ describe('OtelSettingsService', () => { const configWithEnv = new OtelConfig(); configWithEnv.enabled = true; configWithEnv.exporterEndpoint = 'https://from-env'; - const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository); + const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger); await serviceWithEnv.loadSettings(); const result = serviceWithEnv.getSettings(); @@ -154,6 +197,88 @@ describe('OtelSettingsService', () => { expect(result.envManagedFields).toContain('enabled'); expect(result.envManagedFields).toContain('exporterEndpoint'); }); + + it('masks env-managed exporterHeaders in the settings response', async () => { + settingsRepository.findByKey.mockResolvedValue(null); + process.env.N8N_OTEL_EXPORTER_OTLP_HEADERS = 'authorization=Bearer secret-token'; + + const configWithEnv = new OtelConfig(); + configWithEnv.exporterHeaders = 'authorization=Bearer secret-token'; + const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger); + + await serviceWithEnv.loadSettings(); + const result = serviceWithEnv.getSettings(); + + expect(result.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`); + expect(result.exporterHeaders).not.toContain('secret-token'); + expect(result.envManagedFields).toContain('exporterHeaders'); + }); + + it('returns an empty string when no headers are set', async () => { + settingsRepository.findByKey.mockResolvedValue(null); + + await service.loadSettings(); + const result = service.getSettings(); + + expect(result.exporterHeaders).toBe(''); + }); + + it('masks stored exporterHeaders even when not env-managed', async () => { + settingsRepository.findByKey.mockResolvedValue({ + value: JSON.stringify({ exporterHeaders: 'x-api-key=stored' }), + } as Settings); + + await service.loadSettings(); + const result = service.getSettings(); + + expect(result.exporterHeaders).toBe(`x-api-key=${CREDENTIAL_BLANKING_VALUE}`); + expect(result.exporterHeaders).not.toContain('stored'); + expect(result.envManagedFields).not.toContain('exporterHeaders'); + }); + + describe('values supplied via the _FILE env variant', () => { + // Temp dir backing the _FILE test; removed in afterEach + let tempHeadersDir: string | undefined; + + afterEach(() => { + if (tempHeadersDir) { + rmSync(tempHeadersDir, { recursive: true, force: true }); + tempHeadersDir = undefined; + } + }); + + it('treats headers from N8N_OTEL_EXPORTER_OTLP_HEADERS_FILE as env-managed and masks them', async () => { + const dir = mkdtempSync(join(tmpdir(), 'otel-headers-')); + tempHeadersDir = dir; + const headersFile = join(dir, 'headers'); + writeFileSync(headersFile, 'authorization=Bearer file-secret'); + process.env.N8N_OTEL_EXPORTER_OTLP_HEADERS_FILE = headersFile; + settingsRepository.findByKey.mockResolvedValue(null); + + // Mirror what the config factory does when it reads the _FILE variant + // (covered by @n8n/config's own decorator tests) + const configFromFile = new OtelConfig(); + configFromFile.exporterHeaders = 'authorization=Bearer file-secret'; + + const serviceFromFile = new OtelSettingsService(configFromFile, settingsRepository, logger); + await serviceFromFile.loadSettings(); + const result = serviceFromFile.getSettings(); + + expect(result.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`); + expect(result.envManagedFields).toContain('exporterHeaders'); + }); + + it('does not treat an empty _FILE variable as env-managed', async () => { + process.env.N8N_OTEL_EXPORTER_OTLP_HEADERS_FILE = ''; + settingsRepository.findByKey.mockResolvedValue(null); + + await service.loadSettings(); + const result = service.getSettings(); + + expect(result.envManagedFields).not.toContain('exporterHeaders'); + expect(result.exporterHeaders).toBe(''); + }); + }); }); describe('getSettings', () => { @@ -216,7 +341,7 @@ describe('OtelSettingsService', () => { const configWithEnv = new OtelConfig(); configWithEnv.exporterEndpoint = 'https://from-env'; configWithEnv.exporterProtocol = 'grpc'; - const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository); + const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger); settingsRepository.findByKey.mockResolvedValue(null); const incoming: OtelConfig = { @@ -233,6 +358,37 @@ describe('OtelSettingsService', () => { expect(saved.exporterProtocol).toBe('grpc'); expect(saved.enabled).toBe(settings.enabled); }); + + it('keeps the stored value per key when blanked values are echoed back', async () => { + settingsRepository.findByKey.mockResolvedValue({ + value: JSON.stringify({ ...settings, exporterHeaders: 'a=1,b=2' }), + } as Settings); + + await service.saveSettings({ + ...settings, + exporterHeaders: `a=${CREDENTIAL_BLANKING_VALUE},c=3`, + }); + + const saved = JSON.parse( + (settingsRepository.save.mock.calls[0]?.[0] as { value: string }).value, + ) as OtelConfig; + // 'a' keeps its stored value, 'b' is dropped (absent), 'c' is new + expect(saved.exporterHeaders).toBe('a=1,c=3'); + }); + + it('drops a blanked pair whose key has no stored value', async () => { + settingsRepository.findByKey.mockResolvedValue(null); + + await service.saveSettings({ + ...settings, + exporterHeaders: `z=${CREDENTIAL_BLANKING_VALUE}`, + }); + + const saved = JSON.parse( + (settingsRepository.save.mock.calls[0]?.[0] as { value: string }).value, + ) as OtelConfig; + expect(saved.exporterHeaders).toBe(''); + }); }); describe('resolveTestConnection', () => { @@ -249,11 +405,26 @@ describe('OtelSettingsService', () => { expect(service.resolveTestConnection(incoming)).toEqual(incoming); }); + it('resolves blanked values against the stored ones for test connections', async () => { + settingsRepository.findByKey.mockResolvedValue({ + value: JSON.stringify({ exporterHeaders: 'x-api-key=stored,x-other=2' }), + } as Settings); + + await service.loadSettings(); + + const result = service.resolveTestConnection({ + ...incoming, + exporterHeaders: `x-api-key=${CREDENTIAL_BLANKING_VALUE}`, + }); + + expect(result.exporterHeaders).toBe('x-api-key=stored'); + }); + it('overrides env-managed fields with the canonical env-var value', () => { process.env.N8N_OTEL_EXPORTER_OTLP_ENDPOINT = 'https://from-env'; const configWithEnv = new OtelConfig(); configWithEnv.exporterEndpoint = 'https://from-env'; - const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository); + const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger); const result = serviceWithEnv.resolveTestConnection({ ...incoming, @@ -268,7 +439,7 @@ describe('OtelSettingsService', () => { process.env.N8N_OTEL_EXPORTER_OTLP_PROTOCOL = 'grpc'; const configWithEnv = new OtelConfig(); configWithEnv.exporterProtocol = 'grpc'; - const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository); + const serviceWithEnv = new OtelSettingsService(configWithEnv, settingsRepository, logger); const result = serviceWithEnv.resolveTestConnection({ ...incoming, diff --git a/packages/cli/src/modules/otel/otel-settings.service.ts b/packages/cli/src/modules/otel/otel-settings.service.ts index 7a389d123b2..0c778dfc82d 100644 --- a/packages/cli/src/modules/otel/otel-settings.service.ts +++ b/packages/cli/src/modules/otel/otel-settings.service.ts @@ -1,6 +1,8 @@ +import { Logger } from '@n8n/backend-common'; import { SettingsRepository } from '@n8n/db'; import { Service } from '@n8n/di'; -import { jsonParse } from 'n8n-workflow'; +import { isRecord } from '@n8n/utils/is-record'; +import { CREDENTIAL_BLANKING_VALUE, jsonParse } from 'n8n-workflow'; import { OtelConfig } from './otel.config'; import { OTEL_ENV_VARS } from './otel.constants'; @@ -30,11 +32,18 @@ export class OtelSettingsService { constructor( private readonly config: OtelConfig, private readonly settingsRepository: SettingsRepository, + private readonly logger: Logger, ) {} getSettings(): OtelSettingsResponse { if (!this.currentSettings) throw new Error('OTel settings not yet initialized'); - return { ...this.currentSettings, envManagedFields: this.envManagedFields }; + return { + ...this.currentSettings, + // Header keys stay visible; values never leave the server. A blanked + // value marks a stored value; an empty value means unset. + exporterHeaders: this.redactHeaders(this.currentSettings.exporterHeaders), + envManagedFields: this.envManagedFields, + }; } /** @@ -56,18 +65,52 @@ export class OtelSettingsService { private async getPersistedSettings(): Promise | undefined> { const row = await this.settingsRepository.findByKey(OTEL_SETTINGS_KEY); - if (!row?.value) return undefined; - return jsonParse>(row.value, { fallbackValue: undefined }); + return this.parsePersisted(row?.value); + } + + private parsePersisted(value: string | null | undefined): Partial | undefined { + if (!value) return undefined; + try { + const persisted: unknown = jsonParse(value); + // The DB row is not shape-checked, so validate before it reaches any consumer + if (!isRecord(persisted)) { + this.logger.warn( + 'Persisted OTel settings are not a settings object; using defaults instead', + ); + return undefined; + } + const settings = persisted as Partial; + if ('exporterHeaders' in settings && typeof settings.exporterHeaders !== 'string') { + this.logger.warn( + 'Persisted OTel settings contain a non-string exporterHeaders value; using the default instead', + ); + delete settings.exporterHeaders; + } + return settings; + } catch (error) { + this.logger.warn('Persisted OTel settings contain invalid JSON; using defaults instead', { + error: error instanceof Error ? error.message : String(error), + }); + return undefined; + } } async saveSettings(incoming: OtelConfig): Promise { + const existing = await this.settingsRepository.findByKey(OTEL_SETTINGS_KEY); + const persisted = this.parsePersisted(existing?.value); // Env-var fields always win — override any frontend-submitted values with // the canonical env-var value so the DB stays consistent even if a client // sends a stale or tampered payload. const sanitized = this.buildConfig((key) => this.isEnvManaged(key) ? this.config[key] : incoming[key], ); - const existing = await this.settingsRepository.findByKey(OTEL_SETTINGS_KEY); + // Blanked values echoed back by a client mean "keep the stored value" + if (!this.isEnvManaged('exporterHeaders')) { + sanitized.exporterHeaders = this.resolveHeaders( + incoming.exporterHeaders, + persisted?.exporterHeaders ?? '', + ); + } const value = JSON.stringify(sanitized); if (existing) { existing.value = value; @@ -88,13 +131,69 @@ export class OtelSettingsService { exporterEndpoint: pick('exporterEndpoint'), exporterTracingPath: pick('exporterTracingPath'), exporterServiceName: pick('exporterServiceName'), - exporterHeaders: pick('exporterHeaders'), + // A client that echoes a redacted response cannot supply stored values, + // so blanked values resolve against the effective settings + exporterHeaders: this.isEnvManaged('exporterHeaders') + ? this.config.exporterHeaders + : this.resolveHeaders( + incoming.exporterHeaders, + this.currentSettings?.exporterHeaders ?? '', + ), startupConnectivityTimeoutMs: pick('startupConnectivityTimeoutMs'), }; } + /** Keys stay visible in API responses; header values never leave the server. */ + private redactHeaders(headers: string): string { + return this.serializeHeaderPairs( + this.parseHeaderPairs(headers).map(({ key, value }) => ({ + key, + value: value ? CREDENTIAL_BLANKING_VALUE : '', + })), + ); + } + + /** + * Resolves client-submitted headers against stored ones: a blanked value + * means "keep the stored value for this key"; a blanked value with no + * stored counterpart has nothing to keep, so the pair is dropped. + */ + private resolveHeaders(incoming: string, stored: string): string { + const storedByKey = new Map(this.parseHeaderPairs(stored).map((p) => [p.key, p.value])); + return this.serializeHeaderPairs( + this.parseHeaderPairs(incoming).flatMap(({ key, value }) => { + if (value !== CREDENTIAL_BLANKING_VALUE) return [{ key, value }]; + const storedValue = storedByKey.get(key); + return storedValue === undefined ? [] : [{ key, value: storedValue }]; + }), + ); + } + + private parseHeaderPairs(headers: string): Array<{ key: string; value: string }> { + if (!headers.trim()) return []; + return headers + .split(',') + .map((pair) => { + const idx = pair.indexOf('='); + return idx === -1 + ? { key: pair.trim(), value: '' } + : { key: pair.slice(0, idx).trim(), value: pair.slice(idx + 1).trim() }; + }) + .filter((p) => p.key); + } + + private serializeHeaderPairs(pairs: Array<{ key: string; value: string }>): string { + return pairs + .filter((p) => p.key.trim()) + .map((p) => `${p.key}=${p.value}`) + .join(','); + } + private isEnvManaged(key: keyof OtelConfig): boolean { - return process.env[OTEL_ENV_VARS[key]] !== undefined; + const envVar = OTEL_ENV_VARS[key]; + // Mirror readEnv() in @n8n/config: a value supplied via `${envVar}_FILE` + // is env-managed in the same way as one set directly + return process.env[envVar] !== undefined || !!process.env[`${envVar}_FILE`]; } private buildConfig(pick: (key: K) => OtelConfig[K]): OtelConfig { diff --git a/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts b/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts index 3b46652b44e..8a31f2d4c2d 100644 --- a/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/otel/otel.handler.ts @@ -24,9 +24,10 @@ const otelHandlers: OtelHandlers = { getOtelSettings: [ apiKeyHasScopeWithGlobalScopeFallback({ scope: 'otel:manage' }), async (_req, res) => { - const settings = await Container.get(OtelSettingsService).loadSettings(); + const settingsService = Container.get(OtelSettingsService); + await settingsService.loadSettings(); - return res.json(toOtelSettingsResponse(settings)); + return res.json(toOtelSettingsResponse(settingsService.getSettings())); }, ], diff --git a/packages/cli/test/integration/public-api/otel-settings.test.ts b/packages/cli/test/integration/public-api/otel-settings.test.ts index 9cca48e37b7..4082c9f6d54 100644 --- a/packages/cli/test/integration/public-api/otel-settings.test.ts +++ b/packages/cli/test/integration/public-api/otel-settings.test.ts @@ -1,6 +1,7 @@ import { testDb } from '@n8n/backend-test-utils'; import { SettingsRepository, type User } from '@n8n/db'; import { Container } from '@n8n/di'; +import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow'; import { vi } from 'vitest'; import { OtelSettingsService, OTEL_SETTINGS_KEY } from '@/modules/otel/otel-settings.service'; @@ -119,7 +120,10 @@ describe('OpenTelemetry settings in Public API', () => { .send(validSettings); expect(response.status).toBe(200); - expect(response.body).toMatchObject(validSettings); + expect(response.body).toMatchObject({ + ...validSettings, + exporterHeaders: `authorization=${CREDENTIAL_BLANKING_VALUE}`, + }); }); it('takes effect the same way as the UI (write via public API, read via internal API)', async () => { @@ -129,7 +133,10 @@ describe('OpenTelemetry settings in Public API', () => { const internal = await testServer.authAgentFor(owner).get('/otel/settings'); expect(internal.status).toBe(200); - expect(internal.body.data).toMatchObject(validSettings); + expect(internal.body.data).toMatchObject({ + ...validSettings, + exporterHeaders: `authorization=${CREDENTIAL_BLANKING_VALUE}`, + }); }); it('reads back a configuration written through the internal API (public API is a faithful stand-in)', async () => { @@ -138,7 +145,10 @@ describe('OpenTelemetry settings in Public API', () => { const publicRead = await testServer.publicApiAgentFor(owner).get('/settings/otel'); expect(publicRead.status).toBe(200); - expect(publicRead.body).toMatchObject(validSettings); + expect(publicRead.body).toMatchObject({ + ...validSettings, + exporterHeaders: `authorization=${CREDENTIAL_BLANKING_VALUE}`, + }); }); it('toggles enabled both ways', async () => { @@ -170,7 +180,7 @@ describe('OpenTelemetry settings in Public API', () => { expect(putResponse.status).toBe(200); expect(putResponse.body.exporterServiceName).toBe('n8n-updated'); - expect(putResponse.body.exporterHeaders).toBe(validSettings.exporterHeaders); + expect(putResponse.body.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`); }); it('switches the exporter protocol to gRPC and back', async () => { @@ -339,6 +349,79 @@ describe('OpenTelemetry settings in Public API', () => { }); }); + describe('GET with env-managed exporterHeaders', () => { + const ENV_HEADERS = 'authorization=Bearer env-managed-token'; + let originalHeaders: string; + + beforeEach(async () => { + process.env[OTEL_ENV_VARS.exporterHeaders] = ENV_HEADERS; + originalHeaders = Container.get(OtelConfig).exporterHeaders; + Container.get(OtelConfig).exporterHeaders = ENV_HEADERS; + await Container.get(OtelSettingsService).loadSettings(); + }); + + afterEach(async () => { + delete process.env[OTEL_ENV_VARS.exporterHeaders]; + Container.get(OtelConfig).exporterHeaders = originalHeaders; + await Container.get(OtelSettingsService).loadSettings(); + }); + + it('internal API returns the blanking placeholder for exporterHeaders', async () => { + const response = await testServer.authAgentFor(owner).get('/otel/settings'); + + expect(response.status).toBe(200); + expect(response.body.data.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`); + expect(response.body.data.envManagedFields).toContain('exporterHeaders'); + }); + + it('public API returns the blanking placeholder for exporterHeaders', async () => { + const response = await testServer.publicApiAgentFor(owner).get('/settings/otel'); + + expect(response.status).toBe(200); + expect(response.body.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`); + }); + + it('accepts a GET response body echoed straight back (clean round-trip)', async () => { + const getResponse = await testServer.publicApiAgentFor(owner).get('/settings/otel'); + expect(getResponse.status).toBe(200); + + const putResponse = await testServer + .publicApiAgentFor(owner) + .put('/settings/otel') + .send(getResponse.body); + + expect(putResponse.status).toBe(200); + expect(putResponse.body.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`); + }); + }); + + describe('GET with headers supplied via the _FILE env variant', () => { + let originalHeaders: string; + + beforeEach(async () => { + // Simulate `N8N_OTEL_EXPORTER_OTLP_HEADERS_FILE` being set: mark it + // env-managed and pin the file-supplied value on the (singleton) config. + process.env[`${OTEL_ENV_VARS.exporterHeaders}_FILE`] = '/run/secrets/otel-headers'; + originalHeaders = Container.get(OtelConfig).exporterHeaders; + Container.get(OtelConfig).exporterHeaders = 'authorization=Bearer file-managed-token'; + await Container.get(OtelSettingsService).loadSettings(); + }); + + afterEach(async () => { + delete process.env[`${OTEL_ENV_VARS.exporterHeaders}_FILE`]; + Container.get(OtelConfig).exporterHeaders = originalHeaders; + await Container.get(OtelSettingsService).loadSettings(); + }); + + it('internal API returns the blanking placeholder for exporterHeaders', async () => { + const response = await testServer.authAgentFor(owner).get('/otel/settings'); + + expect(response.status).toBe(200); + expect(response.body.data.exporterHeaders).toBe(`authorization=${CREDENTIAL_BLANKING_VALUE}`); + expect(response.body.data.envManagedFields).toContain('exporterHeaders'); + }); + }); + describe('PUT /settings/otel with an env-managed exporter protocol', () => { let originalProtocol: OtelConfig['exporterProtocol']; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index de7ef19468b..21f7159be4a 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3300,6 +3300,7 @@ "settings.opentelemetry.exporterHeaders.keyPlaceholder": "Key", "settings.opentelemetry.exporterHeaders.valuePlaceholder": "Value", "settings.opentelemetry.exporterHeaders.addHeader": "Add header", + "settings.opentelemetry.exporterHeaders.hiddenValuePlaceholder": "Configured value not shown", "settings.opentelemetry.exporterTracingPath.label": "Trace path", "settings.opentelemetry.exporterTracingPath.description": "Appended to the endpoint. /v1/traces is the OTLP default.", "settings.opentelemetry.exporterTracingPath.placeholder": "/v1/traces", diff --git a/packages/modules/otel/frontend/src/SettingsOpenTelemetryView.test.ts b/packages/modules/otel/frontend/src/SettingsOpenTelemetryView.test.ts index a590bce4b53..58c480c3c1b 100644 --- a/packages/modules/otel/frontend/src/SettingsOpenTelemetryView.test.ts +++ b/packages/modules/otel/frontend/src/SettingsOpenTelemetryView.test.ts @@ -475,6 +475,49 @@ describe('SettingsOpenTelemetryView', () => { }); }); + it('renders header keys with masked values when stored headers are redacted', async () => { + getOtelSettingsMock.mockResolvedValue( + makeSettings({ + exporterHeaders: 'authorization=__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6', + envManagedFields: [], + }), + ); + + const { getAllByTestId, getByTestId } = render(); + + await waitFor(() => { + expect(getAllByTestId('otel-header-key')).toHaveLength(1); + }); + expect(getByTestId('otel-header-key')).toHaveValue('authorization'); + // The blanked value renders empty with a hint instead of the placeholder text + expect(getByTestId('otel-header-value')).toHaveValue(''); + expect(getByTestId('otel-header-value')).toHaveAttribute( + 'placeholder', + 'Configured value not shown', + ); + // Field stays editable when not env-managed, so adding a header can replace the set + expect(getByTestId('otel-header-add')).toBeEnabled(); + }); + + it('renders header keys with masked, disabled values when headers are env-managed', async () => { + getOtelSettingsMock.mockResolvedValue( + makeSettings({ + exporterHeaders: 'authorization=__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6', + envManagedFields: ['exporterHeaders'], + }), + ); + + const { getAllByTestId, getByTestId } = render(); + + await waitFor(() => { + expect(getAllByTestId('otel-header-key')).toHaveLength(1); + }); + expect(getByTestId('otel-header-key')).toHaveValue('authorization'); + expect(getByTestId('otel-header-key')).toBeDisabled(); + expect(getByTestId('otel-header-value')).toHaveValue(''); + expect(getByTestId('otel-header-value')).toBeDisabled(); + }); + // ── exporter protocol ───────────────────────────────────────────────────── it('shows the saved protocol in the select', async () => { diff --git a/packages/modules/otel/frontend/src/SettingsOpenTelemetryView.vue b/packages/modules/otel/frontend/src/SettingsOpenTelemetryView.vue index 1aca25ec8c4..cec9e75fc18 100644 --- a/packages/modules/otel/frontend/src/SettingsOpenTelemetryView.vue +++ b/packages/modules/otel/frontend/src/SettingsOpenTelemetryView.vue @@ -24,7 +24,11 @@ import { useSettingsStore } from '@n8n/stores/settings.store'; import { computed, ref, watch, onMounted } from 'vue'; import { onBeforeRouteLeave, type NavigationGuardNext } from 'vue-router'; -import { OTEL_FIELD_ENV_VARS, OTEL_TEST_SPAN_NAME } from './otel.constants'; +import { + CREDENTIALS_BLANKING_VALUE, + OTEL_FIELD_ENV_VARS, + OTEL_TEST_SPAN_NAME, +} from './otel.constants'; import { useOtelStore, headersStringToPairs, headersPairsToString } from './otel.store'; import { createSampleRateFormat, isOtlpProtocol } from './otel.utils'; import OtelSettingsRow from './OtelSettingsRow.vue'; @@ -49,7 +53,7 @@ const showUnsavedChangesDialog = ref(false); const pendingNext = ref(null); function syncHeaderPairsFromStore() { - headerPairs.value = headersStringToPairs(otelStore.settings.exporterHeaders); + headerPairs.value = headerPairsFromStore(); } function syncHeaderPairsToStore() { @@ -77,6 +81,23 @@ function isEnvManaged(field: keyof typeof OTEL_FIELD_ENV_VARS): boolean { return otelStore.envManagedFields.includes(field); } +// Keys come back from the API as-is; values come back blanked, so the value +// inputs below render them empty until the user types a replacement. The pair +// keeps the blanked value in state, so an untouched save preserves the stored one. +function headerPairsFromStore(): Array<{ key: string; value: string }> { + return headersStringToPairs(otelStore.settings.exporterHeaders ?? ''); +} + +function headerValueDisplay(pair: { key: string; value: string }): string { + return pair.value === CREDENTIALS_BLANKING_VALUE ? '' : pair.value; +} + +function headerValuePlaceholder(pair: { key: string; value: string }): string { + return pair.value === CREDENTIALS_BLANKING_VALUE + ? i18n.baseText('settings.opentelemetry.exporterHeaders.hiddenValuePlaceholder') + : i18n.baseText('settings.opentelemetry.exporterHeaders.valuePlaceholder'); +} + // State-first copy: the row tells the admin whether tracing is live right now, // instead of describing what the disabled state would mean hypothetically. const statusDescription = computed(() => @@ -234,7 +255,7 @@ watch( (newVal) => { const currentString = headersPairsToString(headerPairs.value); if (newVal !== currentString) { - headerPairs.value = headersStringToPairs(newVal ?? ''); + headerPairs.value = headerPairsFromStore(); } }, ); @@ -513,10 +534,8 @@ watch( size="small" >