From ee28487195d82e41f6a8a74c122d96121056cb8c Mon Sep 17 00:00:00 2001 From: YangJH Date: Sun, 14 Dec 2025 17:33:48 +0900 Subject: [PATCH 1/7] feat(exporters): implement fetch-later-transport --- .../create-legacy-browser-delegate.ts | 5 +- .../src/otlp-browser-http-export-delegate.ts | 14 +++ .../src/transport/fetch-later-transport.ts | 112 ++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 experimental/packages/otlp-exporter-base/src/transport/fetch-later-transport.ts diff --git a/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts b/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts index 9b1724b973c..13ed91874b8 100644 --- a/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts +++ b/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts @@ -16,6 +16,7 @@ import { ISerializer } from '@opentelemetry/otlp-transformer'; import { createOtlpFetchExportDelegate, + createOtlpFetchLaterExportDelegate, createOtlpSendBeaconExportDelegate, } from '../otlp-browser-http-export-delegate'; import { convertLegacyBrowserHttpOptions } from './convert-legacy-browser-http-options'; @@ -49,7 +50,9 @@ export function createLegacyOtlpBrowserExportDelegate( export function inferExportDelegateToUse( configHeaders: OTLPExporterConfigBase['headers'] ) { - if (!configHeaders && typeof navigator.sendBeacon === 'function') { + if ('fetchLater' in globalThis) { + return createOtlpFetchLaterExportDelegate; + } else if (!configHeaders && typeof navigator.sendBeacon === 'function') { return createOtlpSendBeaconExportDelegate; } 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..445f4fcd1c6 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 { createFetchLaterTransport } from './transport/fetch-later-transport'; export function createOtlpFetchExportDelegate( options: OtlpHttpConfiguration, @@ -49,3 +50,16 @@ export function createOtlpSendBeaconExportDelegate( }) ); } + +export function createOtlpFetchLaterExportDelegate( + options: OtlpHttpConfiguration, + serializer: ISerializer +): IOtlpExportDelegate { + return createOtlpNetworkExportDelegate( + options, + serializer, + createRetryingTransport({ + transport: createFetchLaterTransport(options), + }) + ); +} diff --git a/experimental/packages/otlp-exporter-base/src/transport/fetch-later-transport.ts b/experimental/packages/otlp-exporter-base/src/transport/fetch-later-transport.ts new file mode 100644 index 00000000000..1b8b5aaaa3f --- /dev/null +++ b/experimental/packages/otlp-exporter-base/src/transport/fetch-later-transport.ts @@ -0,0 +1,112 @@ +/* + * 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'; +import { HeadersFactory } from '../configuration/otlp-http-configuration'; + +export interface FetchLaterTransportParameters { + url: string; + headers: HeadersFactory; +} + +/** + * Result returned by fetchLater API + * @see https://developer.mozilla.org/docs/Web/API/Window/fetchLater + */ +interface FetchLaterResult { + readonly activated: boolean; +} + +/** + * Options for fetchLater, extends RequestInit with activateAfter + * @see https://developer.mozilla.org/docs/Web/API/DeferredRequestInit + */ +interface DeferredRequestInit extends RequestInit { + activateAfter?: number; +} + +/** + * fetchLater function type (browser-only API) + * TODO: Remove once fetchLater is available in lib.dom.d.ts + */ +type FetchLaterFn = ( + resource: string | URL | Request, + options?: DeferredRequestInit +) => FetchLaterResult; + +class FetchLaterTransport implements IExporterTransport { + private _parameters: FetchLaterTransportParameters; + + constructor(parameters: FetchLaterTransportParameters) { + this._parameters = parameters; + } + + async send(data: Uint8Array, timeoutMillis: number): Promise { + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), timeoutMillis); + let beaconResult: FetchLaterResult | undefined; + try { + // fetchLater with activateAfter: 0 sends immediately while providing + // reliability guarantees (request will be sent even if page is closed). + // See: https://fetch.spec.whatwg.org/#dom-window-fetchlater + const url = new URL(this._parameters.url); + const fetchLater = (globalThis as Record) + .fetchLater as FetchLaterFn; + + beaconResult = fetchLater(url.href, { + method: 'POST', + headers: await this._parameters.headers(), + body: data, + activateAfter: 0, + signal: abortController.signal, + mode: + globalThis.location?.origin === url.origin ? 'same-origin' : 'cors', + }); + + diag.debug('FetchLater request queued successfully'); + return { status: 'success' }; + } catch (error) { + // Handle QuotaExceededError specifically - this occurs when the 640KB quota is exceeded + if (error instanceof DOMException && error.name === 'QuotaExceededError') { + diag.warn('FetchLater quota exceeded, request not queued'); + } + return { + status: 'failure', + error: new Error('FetchLater request queued failed', { cause: error }), + }; + } finally { + if (typeof beaconResult === 'undefined' || beaconResult.activated) { + clearTimeout(timeout); + } + } + } + + shutdown() { + // Intentionally left empty, nothing to do. + } +} + +/** + * Creates an exporter transport that uses `fetchLater` to send the data. + * @param parameters applied to each request made by transport + */ +export function createFetchLaterTransport( + parameters: FetchLaterTransportParameters +): IExporterTransport { + return new FetchLaterTransport(parameters); +} From ee34d990106195f42c191cb9f8ae487ceb406817 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sun, 14 Dec 2025 18:18:36 +0900 Subject: [PATCH 2/7] refactor(exporters): refactor condition --- .../src/configuration/create-legacy-browser-delegate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts b/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts index 13ed91874b8..097e3c76b90 100644 --- a/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts +++ b/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts @@ -50,7 +50,7 @@ export function createLegacyOtlpBrowserExportDelegate( export function inferExportDelegateToUse( configHeaders: OTLPExporterConfigBase['headers'] ) { - if ('fetchLater' in globalThis) { + if ('fetchLater' in globalThis && typeof (globalThis as any).fetchLater !== 'undefined') { return createOtlpFetchLaterExportDelegate; } else if (!configHeaders && typeof navigator.sendBeacon === 'function') { return createOtlpSendBeaconExportDelegate; From aac914344282d807128456bb49c7f838dd4cce86 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sun, 14 Dec 2025 18:19:04 +0900 Subject: [PATCH 3/7] chore(exporters): add quota spec url --- .../otlp-exporter-base/src/transport/fetch-later-transport.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/experimental/packages/otlp-exporter-base/src/transport/fetch-later-transport.ts b/experimental/packages/otlp-exporter-base/src/transport/fetch-later-transport.ts index 1b8b5aaaa3f..159571828d2 100644 --- a/experimental/packages/otlp-exporter-base/src/transport/fetch-later-transport.ts +++ b/experimental/packages/otlp-exporter-base/src/transport/fetch-later-transport.ts @@ -81,7 +81,8 @@ class FetchLaterTransport implements IExporterTransport { diag.debug('FetchLater request queued successfully'); return { status: 'success' }; } catch (error) { - // Handle QuotaExceededError specifically - this occurs when the 640KB quota is exceeded + // Handle QuotaExceededError specifically - this occurs when quota is exceeded + // See: https://fetch.spec.whatwg.org/#deferred-fetch-quota if (error instanceof DOMException && error.name === 'QuotaExceededError') { diag.warn('FetchLater quota exceeded, request not queued'); } From 1e61997170f6cccaded6719488c490fb30e6fcd3 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sun, 14 Dec 2025 18:19:27 +0900 Subject: [PATCH 4/7] test(exporters): implement tests related fetchLater --- .../create-legacy-browser-delegate.test.ts | 50 +++-- .../browser/fetch-later-transport.test.ts | 184 ++++++++++++++++++ 2 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 experimental/packages/otlp-exporter-base/test/browser/fetch-later-transport.test.ts diff --git a/experimental/packages/otlp-exporter-base/test/browser/create-legacy-browser-delegate.test.ts b/experimental/packages/otlp-exporter-base/test/browser/create-legacy-browser-delegate.test.ts index 082ac935bba..b84c2807dbc 100644 --- a/experimental/packages/otlp-exporter-base/test/browser/create-legacy-browser-delegate.test.ts +++ b/experimental/packages/otlp-exporter-base/test/browser/create-legacy-browser-delegate.test.ts @@ -18,37 +18,61 @@ import * as assert from 'assert'; import { inferExportDelegateToUse } from '../../src/configuration/create-legacy-browser-delegate'; import { createOtlpFetchExportDelegate, + createOtlpFetchLaterExportDelegate, createOtlpSendBeaconExportDelegate, } from '../../src/otlp-browser-http-export-delegate'; describe('createLegacyBrowserDelegate', function () { - describe('when beacon and fetch are available', function () { - it('uses the beacon delegate when no headers are provided', function () { + describe('when fetchLater is available', function () { + it('uses the fetchLater delegate regardless of headers', function () { const delegate = inferExportDelegateToUse(undefined); - assert.equal(delegate, createOtlpSendBeaconExportDelegate); + assert.equal(delegate, createOtlpFetchLaterExportDelegate); }); - it('uses the fetch delegate when headers are provided', function () { + it('uses the fetchLater delegate when headers are provided', function () { const delegate = inferExportDelegateToUse({ foo: 'bar' }); - assert.equal(delegate, createOtlpFetchExportDelegate); + assert.equal(delegate, createOtlpFetchLaterExportDelegate); }); }); - describe('when beacon is unavailable', function () { - const sendBeacon = window.navigator.sendBeacon; + describe('when fetchLater is unavailable', function () { + const fetchLater = (window as any).fetchLater; beforeEach(function () { - // fake sendBeacon being unavailable - (window.navigator as any).sendBeacon = undefined; + // fake fetchLater being unavailable + (window as any).fetchLater = undefined; }); - afterEach(() => { - (window.navigator as any).sendBeacon = sendBeacon; + afterEach(function () { + (window as any).fetchLater = fetchLater; }); - describe('when fetch is available', function () { - it('uses the fetch delegate', function () { + describe('when beacon and fetch are available', function () { + it('uses the beacon delegate when no headers are provided', function () { const delegate = inferExportDelegateToUse(undefined); + assert.equal(delegate, createOtlpSendBeaconExportDelegate); + }); + + it('uses the fetch delegate when headers are provided', function () { + const delegate = inferExportDelegateToUse({ foo: 'bar' }); assert.equal(delegate, createOtlpFetchExportDelegate); }); }); + + describe('when beacon is unavailable', function () { + const sendBeacon = window.navigator.sendBeacon; + beforeEach(function () { + // fake sendBeacon being unavailable + (window.navigator as any).sendBeacon = undefined; + }); + afterEach(() => { + (window.navigator as any).sendBeacon = sendBeacon; + }); + + describe('when fetch is available', function () { + it('uses the fetch delegate', function () { + const delegate = inferExportDelegateToUse(undefined); + assert.equal(delegate, createOtlpFetchExportDelegate); + }); + }); + }); }); }); diff --git a/experimental/packages/otlp-exporter-base/test/browser/fetch-later-transport.test.ts b/experimental/packages/otlp-exporter-base/test/browser/fetch-later-transport.test.ts new file mode 100644 index 00000000000..009da908913 --- /dev/null +++ b/experimental/packages/otlp-exporter-base/test/browser/fetch-later-transport.test.ts @@ -0,0 +1,184 @@ +/* + * 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 { createFetchLaterTransport } from '../../src/transport/fetch-later-transport'; +import { ExportResponseFailure } from '../../src'; + +const testTransportParameters = { + url: 'http://example.test', + headers: async () => ({ + foo: 'foo-value', + bar: 'bar-value', + 'Content-Type': 'application/json', + }), +}; + +const requestTimeout = 1000; +const testPayload = Uint8Array.from([1, 2, 3]); + +describe('FetchLaterTransport', function () { + afterEach(function () { + sinon.restore(); + delete (globalThis as Record).fetchLater; + }); + + describe('send', function () { + it('returns success when fetchLater queues successfully (activated: true)', async function () { + // arrange + const fetchLaterStub = sinon + .stub() + .returns({ activated: true }); + (globalThis as Record).fetchLater = fetchLaterStub; + + const transport = createFetchLaterTransport(testTransportParameters); + + // act + const result = await transport.send(testPayload, requestTimeout); + + // assert + assert.strictEqual(result.status, 'success'); + sinon.assert.calledOnce(fetchLaterStub); + sinon.assert.calledWithMatch(fetchLaterStub, testTransportParameters.url, { + method: 'POST', + headers: { + foo: 'foo-value', + bar: 'bar-value', + 'Content-Type': 'application/json', + }, + body: testPayload, + activateAfter: 0, + }); + }); + + it('returns success when fetchLater queues successfully (activated: false)', async function () { + // arrange + const fetchLaterStub = sinon.stub().returns({ activated: false }); + (globalThis as Record).fetchLater = fetchLaterStub; + + const transport = createFetchLaterTransport(testTransportParameters); + + // act + const result = await transport.send(testPayload, requestTimeout); + + // assert + assert.strictEqual(result.status, 'success'); + sinon.assert.calledOnce(fetchLaterStub); + }); + + it('returns failure when fetchLater throws', async function () { + // arrange + const fetchLaterStub = sinon.stub().throws(new Error('fetchLater failed')); + (globalThis as Record).fetchLater = fetchLaterStub; + + const transport = createFetchLaterTransport(testTransportParameters); + + // act + const result = await transport.send(testPayload, requestTimeout); + + // assert + assert.strictEqual(result.status, 'failure'); + assert.strictEqual( + (result as ExportResponseFailure).error.message, + 'FetchLater request queued failed' + ); + }); + + it('returns failure when QuotaExceededError is thrown', async function () { + // arrange + const quotaError = new DOMException('Quota exceeded', 'QuotaExceededError'); + const fetchLaterStub = sinon.stub().throws(quotaError); + (globalThis as Record).fetchLater = fetchLaterStub; + + const transport = createFetchLaterTransport(testTransportParameters); + + // act + const result = await transport.send(testPayload, requestTimeout); + + // assert + assert.strictEqual(result.status, 'failure'); + assert.strictEqual( + (result as ExportResponseFailure).error.message, + 'FetchLater request queued failed' + ); + assert.strictEqual( + (result as ExportResponseFailure).error.cause, + quotaError + ); + }); + + it('passes AbortSignal to fetchLater', async function () { + // arrange + const fetchLaterStub = sinon.stub().returns({ activated: true }); + (globalThis as Record).fetchLater = fetchLaterStub; + + const transport = createFetchLaterTransport(testTransportParameters); + + // act + await transport.send(testPayload, requestTimeout); + + // assert + sinon.assert.calledOnce(fetchLaterStub); + const callArgs = fetchLaterStub.firstCall.args[1]; + assert.ok(callArgs.signal instanceof AbortSignal); + }); + + it('clears timeout when request is already activated', async function () { + // arrange + const clock = sinon.useFakeTimers(); + const fetchLaterStub = sinon.stub().returns({ activated: true }); + (globalThis as Record).fetchLater = fetchLaterStub; + + const transport = createFetchLaterTransport(testTransportParameters); + + // act + const resultPromise = transport.send(testPayload, requestTimeout); + const result = await resultPromise; + + // assert - timeout should be cleared, advancing clock should not cause issues + assert.strictEqual(result.status, 'success'); + clock.tick(requestTimeout + 100); + clock.restore(); + }); + + it('does not clear timeout when request is queued but not activated', async function () { + // arrange + const clock = sinon.useFakeTimers(); + let capturedSignal: AbortSignal | undefined; + const fetchLaterStub = sinon.stub().callsFake((_url, options) => { + capturedSignal = options?.signal; + return { activated: false }; + }); + (globalThis as Record).fetchLater = fetchLaterStub; + + const transport = createFetchLaterTransport(testTransportParameters); + + // act + const result = await transport.send(testPayload, requestTimeout); + + // assert - result should still be success (queued successfully) + assert.strictEqual(result.status, 'success'); + assert.ok(capturedSignal); + assert.strictEqual(capturedSignal!.aborted, false); + + // Advance time past the timeout - signal should be aborted + clock.tick(requestTimeout + 100); + assert.strictEqual(capturedSignal!.aborted, true); + clock.restore(); + }); + }); +}); From 1cba8302574d4f2aac34483d7519c141b7b86ba3 Mon Sep 17 00:00:00 2001 From: YangJH Date: Tue, 16 Dec 2025 03:09:29 +0900 Subject: [PATCH 5/7] style(exporters): fix lint error --- .../create-legacy-browser-delegate.ts | 6 ++- .../src/transport/fetch-later-transport.ts | 5 ++- .../browser/fetch-later-transport.test.ts | 37 +++++++++++-------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts b/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts index 097e3c76b90..521ae2c7e8f 100644 --- a/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts +++ b/experimental/packages/otlp-exporter-base/src/configuration/create-legacy-browser-delegate.ts @@ -50,7 +50,11 @@ export function createLegacyOtlpBrowserExportDelegate( export function inferExportDelegateToUse( configHeaders: OTLPExporterConfigBase['headers'] ) { - if ('fetchLater' in globalThis && typeof (globalThis as any).fetchLater !== 'undefined') { + if ( + 'fetchLater' in globalThis && + /* eslint-disable @typescript-eslint/no-explicit-any */ + typeof (globalThis as any).fetchLater !== 'undefined' + ) { return createOtlpFetchLaterExportDelegate; } else if (!configHeaders && typeof navigator.sendBeacon === 'function') { return createOtlpSendBeaconExportDelegate; diff --git a/experimental/packages/otlp-exporter-base/src/transport/fetch-later-transport.ts b/experimental/packages/otlp-exporter-base/src/transport/fetch-later-transport.ts index 159571828d2..116b9b6e2da 100644 --- a/experimental/packages/otlp-exporter-base/src/transport/fetch-later-transport.ts +++ b/experimental/packages/otlp-exporter-base/src/transport/fetch-later-transport.ts @@ -83,7 +83,10 @@ class FetchLaterTransport implements IExporterTransport { } catch (error) { // Handle QuotaExceededError specifically - this occurs when quota is exceeded // See: https://fetch.spec.whatwg.org/#deferred-fetch-quota - if (error instanceof DOMException && error.name === 'QuotaExceededError') { + if ( + error instanceof DOMException && + error.name === 'QuotaExceededError' + ) { diag.warn('FetchLater quota exceeded, request not queued'); } return { diff --git a/experimental/packages/otlp-exporter-base/test/browser/fetch-later-transport.test.ts b/experimental/packages/otlp-exporter-base/test/browser/fetch-later-transport.test.ts index 009da908913..9f23b7bc623 100644 --- a/experimental/packages/otlp-exporter-base/test/browser/fetch-later-transport.test.ts +++ b/experimental/packages/otlp-exporter-base/test/browser/fetch-later-transport.test.ts @@ -40,9 +40,7 @@ describe('FetchLaterTransport', function () { describe('send', function () { it('returns success when fetchLater queues successfully (activated: true)', async function () { // arrange - const fetchLaterStub = sinon - .stub() - .returns({ activated: true }); + const fetchLaterStub = sinon.stub().returns({ activated: true }); (globalThis as Record).fetchLater = fetchLaterStub; const transport = createFetchLaterTransport(testTransportParameters); @@ -53,16 +51,20 @@ describe('FetchLaterTransport', function () { // assert assert.strictEqual(result.status, 'success'); sinon.assert.calledOnce(fetchLaterStub); - sinon.assert.calledWithMatch(fetchLaterStub, testTransportParameters.url, { - method: 'POST', - headers: { - foo: 'foo-value', - bar: 'bar-value', - 'Content-Type': 'application/json', - }, - body: testPayload, - activateAfter: 0, - }); + sinon.assert.calledWithMatch( + fetchLaterStub, + testTransportParameters.url, + { + method: 'POST', + headers: { + foo: 'foo-value', + bar: 'bar-value', + 'Content-Type': 'application/json', + }, + body: testPayload, + activateAfter: 0, + } + ); }); it('returns success when fetchLater queues successfully (activated: false)', async function () { @@ -82,7 +84,9 @@ describe('FetchLaterTransport', function () { it('returns failure when fetchLater throws', async function () { // arrange - const fetchLaterStub = sinon.stub().throws(new Error('fetchLater failed')); + const fetchLaterStub = sinon + .stub() + .throws(new Error('fetchLater failed')); (globalThis as Record).fetchLater = fetchLaterStub; const transport = createFetchLaterTransport(testTransportParameters); @@ -100,7 +104,10 @@ describe('FetchLaterTransport', function () { it('returns failure when QuotaExceededError is thrown', async function () { // arrange - const quotaError = new DOMException('Quota exceeded', 'QuotaExceededError'); + const quotaError = new DOMException( + 'Quota exceeded', + 'QuotaExceededError' + ); const fetchLaterStub = sinon.stub().throws(quotaError); (globalThis as Record).fetchLater = fetchLaterStub; From 1d7152fc4ebfc78f5239945b2fe683350aa55454 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sat, 31 Jan 2026 15:57:59 +0900 Subject: [PATCH 6/7] chore: edit changelog --- experimental/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 8603131571d..daaf7b89057 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -15,7 +15,8 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :rocket: Features -feat(configuration): parse config for rc 3 [#6304](https://github.com/open-telemetry/opentelemetry-js/pull/6304) @maryliag +* feat(otlp-exporter-base): implement fetch-later-transport [#6217](https://github.com/open-telemetry/opentelemetry-js/pull/6217) @YangJonghun +* feat(configuration): parse config for rc 3 [#6304](https://github.com/open-telemetry/opentelemetry-js/pull/6304) @maryliag ### :bug: Bug Fixes From 7ee5cac3159b4933ab1c7fa6138fbf90e0ab6187 Mon Sep 17 00:00:00 2001 From: YangJH Date: Sat, 31 Jan 2026 16:16:12 +0900 Subject: [PATCH 7/7] test(exporters): add fetchLater tests for browser exporters --- .../test/browser/OTLPLogExporter.test.ts | 33 ++++++++++++++ .../test/browser/OTLPLogExporter.test.ts | 33 ++++++++++++++ .../test/browser/OTLPTraceExporter.test.ts | 33 ++++++++++++++ .../test/browser/OTLPTraceExporter.test.ts | 33 ++++++++++++++ .../test/browser/OTLPMetricExporter.test.ts | 43 +++++++++++++++++++ .../test/browser/OTLPMetricExporter.test.ts | 43 +++++++++++++++++++ 6 files changed, 218 insertions(+) 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..bdef3754dea 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 @@ -32,10 +32,41 @@ import { describe('OTLPLogExporter', function () { afterEach(() => { sinon.restore(); + delete (globalThis as any).fetchLater; }); describe('export', function () { + describe('when fetchLater is available', function () { + it('should successfully send data using fetchLater', async function () { + // arrange + const fetchLaterStub = sinon.stub().returns({ activated: false }); + (globalThis as any).fetchLater = fetchLaterStub; + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], + }); + + // act + loggerProvider.getLogger('test-logger').emit({ body: 'test-body' }); + await loggerProvider.shutdown(); + + // assert + assert.ok(fetchLaterStub.called, 'fetchLater should be called'); + const [url, options] = fetchLaterStub.args[0]; + assert.ok(url.endsWith('/v1/logs'), 'URL should end with /v1/logs'); + assert.strictEqual(options.method, 'POST'); + assert.doesNotThrow( + () => JSON.parse(new TextDecoder().decode(options.body)), + 'expected requestBody to be in JSON format, but parsing failed' + ); + }); + }); + describe('when sendBeacon is available', function () { + beforeEach(function () { + // disable fetchLater so sendBeacon is used + (globalThis as any).fetchLater = undefined; + }); + it('should successfully send data using sendBeacon', async function () { // arrange const stubBeacon = sinon.stub(navigator, 'sendBeacon'); @@ -60,6 +91,8 @@ describe('OTLPLogExporter', function () { describe('when sendBeacon is not available', function () { beforeEach(function () { + // disable fetchLater so fetch is used + (globalThis as any).fetchLater = undefined; // fake sendBeacon not being available (window.navigator as any).sendBeacon = false; }); 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..932bda4908c 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 @@ -32,10 +32,41 @@ import { describe('OTLPLogExporter', function () { afterEach(() => { sinon.restore(); + delete (globalThis as any).fetchLater; }); describe('export', function () { + describe('when fetchLater is available', function () { + it('should successfully send data using fetchLater', async function () { + // arrange + const fetchLaterStub = sinon.stub().returns({ activated: false }); + (globalThis as any).fetchLater = fetchLaterStub; + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], + }); + + // act + loggerProvider.getLogger('test-logger').emit({ body: 'test-body' }); + await loggerProvider.shutdown(); + + // assert + assert.ok(fetchLaterStub.called, 'fetchLater should be called'); + const [url, options] = fetchLaterStub.args[0]; + assert.ok(url.endsWith('/v1/logs'), 'URL should end with /v1/logs'); + assert.strictEqual(options.method, 'POST'); + assert.throws( + () => JSON.parse(new TextDecoder().decode(options.body)), + 'expected requestBody to be in protobuf format, but parsing as JSON succeeded' + ); + }); + }); + describe('when sendBeacon is available', function () { + beforeEach(function () { + // disable fetchLater so sendBeacon is used + (globalThis as any).fetchLater = undefined; + }); + it('should successfully send data using sendBeacon', async function () { // arrange const stubBeacon = sinon.stub(navigator, 'sendBeacon'); @@ -60,6 +91,8 @@ describe('OTLPLogExporter', function () { describe('when sendBeacon is not available', function () { beforeEach(function () { + // disable fetchLater so fetch is used + (globalThis as any).fetchLater = undefined; // fake sendBeacon not being available (window.navigator as any).sendBeacon = false; }); 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..2b241075f9a 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 @@ -33,10 +33,41 @@ import { OTLPTraceExporter } from '../../src/platform/browser/index'; describe('OTLPTraceExporter', () => { afterEach(() => { sinon.restore(); + delete (globalThis as any).fetchLater; }); describe('export', function () { + describe('when fetchLater is available', function () { + it('should successfully send data using fetchLater', async function () { + // arrange + const fetchLaterStub = sinon.stub().returns({ activated: false }); + (globalThis as any).fetchLater = fetchLaterStub; + const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], + }); + + // act + tracerProvider.getTracer('test-tracer').startSpan('test-span').end(); + await tracerProvider.shutdown(); + + // assert + assert.ok(fetchLaterStub.called, 'fetchLater should be called'); + const [url, options] = fetchLaterStub.args[0]; + assert.ok(url.endsWith('/v1/traces'), 'URL should end with /v1/traces'); + assert.strictEqual(options.method, 'POST'); + assert.doesNotThrow( + () => JSON.parse(new TextDecoder().decode(options.body)), + 'expected requestBody to be in JSON format, but parsing failed' + ); + }); + }); + describe('when sendBeacon is available', function () { + beforeEach(function () { + // disable fetchLater so sendBeacon is used + (globalThis as any).fetchLater = undefined; + }); + it('should successfully send data using sendBeacon', async function () { // arrange const stubBeacon = sinon.stub(navigator, 'sendBeacon'); @@ -61,6 +92,8 @@ describe('OTLPTraceExporter', () => { describe('when sendBeacon is not available', function () { beforeEach(function () { + // disable fetchLater so fetch is used + (globalThis as any).fetchLater = undefined; // fake sendBeacon not being available (window.navigator as any).sendBeacon = false; }); 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..83460ccf06a 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 @@ -33,10 +33,41 @@ import { OTLPTraceExporter } from '../../src/platform/browser/index'; describe('OTLPTraceExporter', () => { afterEach(() => { sinon.restore(); + delete (globalThis as any).fetchLater; }); describe('export', function () { + describe('when fetchLater is available', function () { + it('should successfully send data using fetchLater', async function () { + // arrange + const fetchLaterStub = sinon.stub().returns({ activated: false }); + (globalThis as any).fetchLater = fetchLaterStub; + const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], + }); + + // act + tracerProvider.getTracer('test-tracer').startSpan('test-span').end(); + await tracerProvider.shutdown(); + + // assert + assert.ok(fetchLaterStub.called, 'fetchLater should be called'); + const [url, options] = fetchLaterStub.args[0]; + assert.ok(url.endsWith('/v1/traces'), 'URL should end with /v1/traces'); + assert.strictEqual(options.method, 'POST'); + assert.throws( + () => JSON.parse(new TextDecoder().decode(options.body)), + 'expected requestBody to be in protobuf format, but parsing as JSON succeeded' + ); + }); + }); + describe('when sendBeacon is available', function () { + beforeEach(function () { + // disable fetchLater so sendBeacon is used + (globalThis as any).fetchLater = undefined; + }); + it('should successfully send data using sendBeacon', async function () { // arrange const stubBeacon = sinon.stub(navigator, 'sendBeacon'); @@ -61,6 +92,8 @@ describe('OTLPTraceExporter', () => { describe('when sendBeacon is not available', function () { beforeEach(function () { + // disable fetchLater so fetch is used + (globalThis as any).fetchLater = undefined; // fake sendBeacon not being available (window.navigator as any).sendBeacon = false; }); 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..191f29b7d3c 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 @@ -37,10 +37,51 @@ import { OTLPMetricExporter } from '../../src/platform/browser'; describe('OTLPMetricExporter', function () { afterEach(() => { sinon.restore(); + delete (globalThis as any).fetchLater; }); describe('export', function () { + describe('when fetchLater is available', function () { + it('should successfully send data using fetchLater', async function () { + // arrange + const fetchLaterStub = sinon.stub().returns({ activated: false }); + (globalThis as any).fetchLater = fetchLaterStub; + const meterProvider = new MeterProvider({ + readers: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + }), + ], + }); + + // act + meterProvider + .getMeter('test-meter') + .createCounter('test-counter') + .add(1); + await meterProvider.shutdown(); + + // assert + assert.ok(fetchLaterStub.called, 'fetchLater should be called'); + const [url, options] = fetchLaterStub.args[0]; + assert.ok( + url.endsWith('/v1/metrics'), + 'URL should end with /v1/metrics' + ); + assert.strictEqual(options.method, 'POST'); + assert.doesNotThrow( + () => JSON.parse(new TextDecoder().decode(options.body)), + 'expected requestBody to be in JSON format, but parsing failed' + ); + }); + }); + describe('when sendBeacon is available', function () { + beforeEach(function () { + // disable fetchLater so sendBeacon is used + (globalThis as any).fetchLater = undefined; + }); + it('should successfully send data using sendBeacon', async function () { // arrange const stubBeacon = sinon.stub(navigator, 'sendBeacon'); @@ -72,6 +113,8 @@ describe('OTLPMetricExporter', function () { describe('when sendBeacon is not available', function () { beforeEach(function () { + // disable fetchLater so fetch is used + (globalThis as any).fetchLater = undefined; // fake sendBeacon not being available (window.navigator as any).sendBeacon = false; }); 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..07d057a3fdf 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 @@ -33,10 +33,51 @@ import { OTLPMetricExporter } from '../../src/platform/browser'; describe('OTLPTraceExporter', () => { afterEach(() => { sinon.restore(); + delete (globalThis as any).fetchLater; }); describe('export', function () { + describe('when fetchLater is available', function () { + it('should successfully send data using fetchLater', async function () { + // arrange + const fetchLaterStub = sinon.stub().returns({ activated: false }); + (globalThis as any).fetchLater = fetchLaterStub; + const meterProvider = new MeterProvider({ + readers: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + }), + ], + }); + + // act + meterProvider + .getMeter('test-meter') + .createCounter('test-counter') + .add(1); + await meterProvider.shutdown(); + + // assert + assert.ok(fetchLaterStub.called, 'fetchLater should be called'); + const [url, options] = fetchLaterStub.args[0]; + assert.ok( + url.endsWith('/v1/metrics'), + 'URL should end with /v1/metrics' + ); + assert.strictEqual(options.method, 'POST'); + assert.throws( + () => JSON.parse(new TextDecoder().decode(options.body)), + 'expected requestBody to be in protobuf format, but parsing as JSON succeeded' + ); + }); + }); + describe('when sendBeacon is available', function () { + beforeEach(function () { + // disable fetchLater so sendBeacon is used + (globalThis as any).fetchLater = undefined; + }); + it('should successfully send data using sendBeacon', async function () { // arrange const stubBeacon = sinon.stub(navigator, 'sendBeacon'); @@ -68,6 +109,8 @@ describe('OTLPTraceExporter', () => { describe('when sendBeacon is not available', function () { beforeEach(function () { + // disable fetchLater so fetch is used + (globalThis as any).fetchLater = undefined; // fake sendBeacon not being available (window.navigator as any).sendBeacon = false; });