From 67d76ad760c1a70ab1d1821a7df25d5b19d312f3 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sat, 31 Jan 2026 20:17:00 +0900 Subject: [PATCH 1/7] test(exporters): fix sendBeacon stub to return true in browser tests --- .../test/browser/OTLPLogExporter.test.ts | 2 +- .../test/browser/OTLPLogExporter.test.ts | 2 +- .../test/browser/OTLPTraceExporter.test.ts | 2 +- .../test/browser/OTLPTraceExporter.test.ts | 2 +- .../test/browser/OTLPMetricExporter.test.ts | 2 +- .../test/browser/OTLPMetricExporter.test.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/experimental/packages/exporter-logs-otlp-http/test/browser/OTLPLogExporter.test.ts b/experimental/packages/exporter-logs-otlp-http/test/browser/OTLPLogExporter.test.ts index 0ce75d01509..1bf92961147 100644 --- a/experimental/packages/exporter-logs-otlp-http/test/browser/OTLPLogExporter.test.ts +++ b/experimental/packages/exporter-logs-otlp-http/test/browser/OTLPLogExporter.test.ts @@ -38,7 +38,7 @@ describe('OTLPLogExporter', function () { describe('when sendBeacon is available', function () { it('should successfully send data using sendBeacon', async function () { // arrange - const stubBeacon = sinon.stub(navigator, 'sendBeacon'); + const stubBeacon = sinon.stub(navigator, 'sendBeacon').returns(true); const loggerProvider = new LoggerProvider({ processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], }); diff --git a/experimental/packages/exporter-logs-otlp-proto/test/browser/OTLPLogExporter.test.ts b/experimental/packages/exporter-logs-otlp-proto/test/browser/OTLPLogExporter.test.ts index 4c6b4f36a84..457a5c5936a 100644 --- a/experimental/packages/exporter-logs-otlp-proto/test/browser/OTLPLogExporter.test.ts +++ b/experimental/packages/exporter-logs-otlp-proto/test/browser/OTLPLogExporter.test.ts @@ -38,7 +38,7 @@ describe('OTLPLogExporter', function () { describe('when sendBeacon is available', function () { it('should successfully send data using sendBeacon', async function () { // arrange - const stubBeacon = sinon.stub(navigator, 'sendBeacon'); + const stubBeacon = sinon.stub(navigator, 'sendBeacon').returns(true); const loggerProvider = new LoggerProvider({ processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], }); diff --git a/experimental/packages/exporter-trace-otlp-http/test/browser/OTLPTraceExporter.test.ts b/experimental/packages/exporter-trace-otlp-http/test/browser/OTLPTraceExporter.test.ts index f7ccd7b59b2..e05e931470a 100644 --- a/experimental/packages/exporter-trace-otlp-http/test/browser/OTLPTraceExporter.test.ts +++ b/experimental/packages/exporter-trace-otlp-http/test/browser/OTLPTraceExporter.test.ts @@ -39,7 +39,7 @@ describe('OTLPTraceExporter', () => { describe('when sendBeacon is available', function () { it('should successfully send data using sendBeacon', async function () { // arrange - const stubBeacon = sinon.stub(navigator, 'sendBeacon'); + const stubBeacon = sinon.stub(navigator, 'sendBeacon').returns(true); const tracerProvider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], }); diff --git a/experimental/packages/exporter-trace-otlp-proto/test/browser/OTLPTraceExporter.test.ts b/experimental/packages/exporter-trace-otlp-proto/test/browser/OTLPTraceExporter.test.ts index 79e5b86a1e9..4ca4759ec79 100644 --- a/experimental/packages/exporter-trace-otlp-proto/test/browser/OTLPTraceExporter.test.ts +++ b/experimental/packages/exporter-trace-otlp-proto/test/browser/OTLPTraceExporter.test.ts @@ -39,7 +39,7 @@ describe('OTLPTraceExporter', () => { describe('when sendBeacon is available', function () { it('should successfully send data using sendBeacon', async function () { // arrange - const stubBeacon = sinon.stub(navigator, 'sendBeacon'); + const stubBeacon = sinon.stub(navigator, 'sendBeacon').returns(true); const tracerProvider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], }); diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/browser/OTLPMetricExporter.test.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/browser/OTLPMetricExporter.test.ts index ec2bd3e3234..b9a65db592b 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/browser/OTLPMetricExporter.test.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-http/test/browser/OTLPMetricExporter.test.ts @@ -43,7 +43,7 @@ describe('OTLPMetricExporter', function () { describe('when sendBeacon is available', function () { it('should successfully send data using sendBeacon', async function () { // arrange - const stubBeacon = sinon.stub(navigator, 'sendBeacon'); + const stubBeacon = sinon.stub(navigator, 'sendBeacon').returns(true); const meterProvider = new MeterProvider({ readers: [ new PeriodicExportingMetricReader({ diff --git a/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/browser/OTLPMetricExporter.test.ts b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/browser/OTLPMetricExporter.test.ts index eb684cd8479..46d1d4f8686 100644 --- a/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/browser/OTLPMetricExporter.test.ts +++ b/experimental/packages/opentelemetry-exporter-metrics-otlp-proto/test/browser/OTLPMetricExporter.test.ts @@ -39,7 +39,7 @@ describe('OTLPTraceExporter', () => { describe('when sendBeacon is available', function () { it('should successfully send data using sendBeacon', async function () { // arrange - const stubBeacon = sinon.stub(navigator, 'sendBeacon'); + const stubBeacon = sinon.stub(navigator, 'sendBeacon').returns(true); const meterProvider = new MeterProvider({ readers: [ new PeriodicExportingMetricReader({ From e5e491323f9d76a80d0195f20cf4acff68809092 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sat, 31 Jan 2026 20:17:06 +0900 Subject: [PATCH 2/7] feat(otlp-exporter-base): add FailoverTransport for fallback on failure --- .../src/transport/failover-transport.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 experimental/packages/otlp-exporter-base/src/transport/failover-transport.ts diff --git a/experimental/packages/otlp-exporter-base/src/transport/failover-transport.ts b/experimental/packages/otlp-exporter-base/src/transport/failover-transport.ts new file mode 100644 index 00000000000..30f6d9d4404 --- /dev/null +++ b/experimental/packages/otlp-exporter-base/src/transport/failover-transport.ts @@ -0,0 +1,58 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IExporterTransport } from '../exporter-transport'; +import { ExportResponse } from '../export-response'; +import { diag } from '@opentelemetry/api'; + +export interface FailoverTransportParameters { + primary: IExporterTransport; + failover: IExporterTransport; +} + +class FailoverTransport implements IExporterTransport { + private _primary: IExporterTransport; + private _failover: IExporterTransport; + + constructor(parameters: FailoverTransportParameters) { + this._primary = parameters.primary; + this._failover = parameters.failover; + } + + async send(data: Uint8Array, timeoutMillis: number): Promise { + const result = await this._primary.send(data, timeoutMillis); + if (result.status === 'failure') { + diag.debug('Primary transport failed, switching to failover transport'); + return this._failover.send(data, timeoutMillis); + } + return result; + } + + shutdown(): void { + this._primary.shutdown(); + this._failover.shutdown(); + } +} + +/** + * Creates a transport that tries the primary transport first, + * and switches to the failover transport if the primary fails. + */ +export function createFailoverTransport( + parameters: FailoverTransportParameters +): IExporterTransport { + return new FailoverTransport(parameters); +} From 083b410db7431b336529c560e1c464da30c804f1 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sat, 31 Jan 2026 20:17:11 +0900 Subject: [PATCH 3/7] fix(otlp-exporter-base): disable keepalive for body size >= 60KiB --- .../otlp-exporter-base/src/transport/fetch-transport.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts b/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts index 715ac7ed910..253fd2c428e 100644 --- a/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts +++ b/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts @@ -23,6 +23,13 @@ import { } from '../is-export-retryable'; import { HeadersFactory } from '../configuration/otlp-http-configuration'; +/** + * Maximum body size for using keepalive flag. + * Browsers limit keepalive requests to 64KiB total. We use 60KiB for body + * to leave room for headers. + */ +const KEEPALIVE_BODY_SIZE_LIMIT = 60 * 1024; + export interface FetchTransportParameters { url: string; headers: HeadersFactory; @@ -46,7 +53,7 @@ class FetchTransport implements IExporterTransport { headers: await this._parameters.headers(), body: data, signal: abortController.signal, - keepalive: isBrowserEnvironment, + keepalive: isBrowserEnvironment && data.byteLength < KEEPALIVE_BODY_SIZE_LIMIT, mode: isBrowserEnvironment ? globalThis.location?.origin === url.origin ? 'same-origin' From 656a5bce32a922c13c79f1984861b07500fe611b Mon Sep 17 00:00:00 2001 From: YangJH Date: Sat, 31 Jan 2026 20:17:17 +0900 Subject: [PATCH 4/7] feat(otlp-exporter-base): use FailoverTransport for sendBeacon delegate --- .../src/otlp-browser-http-export-delegate.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/experimental/packages/otlp-exporter-base/src/otlp-browser-http-export-delegate.ts b/experimental/packages/otlp-exporter-base/src/otlp-browser-http-export-delegate.ts index 46610a59bab..a04d4388a23 100644 --- a/experimental/packages/otlp-exporter-base/src/otlp-browser-http-export-delegate.ts +++ b/experimental/packages/otlp-exporter-base/src/otlp-browser-http-export-delegate.ts @@ -20,6 +20,7 @@ import { createRetryingTransport } from './retrying-transport'; import { createSendBeaconTransport } from './transport/send-beacon-transport'; import { createOtlpNetworkExportDelegate } from './otlp-network-export-delegate'; import { createFetchTransport } from './transport/fetch-transport'; +import { createFailoverTransport } from './transport/failover-transport'; export function createOtlpFetchExportDelegate( options: OtlpHttpConfiguration, @@ -42,9 +43,12 @@ export function createOtlpSendBeaconExportDelegate( options, serializer, createRetryingTransport({ - transport: createSendBeaconTransport({ - url: options.url, - headers: options.headers, + transport: createFailoverTransport({ + primary: createSendBeaconTransport({ + url: options.url, + headers: options.headers, + }), + failover: createFetchTransport(options), }), }) ); From 7937e17a89fdde7cd805bea13672672fc1bfdf45 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sat, 31 Jan 2026 20:17:22 +0900 Subject: [PATCH 5/7] test(otlp-exporter-base): add tests for FailoverTransport and keepalive limit --- .../test/browser/fetch-transport.test.ts | 44 +++ .../test/common/failover-transport.test.ts | 251 ++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 experimental/packages/otlp-exporter-base/test/common/failover-transport.test.ts diff --git a/experimental/packages/otlp-exporter-base/test/browser/fetch-transport.test.ts b/experimental/packages/otlp-exporter-base/test/browser/fetch-transport.test.ts index 2fdea561a57..e9a1a1caefa 100644 --- a/experimental/packages/otlp-exporter-base/test/browser/fetch-transport.test.ts +++ b/experimental/packages/otlp-exporter-base/test/browser/fetch-transport.test.ts @@ -191,5 +191,49 @@ describe('FetchTransport', function () { done(); }, done /* catch any rejections */); }); + + it('uses keepalive when body size is less than 60KiB', function (done) { + // arrange + const smallPayload = new Uint8Array(61439); + const fetchStub = sinon + .stub(globalThis, 'fetch') + .resolves(new Response('', { status: 200 })); + const transport = createFetchTransport(testTransportParameters); + + // act + transport.send(smallPayload, requestTimeout).then(() => { + // assert + try { + sinon.assert.calledOnce(fetchStub); + const callArgs = fetchStub.firstCall.args[1]; + assert.strictEqual(callArgs?.keepalive, true); + } catch (e) { + done(e); + } + done(); + }, done); + }); + + it('does not use keepalive when body size is 60KiB or larger', function (done) { + // arrange + const largePayload = new Uint8Array(61440); + const fetchStub = sinon + .stub(globalThis, 'fetch') + .resolves(new Response('', { status: 200 })); + const transport = createFetchTransport(testTransportParameters); + + // act + transport.send(largePayload, requestTimeout).then(() => { + // assert + try { + sinon.assert.calledOnce(fetchStub); + const callArgs = fetchStub.firstCall.args[1]; + assert.strictEqual(callArgs?.keepalive, false); + } catch (e) { + done(e); + } + done(); + }, done); + }); }); }); diff --git a/experimental/packages/otlp-exporter-base/test/common/failover-transport.test.ts b/experimental/packages/otlp-exporter-base/test/common/failover-transport.test.ts new file mode 100644 index 00000000000..21231297219 --- /dev/null +++ b/experimental/packages/otlp-exporter-base/test/common/failover-transport.test.ts @@ -0,0 +1,251 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as sinon from 'sinon'; +import * as assert from 'assert'; +import { createFailoverTransport } from '../../src/transport/failover-transport'; +import { IExporterTransport, ExportResponse } from '../../src'; + +const testPayload = Uint8Array.from([1, 2, 3]); +const timeoutMillis = 1000; + +describe('FailoverTransport', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('send', function () { + it('returns primary result when primary succeeds', async function () { + // arrange + const expectedResponse: ExportResponse = { status: 'success' }; + const primaryStubs = { + send: sinon.stub().resolves(expectedResponse), + shutdown: sinon.stub(), + }; + const failoverStubs = { + send: sinon.stub().resolves({ status: 'success' }), + shutdown: sinon.stub(), + }; + const transport = createFailoverTransport({ + primary: primaryStubs as IExporterTransport, + failover: failoverStubs as IExporterTransport, + }); + + // act + const result = await transport.send(testPayload, timeoutMillis); + + // assert + assert.deepEqual(result, expectedResponse); + sinon.assert.calledOnceWithExactly( + primaryStubs.send, + testPayload, + timeoutMillis + ); + sinon.assert.notCalled(failoverStubs.send); + }); + + it('returns primary result when primary returns retryable', async function () { + // arrange + const expectedResponse: ExportResponse = { status: 'retryable' }; + const primaryStubs = { + send: sinon.stub().resolves(expectedResponse), + shutdown: sinon.stub(), + }; + const failoverStubs = { + send: sinon.stub().resolves({ status: 'success' }), + shutdown: sinon.stub(), + }; + const transport = createFailoverTransport({ + primary: primaryStubs as IExporterTransport, + failover: failoverStubs as IExporterTransport, + }); + + // act + const result = await transport.send(testPayload, timeoutMillis); + + // assert + assert.deepEqual(result, expectedResponse); + sinon.assert.calledOnceWithExactly( + primaryStubs.send, + testPayload, + timeoutMillis + ); + sinon.assert.notCalled(failoverStubs.send); + }); + + it('switches to failover when primary fails', async function () { + // arrange + const failureResponse: ExportResponse = { + status: 'failure', + error: new Error('Primary failed'), + }; + const successResponse: ExportResponse = { status: 'success' }; + const primaryStubs = { + send: sinon.stub().resolves(failureResponse), + shutdown: sinon.stub(), + }; + const failoverStubs = { + send: sinon.stub().resolves(successResponse), + shutdown: sinon.stub(), + }; + const transport = createFailoverTransport({ + primary: primaryStubs as IExporterTransport, + failover: failoverStubs as IExporterTransport, + }); + + // act + const result = await transport.send(testPayload, timeoutMillis); + + // assert + assert.deepEqual(result, successResponse); + sinon.assert.calledOnceWithExactly( + primaryStubs.send, + testPayload, + timeoutMillis + ); + sinon.assert.calledOnceWithExactly( + failoverStubs.send, + testPayload, + timeoutMillis + ); + }); + + it('returns failover failure when both fail', async function () { + // arrange + const primaryFailure: ExportResponse = { + status: 'failure', + error: new Error('Primary failed'), + }; + const failoverFailure: ExportResponse = { + status: 'failure', + error: new Error('Failover failed'), + }; + const primaryStubs = { + send: sinon.stub().resolves(primaryFailure), + shutdown: sinon.stub(), + }; + const failoverStubs = { + send: sinon.stub().resolves(failoverFailure), + shutdown: sinon.stub(), + }; + const transport = createFailoverTransport({ + primary: primaryStubs as IExporterTransport, + failover: failoverStubs as IExporterTransport, + }); + + // act + const result = await transport.send(testPayload, timeoutMillis); + + // assert + assert.deepEqual(result, failoverFailure); + sinon.assert.calledOnceWithExactly( + primaryStubs.send, + testPayload, + timeoutMillis + ); + sinon.assert.calledOnceWithExactly( + failoverStubs.send, + testPayload, + timeoutMillis + ); + }); + + it('rejects when primary rejects', async function () { + // arrange + const expectedError = new Error('Primary rejected'); + const primaryStubs = { + send: sinon.stub().rejects(expectedError), + shutdown: sinon.stub(), + }; + const failoverStubs = { + send: sinon.stub().resolves({ status: 'success' }), + shutdown: sinon.stub(), + }; + const transport = createFailoverTransport({ + primary: primaryStubs as IExporterTransport, + failover: failoverStubs as IExporterTransport, + }); + + // act & assert + await assert.rejects(() => transport.send(testPayload, timeoutMillis)); + sinon.assert.calledOnceWithExactly( + primaryStubs.send, + testPayload, + timeoutMillis + ); + sinon.assert.notCalled(failoverStubs.send); + }); + + it('rejects when failover rejects after primary fails', async function () { + // arrange + const primaryFailure: ExportResponse = { + status: 'failure', + error: new Error('Primary failed'), + }; + const failoverError = new Error('Failover rejected'); + const primaryStubs = { + send: sinon.stub().resolves(primaryFailure), + shutdown: sinon.stub(), + }; + const failoverStubs = { + send: sinon.stub().rejects(failoverError), + shutdown: sinon.stub(), + }; + const transport = createFailoverTransport({ + primary: primaryStubs as IExporterTransport, + failover: failoverStubs as IExporterTransport, + }); + + // act & assert + await assert.rejects(() => transport.send(testPayload, timeoutMillis)); + sinon.assert.calledOnceWithExactly( + primaryStubs.send, + testPayload, + timeoutMillis + ); + sinon.assert.calledOnceWithExactly( + failoverStubs.send, + testPayload, + timeoutMillis + ); + }); + }); + + describe('shutdown', function () { + it('shuts down both transports', function () { + // arrange + const primaryStubs = { + send: sinon.stub(), + shutdown: sinon.stub(), + }; + const failoverStubs = { + send: sinon.stub(), + shutdown: sinon.stub(), + }; + const transport = createFailoverTransport({ + primary: primaryStubs as IExporterTransport, + failover: failoverStubs as IExporterTransport, + }); + + // act + transport.shutdown(); + + // assert + sinon.assert.calledOnce(primaryStubs.shutdown); + sinon.assert.calledOnce(failoverStubs.shutdown); + }); + }); +}); From 81255a590b3d3fbc25e447bccf682e2510cf0428 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sat, 31 Jan 2026 20:33:03 +0900 Subject: [PATCH 6/7] chore: add changelog entry for #6358 --- experimental/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 8603131571d..bd8f155b741 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -21,6 +21,7 @@ feat(configuration): parse config for rc 3 [#6304](https://github.com/open-telem * fix(exporter-prometheus): add missing `@opentelemetry/semantic-conventions` dependency [#6330](https://github.com/open-telemetry/opentelemetry-js/pull/6330) @omizha * fix(otlp-transformer): correctly handle Uint8Array attribute values when serializing to JSON [#6348](https://github.com/open-telemetry/opentelemetry-js/pull/6348) @pichlermarc +* fix(otlp-exporter-base): handle 64KiB limit for browser transports [#6358](https://github.com/open-telemetry/opentelemetry-js/pull/6358) @YangJonghun ### :books: Documentation From 2b4f6e065205c19c0b331ddf727dcf02971ad6d1 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sat, 31 Jan 2026 20:43:14 +0900 Subject: [PATCH 7/7] chore(otlp-exporter-base): fix lint error --- .../otlp-exporter-base/src/transport/fetch-transport.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts b/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts index 253fd2c428e..1beb9813ff6 100644 --- a/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts +++ b/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts @@ -53,7 +53,8 @@ class FetchTransport implements IExporterTransport { headers: await this._parameters.headers(), body: data, signal: abortController.signal, - keepalive: isBrowserEnvironment && data.byteLength < KEEPALIVE_BODY_SIZE_LIMIT, + keepalive: + isBrowserEnvironment && data.byteLength < KEEPALIVE_BODY_SIZE_LIMIT, mode: isBrowserEnvironment ? globalThis.location?.origin === url.origin ? 'same-origin'