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 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; }); 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..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 @@ -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,13 @@ export function createLegacyOtlpBrowserExportDelegate( export function inferExportDelegateToUse( configHeaders: OTLPExporterConfigBase['headers'] ) { - if (!configHeaders && typeof navigator.sendBeacon === 'function') { + 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/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..116b9b6e2da --- /dev/null +++ b/experimental/packages/otlp-exporter-base/src/transport/fetch-later-transport.ts @@ -0,0 +1,116 @@ +/* + * 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 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'); + } + 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); +} 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..9f23b7bc623 --- /dev/null +++ b/experimental/packages/otlp-exporter-base/test/browser/fetch-later-transport.test.ts @@ -0,0 +1,191 @@ +/* + * 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(); + }); + }); +});