From d9e655068447dc35861e348e16a87f2bde9a0686 Mon Sep 17 00:00:00 2001 From: Ramin Baradari <900288+rbaradari@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:44:16 +0100 Subject: [PATCH] feat(transport): support dynamic headers in Fetch/OtlpHttp transport (#1490) --- CHANGELOG.md | 4 + experimental/CHANGELOG.md | 1 + .../transport-otlp-http/src/transport.test.ts | 81 +++++++++++++++++ .../transport-otlp-http/src/transport.ts | 9 +- experimental/transport-otlp-http/src/types.ts | 8 +- .../src/transports/fetch/transport.test.ts | 87 +++++++++++++++++++ .../web-sdk/src/transports/fetch/transport.ts | 9 +- .../web-sdk/src/transports/fetch/types.ts | 8 +- 8 files changed, 201 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f3293b0..15915339d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Next +- Feature (`@grafana/faro-web-sdk`): Fetch transport now supports dynamic header values. Each header can be a static string or a function returning a string, resolved at request time (#1490). + +- Feature (`@grafana/faro-transport-otlp-http [experimental]`): OLTP HTTP transport now supports dynamic header values. Each header can be a static string or a function returning a string, resolved at request time (#1490). + ## 2.0.2 - Breaking (`@grafana/faro-web-sdk`): User action events now have a standardized event name diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index e71f08e9c..40603ec87 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -3,6 +3,7 @@ ## Next - Added support for experimental `ReplayInstrumentation` (`@grafana/faro-instrumentation-replay`). +- Feature (`@grafana/faro-transport-otlp-http`): OLTP HTTP transport now supports dynamic header values. Each header can be a static string or a function returning a string, resolved at request time (#1490). ## 1.13.0 diff --git a/experimental/transport-otlp-http/src/transport.test.ts b/experimental/transport-otlp-http/src/transport.test.ts index 4808f62cf..4e0662904 100644 --- a/experimental/transport-otlp-http/src/transport.test.ts +++ b/experimental/transport-otlp-http/src/transport.test.ts @@ -420,6 +420,87 @@ describe('OtlpHttpTransport', () => { expect(mockResponseTextFn).toHaveBeenCalledTimes(1); }); + it('will add static header values', () => { + const transport = new OtlpHttpTransport({ + logsURL: 'https://www.example.com/v1/logs', + requestOptions: { + headers: { + Authorization: 'Bearer static-token', + 'X-Static': 'static-value', + }, + }, + }); + + transport.internalLogger = mockInternalLogger; + + transport.send([logTransportItem]); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + 'https://www.example.com/v1/logs', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer static-token', + 'X-Static': 'static-value', + }), + }) + ); + }); + + it('will add dynamic header values from sync callbacks', () => { + const transport = new OtlpHttpTransport({ + logsURL: 'https://www.example.com/v1/logs', + requestOptions: { + headers: { + Authorization: () => 'Bearer dynamic-token', + 'X-User': () => 'user-123', + }, + }, + }); + + transport.internalLogger = mockInternalLogger; + + transport.send([logTransportItem]); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + 'https://www.example.com/v1/logs', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer dynamic-token', + 'X-User': 'user-123', + }), + }) + ); + }); + + it('will add static header values and dynamic header values from sync callbacks', () => { + const transport = new OtlpHttpTransport({ + logsURL: 'https://www.example.com/v1/logs', + requestOptions: { + headers: { + Authorization: () => 'Bearer dynamic-token', + 'X-Static': 'static-value', + }, + }, + }); + + transport.internalLogger = mockInternalLogger; + + transport.send([logTransportItem]); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + 'https://www.example.com/v1/logs', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer dynamic-token', + 'X-Static': 'static-value', + }), + }) + ); + }); + it('will not send traces data if traces URL is not set', () => { const transport = new OtlpHttpTransport({ logsURL: 'www.example.com/v1/logs', diff --git a/experimental/transport-otlp-http/src/transport.ts b/experimental/transport-otlp-http/src/transport.ts index 715addbc1..3b20e6362 100644 --- a/experimental/transport-otlp-http/src/transport.ts +++ b/experimental/transport-otlp-http/src/transport.ts @@ -89,18 +89,23 @@ export class OtlpHttpTransport extends BaseTransport { const body = JSON.stringify({ [key]: value }); const { requestOptions, apiKey } = this.options; - const { headers, ...restOfRequestOptions } = requestOptions ?? {}; + const { headers = {}, ...restOfRequestOptions } = requestOptions ?? {}; if (!url) { continue; } + const resolvedHeaders: Record = {}; + for (const [key, value] of Object.entries(headers)) { + resolvedHeaders[key] = typeof value === 'function' ? value() : value; + } + this.promiseBuffer.add(() => { return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(headers ?? {}), + ...resolvedHeaders, ...(apiKey ? { 'x-api-key': apiKey } : {}), }, body, diff --git a/experimental/transport-otlp-http/src/types.ts b/experimental/transport-otlp-http/src/types.ts index 9c3149665..acae7ac43 100644 --- a/experimental/transport-otlp-http/src/types.ts +++ b/experimental/transport-otlp-http/src/types.ts @@ -1,7 +1,13 @@ import type { ExceptionEvent, MeasurementEvent, TransportItem } from '@grafana/faro-core'; export interface OtlpTransportRequestOptions extends Omit { - headers?: Record; + /** + * Headers to include in every request. + * Each value can be: + * - a string (static value) + * - a function returning a string (dynamic value) + */ + headers?: Record string)>; } export interface OtlpHttpTransportOptions { diff --git a/packages/web-sdk/src/transports/fetch/transport.test.ts b/packages/web-sdk/src/transports/fetch/transport.test.ts index 4d7e45ae1..bbcb70edf 100644 --- a/packages/web-sdk/src/transports/fetch/transport.test.ts +++ b/packages/web-sdk/src/transports/fetch/transport.test.ts @@ -249,6 +249,93 @@ describe('FetchTransport', () => { expect(ignoreUrls).toStrictEqual([collectorUrl, ...globalIgnoreUrls]); }); + it('will add static header values', () => { + const transport = new FetchTransport({ + url: 'http://example.com/collect', + requestOptions: { + headers: { + Authorization: 'Bearer static-token', + 'X-Static': 'static-value', + }, + }, + }); + + transport.metas.value = { session: { id: mockSessionId } }; + + transport.internalLogger = mockInternalLogger; + + transport.send([item]); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + 'http://example.com/collect', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer static-token', + 'X-Static': 'static-value', + }), + }) + ); + }); + + it('will add dynamic header values from sync callbacks', () => { + const transport = new FetchTransport({ + url: 'http://example.com/collect', + requestOptions: { + headers: { + Authorization: () => `Bearer ${mockSessionId}-token`, + 'X-User': () => 'user-123', + }, + }, + }); + + transport.metas.value = { session: { id: mockSessionId } }; + + transport.internalLogger = mockInternalLogger; + + transport.send([item]); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + 'http://example.com/collect', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Bearer ${mockSessionId}-token`, + 'X-User': 'user-123', + }), + }) + ); + }); + + it('will add static header values and dynamic header values from sync callbacks', () => { + const transport = new FetchTransport({ + url: 'http://example.com/collect', + requestOptions: { + headers: { + Authorization: () => `Bearer ${mockSessionId}-token`, + 'X-Static': 'static-value', + }, + }, + }); + + transport.metas.value = { session: { id: mockSessionId } }; + + transport.internalLogger = mockInternalLogger; + + transport.send([item]); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + 'http://example.com/collect', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Bearer ${mockSessionId}-token`, + 'X-Static': 'static-value', + }), + }) + ); + }); + it('creates a new faro session if collector response indicates an invalid session', async () => { fetch.mockImplementationOnce(() => Promise.resolve({ diff --git a/packages/web-sdk/src/transports/fetch/transport.ts b/packages/web-sdk/src/transports/fetch/transport.ts index 55d91a580..79af6481b 100644 --- a/packages/web-sdk/src/transports/fetch/transport.ts +++ b/packages/web-sdk/src/transports/fetch/transport.ts @@ -49,7 +49,7 @@ export class FetchTransport extends BaseTransport { const { url, requestOptions, apiKey } = this.options; - const { headers, ...restOfRequestOptions } = requestOptions ?? {}; + const { headers = {}, ...restOfRequestOptions } = requestOptions ?? {}; let sessionId; const sessionMeta = this.metas.value.session; @@ -57,11 +57,16 @@ export class FetchTransport extends BaseTransport { sessionId = sessionMeta.id; } + const resolvedHeaders: Record = {}; + for (const [key, value] of Object.entries(headers)) { + resolvedHeaders[key] = typeof value === 'function' ? value() : value; + } + return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(headers ?? {}), + ...resolvedHeaders, ...(apiKey ? { 'x-api-key': apiKey } : {}), ...(sessionId ? { 'x-faro-session-id': sessionId } : {}), }, diff --git a/packages/web-sdk/src/transports/fetch/types.ts b/packages/web-sdk/src/transports/fetch/types.ts index a637077d2..9f1e9ba27 100644 --- a/packages/web-sdk/src/transports/fetch/types.ts +++ b/packages/web-sdk/src/transports/fetch/types.ts @@ -1,5 +1,11 @@ export interface FetchTransportRequestOptions extends Omit { - headers?: Record; + /** + * Headers to include in every request. + * Each value can be: + * - a string (static value) + * - a function returning a string (dynamic value) + */ + headers?: Record string)>; } export interface FetchTransportOptions {