diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d554449f440..4de6308739e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -25,9 +25,11 @@ experimental/packages/api-logs/ @open-telemetry/javascript-maintainers # Any browser-specific code is co-owned by Browser SIG and JS SIG (list more packages here as needed): bundler-tests/ @open-telemetry/browser-maintainers @open-telemetry/javascript-approvers examples/opentelemetry-web/ @open-telemetry/browser-maintainers @open-telemetry/javascript-approvers +experimental/packages/**/*browser* @open-telemetry/browser-maintainers @open-telemetry/javascript-approvers experimental/packages/opentelemetry-browser-detector/ @open-telemetry/browser-maintainers @open-telemetry/javascript-approvers experimental/packages/opentelemetry-instrumentation-fetch/ @open-telemetry/browser-maintainers @open-telemetry/javascript-approvers experimental/packages/opentelemetry-instrumentation-xml-http-request/ @open-telemetry/browser-maintainers @open-telemetry/javascript-approvers +experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts @open-telemetry/browser-maintainers @open-telemetry/javascript-approvers experimental/packages/web-common/ @open-telemetry/browser-maintainers @open-telemetry/javascript-approvers packages/opentelemetry-context-zone/ @open-telemetry/browser-maintainers @open-telemetry/javascript-approvers packages/opentelemetry-context-zone-peer-dep/ @open-telemetry/browser-maintainers @open-telemetry/javascript-approvers diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index acfe42cca35..c64dce988b5 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -12,6 +12,9 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :bug: Bug Fixes +* fix(otlp-exporter-base): remove sendBeacon in favor of fetch with keepalive [#6391](https://github.com/open-telemetry/opentelemetry-js/pull/6391) @overbalance + * (user-facing) createOtlpSendBeaconExportDelegate will be removed in a future version + ### :books: Documentation ### :house: Internal 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..07309a384be 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 @@ -35,56 +35,26 @@ describe('OTLPLogExporter', function () { }); describe('export', function () { - describe('when sendBeacon is available', function () { - it('should successfully send data using sendBeacon', async function () { - // arrange - const stubBeacon = sinon.stub(navigator, 'sendBeacon'); - const loggerProvider = new LoggerProvider({ - processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], - }); - - // act - loggerProvider.getLogger('test-logger').emit({ body: 'test-body' }); - await loggerProvider.shutdown(); - - // assert - const args = stubBeacon.args[0]; - const blob: Blob = args[1] as unknown as Blob; - const body = await blob.text(); - assert.doesNotThrow( - () => JSON.parse(body), - 'expected requestBody to be in JSON format, but parsing failed' - ); - }); - }); - - describe('when sendBeacon is not available', function () { - beforeEach(function () { - // fake sendBeacon not being available - (window.navigator as any).sendBeacon = false; + it('should successfully send data using fetch', async function () { + // arrange + const stubFetch = sinon + .stub(window, 'fetch') + .resolves(new Response('', { status: 200 })); + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], }); - it('should successfully send data using fetch', async function () { - // arrange - const stubFetch = sinon - .stub(window, 'fetch') - .resolves(new Response('', { status: 200 })); - const loggerProvider = new LoggerProvider({ - processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], - }); - - // act - loggerProvider.getLogger('test-logger').emit({ body: 'test-body' }); - await loggerProvider.shutdown(); - - // assert - const request = new Request(...stubFetch.args[0]); - const body = await request.text(); - assert.doesNotThrow( - () => JSON.parse(body), - 'expected requestBody to be in JSON format, but parsing failed' - ); - }); + // act + loggerProvider.getLogger('test-logger').emit({ body: 'test-body' }); + await loggerProvider.shutdown(); + + // assert + const request = new Request(...stubFetch.args[0]); + const body = await request.text(); + assert.doesNotThrow( + () => JSON.parse(body), + 'expected requestBody to be in JSON format, but parsing failed' + ); }); }); }); 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..f2d1c7c8857 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 @@ -35,56 +35,26 @@ describe('OTLPLogExporter', function () { }); describe('export', function () { - describe('when sendBeacon is available', function () { - it('should successfully send data using sendBeacon', async function () { - // arrange - const stubBeacon = sinon.stub(navigator, 'sendBeacon'); - const loggerProvider = new LoggerProvider({ - processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], - }); - - // act - loggerProvider.getLogger('test-logger').emit({ body: 'test-body' }); - await loggerProvider.shutdown(); - - // assert - const args = stubBeacon.args[0]; - const blob: Blob = args[1] as unknown as Blob; - const body = await blob.text(); - assert.throws( - () => JSON.parse(body), - 'expected requestBody to be in protobuf format, but parsing as JSON succeeded' - ); - }); - }); - - describe('when sendBeacon is not available', function () { - beforeEach(function () { - // fake sendBeacon not being available - (window.navigator as any).sendBeacon = false; + it('should successfully send data using fetch', async function () { + // arrange + const stubFetch = sinon + .stub(window, 'fetch') + .resolves(new Response('', { status: 200 })); + const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], }); - it('should successfully send data using fetch', async function () { - // arrange - const stubFetch = sinon - .stub(window, 'fetch') - .resolves(new Response('', { status: 200 })); - const loggerProvider = new LoggerProvider({ - processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], - }); - - // act - loggerProvider.getLogger('test-logger').emit({ body: 'test-body' }); - await loggerProvider.shutdown(); - - // assert - const request = new Request(...stubFetch.args[0]); - const body = await request.text(); - assert.throws( - () => JSON.parse(body), - 'expected requestBody to be in protobuf format, but parsing as JSON succeeded' - ); - }); + // act + loggerProvider.getLogger('test-logger').emit({ body: 'test-body' }); + await loggerProvider.shutdown(); + + // assert + const request = new Request(...stubFetch.args[0]); + const body = await request.text(); + assert.throws( + () => JSON.parse(body), + 'expected requestBody to be in protobuf format, but parsing as JSON succeeded' + ); }); }); }); 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..02ee867d017 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 @@ -36,56 +36,26 @@ describe('OTLPTraceExporter', () => { }); describe('export', function () { - describe('when sendBeacon is available', function () { - it('should successfully send data using sendBeacon', async function () { - // arrange - const stubBeacon = sinon.stub(navigator, 'sendBeacon'); - const tracerProvider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], - }); - - // act - tracerProvider.getTracer('test-tracer').startSpan('test-span').end(); - await tracerProvider.shutdown(); - - // assert - const args = stubBeacon.args[0]; - const blob: Blob = args[1] as unknown as Blob; - const body = await blob.text(); - assert.doesNotThrow( - () => JSON.parse(body), - 'expected requestBody to be in JSON format, but parsing failed' - ); - }); - }); - - describe('when sendBeacon is not available', function () { - beforeEach(function () { - // fake sendBeacon not being available - (window.navigator as any).sendBeacon = false; + it('should successfully send data using fetch', async function () { + // arrange + const stubFetch = sinon + .stub(window, 'fetch') + .resolves(new Response('', { status: 200 })); + const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], }); - it('should successfully send data using fetch', async function () { - // arrange - const stubFetch = sinon - .stub(window, 'fetch') - .resolves(new Response('', { status: 200 })); - const tracerProvider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], - }); - - // act - tracerProvider.getTracer('test-tracer').startSpan('test-span').end(); - await tracerProvider.shutdown(); - - // assert - const request = new Request(...stubFetch.args[0]); - const body = await request.text(); - assert.doesNotThrow( - () => JSON.parse(body), - 'expected requestBody to be in JSON format, but parsing failed' - ); - }); + // act + tracerProvider.getTracer('test-tracer').startSpan('test-span').end(); + await tracerProvider.shutdown(); + + // assert + const request = new Request(...stubFetch.args[0]); + const body = await request.text(); + assert.doesNotThrow( + () => JSON.parse(body), + 'expected requestBody to be in JSON format, but parsing failed' + ); }); }); }); 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..b0b49447777 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 @@ -36,56 +36,26 @@ describe('OTLPTraceExporter', () => { }); describe('export', function () { - describe('when sendBeacon is available', function () { - it('should successfully send data using sendBeacon', async function () { - // arrange - const stubBeacon = sinon.stub(navigator, 'sendBeacon'); - const tracerProvider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], - }); - - // act - tracerProvider.getTracer('test-tracer').startSpan('test-span').end(); - await tracerProvider.shutdown(); - - // assert - const args = stubBeacon.args[0]; - const blob: Blob = args[1] as unknown as Blob; - const body = await blob.text(); - assert.throws( - () => JSON.parse(body), - 'expected requestBody to be in protobuf format, but parsing as JSON succeeded' - ); - }); - }); - - describe('when sendBeacon is not available', function () { - beforeEach(function () { - // fake sendBeacon not being available - (window.navigator as any).sendBeacon = false; + it('should successfully send data using fetch', async function () { + // arrange + const stubFetch = sinon + .stub(window, 'fetch') + .resolves(new Response('', { status: 200 })); + const tracerProvider = new BasicTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], }); - it('should successfully send data using fetch', async function () { - // arrange - const stubFetch = sinon - .stub(window, 'fetch') - .resolves(new Response('', { status: 200 })); - const tracerProvider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], - }); - - // act - tracerProvider.getTracer('test-tracer').startSpan('test-span').end(); - await tracerProvider.shutdown(); - - // assert - const request = new Request(...stubFetch.args[0]); - const body = await request.text(); - assert.throws( - () => JSON.parse(body), - 'expected request body to be in protobuf format, but parsing as JSON succeeded' - ); - }); + // act + tracerProvider.getTracer('test-tracer').startSpan('test-span').end(); + await tracerProvider.shutdown(); + + // assert + const request = new Request(...stubFetch.args[0]); + const body = await request.text(); + assert.throws( + () => JSON.parse(body), + 'expected request body to be in protobuf format, but parsing as JSON succeeded' + ); }); }); }); 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..96f79c9b265 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 @@ -40,70 +40,30 @@ describe('OTLPMetricExporter', function () { }); describe('export', function () { - describe('when sendBeacon is available', function () { - it('should successfully send data using sendBeacon', async function () { - // arrange - const stubBeacon = sinon.stub(navigator, 'sendBeacon'); - const meterProvider = new MeterProvider({ - readers: [ - new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter(), - }), - ], - }); - - // act - meterProvider - .getMeter('test-meter') - .createCounter('test-counter') - .add(1); - await meterProvider.shutdown(); - - // assert - const args = stubBeacon.args[0]; - const blob: Blob = args[1] as unknown as Blob; - const body = await blob.text(); - assert.doesNotThrow( - () => JSON.parse(body), - 'expected requestBody to be in JSON format, but parsing failed' - ); - }); - }); - - describe('when sendBeacon is not available', function () { - beforeEach(function () { - // fake sendBeacon not being available - (window.navigator as any).sendBeacon = false; + it('should successfully send data using fetch', async function () { + // arrange + const stubFetch = sinon + .stub(window, 'fetch') + .resolves(new Response('', { status: 200 })); + const meterProvider = new MeterProvider({ + readers: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + }), + ], }); - it('should successfully send data using fetch', async function () { - // arrange - const stubFetch = sinon - .stub(window, 'fetch') - .resolves(new Response('', { status: 200 })); - const meterProvider = new MeterProvider({ - readers: [ - new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter(), - }), - ], - }); - - // act - meterProvider - .getMeter('test-meter') - .createCounter('test-counter') - .add(1); - await meterProvider.shutdown(); + // act + meterProvider.getMeter('test-meter').createCounter('test-counter').add(1); + await meterProvider.shutdown(); - // assert - const request = new Request(...stubFetch.args[0]); - const body = await request.text(); - assert.doesNotThrow( - () => JSON.parse(body), - 'expected request body to be in JSON format, but parsing failed' - ); - }); + // assert + const request = new Request(...stubFetch.args[0]); + const body = await request.text(); + assert.doesNotThrow( + () => JSON.parse(body), + 'expected request body to be in JSON format, but parsing failed' + ); }); }); 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..f1c47478d14 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 @@ -30,77 +30,37 @@ import { OTLPMetricExporter } from '../../src/platform/browser'; * - `@opentelemetry/otlp-grpc-exporter-base`: gRPC transport */ -describe('OTLPTraceExporter', () => { +describe('OTLPMetricExporter', () => { afterEach(() => { sinon.restore(); }); describe('export', function () { - describe('when sendBeacon is available', function () { - it('should successfully send data using sendBeacon', async function () { - // arrange - const stubBeacon = sinon.stub(navigator, 'sendBeacon'); - const meterProvider = new MeterProvider({ - readers: [ - new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter(), - }), - ], - }); - - // act - meterProvider - .getMeter('test-meter') - .createCounter('test-counter') - .add(1); - await meterProvider.shutdown(); - - // assert - const args = stubBeacon.args[0]; - const blob: Blob = args[1] as unknown as Blob; - const body = await blob.text(); - assert.throws( - () => JSON.parse(body), - 'expected requestBody to be in protobuf format, but parsing as JSON succeeded' - ); + it('should successfully send data using fetch', async function () { + // arrange + const stubFetch = sinon + .stub(window, 'fetch') + .resolves(new Response('', { status: 200 })); + const meterProvider = new MeterProvider({ + readers: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + }), + ], }); - }); - describe('when sendBeacon is not available', function () { - beforeEach(function () { - // fake sendBeacon not being available - (window.navigator as any).sendBeacon = false; - }); + // act + meterProvider.getMeter('test-meter').createCounter('test-counter').add(1); - it('should successfully send data using fetch', async function () { - // arrange - const stubFetch = sinon - .stub(window, 'fetch') - .resolves(new Response('', { status: 200 })); - const meterProvider = new MeterProvider({ - readers: [ - new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter(), - }), - ], - }); + await meterProvider.shutdown(); - // act - meterProvider - .getMeter('test-meter') - .createCounter('test-counter') - .add(1); - - await meterProvider.shutdown(); - - // assert - const request = new Request(...stubFetch.args[0]); - const body = await request.text(); - assert.throws( - () => JSON.parse(body), - 'expected requestBody to be in protobuf format, but parsing as JSON succeeded' - ); - }); + // assert + const request = new Request(...stubFetch.args[0]); + const body = await request.text(); + assert.throws( + () => JSON.parse(body), + 'expected requestBody to be in protobuf format, but parsing as JSON succeeded' + ); }); }); }); 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..01eb21382b2 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 @@ -14,10 +14,7 @@ * limitations under the License. */ import { ISerializer } from '@opentelemetry/otlp-transformer'; -import { - createOtlpFetchExportDelegate, - createOtlpSendBeaconExportDelegate, -} from '../otlp-browser-http-export-delegate'; +import { createOtlpFetchExportDelegate } from '../otlp-browser-http-export-delegate'; import { convertLegacyBrowserHttpOptions } from './convert-legacy-browser-http-options'; import { IOtlpExportDelegate } from '../otlp-export-delegate'; import { OTLPExporterConfigBase } from './legacy-base-configuration'; @@ -35,23 +32,11 @@ export function createLegacyOtlpBrowserExportDelegate( signalResourcePath: string, requiredHeaders: Record ): IOtlpExportDelegate { - const createOtlpExportDelegate = inferExportDelegateToUse(config.headers); - const options = convertLegacyBrowserHttpOptions( config, signalResourcePath, requiredHeaders ); - return createOtlpExportDelegate(options, serializer); -} - -export function inferExportDelegateToUse( - configHeaders: OTLPExporterConfigBase['headers'] -) { - if (!configHeaders && typeof navigator.sendBeacon === 'function') { - return createOtlpSendBeaconExportDelegate; - } - - return createOtlpFetchExportDelegate; + return createOtlpFetchExportDelegate(options, serializer); } diff --git a/experimental/packages/otlp-exporter-base/src/index-browser-http.ts b/experimental/packages/otlp-exporter-base/src/index-browser-http.ts index 6ab65d75ce8..5dff0c97be0 100644 --- a/experimental/packages/otlp-exporter-base/src/index-browser-http.ts +++ b/experimental/packages/otlp-exporter-base/src/index-browser-http.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +/** + * @deprecated Use `createOtlpFetchExportDelegate` instead. + */ export { createOtlpSendBeaconExportDelegate } from './otlp-browser-http-export-delegate'; export { convertLegacyBrowserHttpOptions } from './configuration/convert-legacy-browser-http-options'; 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..32728fdab7c 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 @@ -17,7 +17,6 @@ import { OtlpHttpConfiguration } from './configuration/otlp-http-configuration'; import { ISerializer } from '@opentelemetry/otlp-transformer'; import { IOtlpExportDelegate } from './otlp-export-delegate'; 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'; @@ -34,18 +33,12 @@ export function createOtlpFetchExportDelegate( ); } +/** + * @deprecated Use {@link createOtlpFetchExportDelegate} instead. Modern browsers use `fetch` with `keepAlive: true` when `sendBeacon` is used. Use a `fetch` polyfill that mimics this behavior to keep using `sendBeacon`. + */ export function createOtlpSendBeaconExportDelegate( options: OtlpHttpConfiguration, serializer: ISerializer ): IOtlpExportDelegate { - return createOtlpNetworkExportDelegate( - options, - serializer, - createRetryingTransport({ - transport: createSendBeaconTransport({ - url: options.url, - headers: options.headers, - }), - }) - ); + return createOtlpFetchExportDelegate(options, serializer); } 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 634f6c92a5b..57456e561d0 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,34 @@ import { } from '../is-export-retryable'; import { HeadersFactory } from '../configuration/otlp-http-configuration'; +/** + * Maximum total body size for concurrent keepalive requests. + * Browsers enforce a 64KiB cumulative limit across all pending keepalive requests. + * We use 60KB to leave headroom for headers. + * @see https://github.com/whatwg/fetch/issues/679 + * @see https://blog.huli.tw/2025/01/06/en/navigator-sendbeacon-64kib-and-source-code/ + */ +const MAX_KEEPALIVE_BODY_SIZE = 60 * 1024; + +/** + * Maximum concurrent keepalive requests. + * Chrome enforces 9 concurrent keepalive fetch requests per renderer process. + * @see https://github.com/whatwg/fetch/issues/679 + * Quote: "If the renderer process is processing more than 9 requests with keepalive set, we reject a new request" + */ +const MAX_KEEPALIVE_REQUESTS = 9; + +/** + * Track cumulative pending body size across all in-flight keepalive requests. + * This is necessary because the 64KiB limit is cumulative, not per-request. + */ +let pendingBodySize = 0; + +/** + * Track number of pending keepalive requests. + */ +let pendingKeepaliveCount = 0; + export interface FetchTransportParameters { url: string; headers: HeadersFactory; @@ -51,36 +79,59 @@ class FetchTransport implements IExporterTransport { fetchApi = fetchApi.__original; } + const requestSize = data.byteLength; + + // Determine if we can use keepalive based on cumulative browser limits. + // We must check BEFORE adding to pending totals to avoid exceeding limits. + const wouldExceedSize = + pendingBodySize + requestSize > MAX_KEEPALIVE_BODY_SIZE; + const wouldExceedCount = pendingKeepaliveCount >= MAX_KEEPALIVE_REQUESTS; + const useKeepalive = !wouldExceedSize && !wouldExceedCount; + + if (useKeepalive) { + pendingBodySize += requestSize; + pendingKeepaliveCount++; + } else { + const reason = wouldExceedSize ? 'size limit' : 'count limit'; + diag.debug( + `keepalive disabled: ${(requestSize / 1024).toFixed(1)}KB payload, ${pendingKeepaliveCount} pending (${reason})` + ); + } + try { - const isBrowserEnvironment = !!globalThis.location; const url = new URL(this._parameters.url); const response = await fetchApi(url.href, { method: 'POST', headers: await this._parameters.headers(), body: data, signal: abortController.signal, - keepalive: isBrowserEnvironment, - mode: isBrowserEnvironment - ? globalThis.location?.origin === url.origin + keepalive: useKeepalive, + mode: globalThis.location + ? globalThis.location.origin === url.origin ? 'same-origin' : 'cors' : 'no-cors', }); if (response.status >= 200 && response.status <= 299) { - diag.debug('response success'); + diag.debug(`export response success (status: ${response.status})`); return { status: 'success' }; } else if (isExportHTTPErrorRetryable(response.status)) { + diag.warn(`export response retryable (status: ${response.status})`); const retryAfter = response.headers.get('Retry-After'); const retryInMillis = parseRetryAfterToMills(retryAfter); return { status: 'retryable', retryInMillis }; } + diag.error(`export response failure (status: ${response.status})`); return { status: 'failure', - error: new Error('Fetch request failed with non-retryable status'), + error: new Error( + `Fetch request failed with non-retryable status ${response.status}` + ), }; } catch (error) { if (isFetchNetworkErrorRetryable(error)) { + diag.warn(`export request retryable (network error: ${error})`); return { status: 'retryable', error: new Error('Fetch request encountered a network error', { @@ -88,12 +139,17 @@ class FetchTransport implements IExporterTransport { }), }; } + diag.error(`export request failure (error: ${error})`); return { status: 'failure', error: new Error('Fetch request errored', { cause: error }), }; } finally { clearTimeout(timeout); + if (useKeepalive) { + pendingBodySize -= requestSize; + pendingKeepaliveCount--; + } } } diff --git a/experimental/packages/otlp-exporter-base/src/transport/send-beacon-transport.ts b/experimental/packages/otlp-exporter-base/src/transport/send-beacon-transport.ts deleted file mode 100644 index a14b29d0cc0..00000000000 --- a/experimental/packages/otlp-exporter-base/src/transport/send-beacon-transport.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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 SendBeaconParameters { - url: string; - /** - * Only `Content-Type` will be used, sendBeacon does not support custom headers - * https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon - */ - headers: HeadersFactory; -} - -class SendBeaconTransport implements IExporterTransport { - private _params: SendBeaconParameters; - constructor(params: SendBeaconParameters) { - this._params = params; - } - - async send(data: Uint8Array): Promise { - const blobType = (await this._params.headers())['Content-Type']; - return new Promise(resolve => { - if ( - navigator.sendBeacon( - this._params.url, - new Blob([data], { type: blobType }) - ) - ) { - // no way to signal retry, treat everything as success - diag.debug('SendBeacon success'); - resolve({ - status: 'success', - }); - } else { - resolve({ - status: 'failure', - error: new Error('SendBeacon failed'), - }); - } - }); - } - - shutdown(): void { - // Intentionally left empty, nothing to do. - } -} - -export function createSendBeaconTransport( - parameters: SendBeaconParameters -): IExporterTransport { - return new SendBeaconTransport(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 deleted file mode 100644 index 082ac935bba..00000000000 --- a/experimental/packages/otlp-exporter-base/test/browser/create-legacy-browser-delegate.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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 assert from 'assert'; -import { inferExportDelegateToUse } from '../../src/configuration/create-legacy-browser-delegate'; -import { - createOtlpFetchExportDelegate, - 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 () { - 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-transport.test.ts b/experimental/packages/otlp-exporter-base/test/browser/fetch-transport.test.ts index afe01f2cdef..cb2fc059b40 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 @@ -17,6 +17,8 @@ import * as sinon from 'sinon'; import * as assert from 'assert'; import { createFetchTransport } from '../../src/transport/fetch-transport'; +import { createRetryingTransport } from '../../src/retrying-transport'; +import { registerMockDiagLogger } from '../common/test-utils'; import { ExportResponseRetryable, ExportResponseFailure, @@ -35,6 +37,11 @@ const testTransportParameters = { const requestTimeout = 1000; const testPayload = Uint8Array.from([1, 2, 3]); +// 60KB is the max cumulative body size for keepalive +const MAX_KEEPALIVE_BODY_SIZE = 60 * 1024; +// 9 is the max concurrent keepalive requests +const MAX_KEEPALIVE_REQUESTS = 9; + describe('FetchTransport', function () { afterEach(function () { sinon.restore(); @@ -215,5 +222,291 @@ describe('FetchTransport', function () { done(); }, done /* catch any rejections */); }); + + it('returns failure when fetch throws TypeError with cause', async function () { + // arrange - TypeError with cause is NOT a network error (cause indicates wrapped error) + const errorWithCause = new TypeError('Failed'); + (errorWithCause as any).cause = new Error('underlying'); + sinon.stub(globalThis, 'fetch').rejects(errorWithCause); + const transport = createFetchTransport(testTransportParameters); + + // act + const result = await transport.send(testPayload, requestTimeout); + + // assert - should be failure, not retryable + assert.strictEqual(result.status, 'failure'); + assert.strictEqual( + (result as ExportResponseFailure).error.message, + 'Fetch request errored' + ); + }); + }); + + describe('keepalive queue tracking', function () { + it('enables keepalive for small requests under limits', async function () { + // arrange + const fetchStub = sinon + .stub(globalThis, 'fetch') + .resolves(new Response('', { status: 200 })); + const transport = createFetchTransport(testTransportParameters); + + // act + await transport.send(testPayload, requestTimeout); + + // assert + const requestInit = fetchStub.firstCall.args[1] as RequestInit; + assert.strictEqual(requestInit.keepalive, true); + }); + + it('disables keepalive when cumulative body size would exceed limit', async function () { + // arrange + // Create payload that's just over half the limit + const largePayload = new Uint8Array(MAX_KEEPALIVE_BODY_SIZE / 2 + 1); + + let resolveFirst!: (value: Response) => void; + const firstPromise = new Promise(r => { + resolveFirst = r; + }); + + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.onCall(0).returns(firstPromise); + fetchStub.onCall(1).resolves(new Response('', { status: 200 })); + + const { debug } = registerMockDiagLogger(); + const transport = createFetchTransport(testTransportParameters); + + // act - start first request (doesn't resolve yet) + const p1 = transport.send(largePayload, requestTimeout); + + // Start second request while first is pending + // Combined size would exceed 60KB limit + const p2 = transport.send(largePayload, requestTimeout); + + // Wait for second request to complete (it resolves immediately) + await p2; + + // assert - second request should have keepalive disabled + const secondRequestInit = fetchStub.secondCall.args[1] as RequestInit; + assert.strictEqual( + secondRequestInit.keepalive, + false, + 'keepalive should be false when cumulative size exceeds limit' + ); + + // assert - diag.debug should log keepalive disabled with size reason + sinon.assert.calledWith( + debug, + `keepalive disabled: ${(largePayload.byteLength / 1024).toFixed(1)}KB payload, 1 pending (size limit)` + ); + + // cleanup - resolve first request + resolveFirst(new Response('', { status: 200 })); + await p1; + }); + + it('disables keepalive when concurrent request count exceeds limit', async function () { + // arrange + const pendingResolvers: Array<(value: Response) => void> = []; + const fetchStub = sinon.stub(globalThis, 'fetch').callsFake(() => { + return new Promise(resolve => { + pendingResolvers.push(resolve); + }); + }); + + const { debug } = registerMockDiagLogger(); + const transport = createFetchTransport(testTransportParameters); + + // act - start MAX_KEEPALIVE_REQUESTS requests (all pending) + const pendingRequests: Promise[] = []; + for (let i = 0; i < MAX_KEEPALIVE_REQUESTS; i++) { + pendingRequests.push(transport.send(testPayload, requestTimeout)); + } + + // Wait for all fetch calls to be made + while (fetchStub.callCount < MAX_KEEPALIVE_REQUESTS) { + await new Promise(r => setTimeout(r, 0)); + } + + // Start one more request - should exceed the limit + const extraRequest = transport.send(testPayload, requestTimeout); + + // Wait for the extra fetch call + while (fetchStub.callCount < MAX_KEEPALIVE_REQUESTS + 1) { + await new Promise(r => setTimeout(r, 0)); + } + + // assert - the 10th request should have keepalive disabled + const tenthRequestInit = fetchStub.getCall(MAX_KEEPALIVE_REQUESTS) + .args[1] as RequestInit; + assert.strictEqual( + tenthRequestInit.keepalive, + false, + 'keepalive should be false when request count exceeds limit' + ); + + // assert - diag.debug should log keepalive disabled with count reason + sinon.assert.calledWith( + debug, + `keepalive disabled: ${(testPayload.byteLength / 1024).toFixed(1)}KB payload, ${MAX_KEEPALIVE_REQUESTS} pending (count limit)` + ); + + // cleanup - resolve all pending requests + pendingResolvers.forEach(resolve => + resolve(new Response('', { status: 200 })) + ); + await Promise.all([...pendingRequests, extraRequest]); + }); + + it('decrements counters after request completes successfully', async function () { + // arrange + let resolveFirst!: (value: Response) => void; + const firstPromise = new Promise(r => { + resolveFirst = r; + }); + + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.onCall(0).returns(firstPromise); + fetchStub.onCall(1).resolves(new Response('', { status: 200 })); + fetchStub.onCall(2).resolves(new Response('', { status: 200 })); + + // Use payload just over half the limit + const largePayload = new Uint8Array(MAX_KEEPALIVE_BODY_SIZE / 2 + 1); + const transport = createFetchTransport(testTransportParameters); + + // act - start first request + const p1 = transport.send(largePayload, requestTimeout); + + // Second request while first pending - should disable keepalive + const p2 = transport.send(largePayload, requestTimeout); + await p2; // Wait for second to complete + + const secondInit = fetchStub.secondCall.args[1] as RequestInit; + assert.strictEqual(secondInit.keepalive, false); + + // Complete first request + resolveFirst(new Response('', { status: 200 })); + await p1; + + // Third request after first completed - counter should be decremented + await transport.send(largePayload, requestTimeout); + const thirdInit = fetchStub.thirdCall.args[1] as RequestInit; + assert.strictEqual( + thirdInit.keepalive, + true, + 'keepalive should be re-enabled after pending request completes' + ); + }); + + it('decrements counters after request fails', async function () { + // arrange + let rejectFirst!: (error: Error) => void; + const firstPromise = new Promise((_, reject) => { + rejectFirst = reject; + }); + + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.onCall(0).returns(firstPromise); + fetchStub.onCall(1).resolves(new Response('', { status: 200 })); + fetchStub.onCall(2).resolves(new Response('', { status: 200 })); + + const largePayload = new Uint8Array(MAX_KEEPALIVE_BODY_SIZE / 2 + 1); + const transport = createFetchTransport(testTransportParameters); + + // act - start first request + const p1 = transport.send(largePayload, requestTimeout); + + // Second request while first pending - should disable keepalive + const p2 = transport.send(largePayload, requestTimeout); + await p2; // Wait for second to complete + + const secondInit = fetchStub.secondCall.args[1] as RequestInit; + assert.strictEqual(secondInit.keepalive, false); + + // Fail first request + rejectFirst(new Error('network error')); + await p1; // This should resolve (transport catches errors) + + // Third request after first failed - counter should be decremented + await transport.send(largePayload, requestTimeout); + const thirdInit = fetchStub.thirdCall.args[1] as RequestInit; + assert.strictEqual( + thirdInit.keepalive, + true, + 'keepalive should be re-enabled after failed request completes' + ); + }); + }); + + describe('retry integration', function () { + it('retries when server returns 503 then succeeds', async function () { + // arrange + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.onCall(0).resolves(new Response('', { status: 503 })); + fetchStub.onCall(1).resolves(new Response('', { status: 200 })); + + const transport = createRetryingTransport({ + transport: createFetchTransport(testTransportParameters), + }); + + // act + const result = await transport.send(testPayload, 10000); + + // assert + assert.strictEqual(result.status, 'success'); + assert.strictEqual(fetchStub.callCount, 2, 'should have retried once'); + }); + + it('retries when server returns 429 then succeeds', async function () { + // arrange + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.onCall(0).resolves(new Response('', { status: 429 })); + fetchStub.onCall(1).resolves(new Response('', { status: 200 })); + + const transport = createRetryingTransport({ + transport: createFetchTransport(testTransportParameters), + }); + + // act + const result = await transport.send(testPayload, 10000); + + // assert + assert.strictEqual(result.status, 'success'); + assert.strictEqual(fetchStub.callCount, 2, 'should have retried once'); + }); + + it('retries on network error then succeeds', async function () { + // arrange + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.onCall(0).rejects(new TypeError('Failed to fetch')); + fetchStub.onCall(1).resolves(new Response('', { status: 200 })); + + const transport = createRetryingTransport({ + transport: createFetchTransport(testTransportParameters), + }); + + // act + const result = await transport.send(testPayload, 10000); + + // assert + assert.strictEqual(result.status, 'success'); + assert.strictEqual(fetchStub.callCount, 2, 'should have retried once'); + }); + + it('does not retry on 404', async function () { + // arrange + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.resolves(new Response('', { status: 404 })); + + const transport = createRetryingTransport({ + transport: createFetchTransport(testTransportParameters), + }); + + // act + const result = await transport.send(testPayload, 10000); + + // assert + assert.strictEqual(result.status, 'failure'); + assert.strictEqual(fetchStub.callCount, 1, 'should not have retried'); + }); }); }); diff --git a/experimental/packages/otlp-exporter-base/test/browser/send-beacon-transport.test.ts b/experimental/packages/otlp-exporter-base/test/browser/send-beacon-transport.test.ts deleted file mode 100644 index d9847860012..00000000000 --- a/experimental/packages/otlp-exporter-base/test/browser/send-beacon-transport.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * 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 { createSendBeaconTransport } from '../../src/transport/send-beacon-transport'; -import * as assert from 'assert'; - -describe('SendBeaconTransport', function () { - afterEach(function () { - sinon.restore(); - }); - - describe('send', function () { - it('returns failure when sendBeacon fails', async function () { - // arrange - const sendStub = sinon.stub(navigator, 'sendBeacon').returns(false); - const transport = createSendBeaconTransport({ - url: 'http://example.test', - headers: async () => ({ 'Content-Type': 'application/json' }), - }); - - // act - const result = await transport.send(Uint8Array.from([1, 2, 3]), 1000); - - // assert - sinon.assert.calledOnceWithMatch( - sendStub, - 'http://example.test', - sinon.match - .instanceOf(Blob) - .and( - sinon.match( - actual => actual.type === 'application/json', - 'Expected Blob type to match.' - ) - ) - ); - assert.strictEqual(result.status, 'failure'); - }); - - it('returns success when sendBeacon succeeds', async function () { - // arrange - const sendStub = sinon.stub(navigator, 'sendBeacon').returns(true); - const transport = createSendBeaconTransport({ - url: 'http://example.test', - headers: async () => ({ 'Content-Type': 'application/json' }), - }); - - // act - const result = await transport.send(Uint8Array.from([1, 2, 3]), 1000); - - // assert - sinon.assert.calledOnceWithMatch( - sendStub, - 'http://example.test', - sinon.match - .instanceOf(Blob) - .and( - sinon.match( - actual => actual.type === 'application/json', - 'Expected Blob type to match.' - ) - ) - ); - assert.strictEqual(result.status, 'success'); - }); - }); -}); diff --git a/experimental/packages/otlp-exporter-base/test/common/test-utils.ts b/experimental/packages/otlp-exporter-base/test/common/test-utils.ts index b8752f6b94c..8e74826b0a9 100644 --- a/experimental/packages/otlp-exporter-base/test/common/test-utils.ts +++ b/experimental/packages/otlp-exporter-base/test/common/test-utils.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import * as sinon from 'sinon'; -import { diag } from '@opentelemetry/api'; +import { diag, DiagLogLevel } from '@opentelemetry/api'; export function registerMockDiagLogger() { // arrange @@ -25,7 +25,7 @@ export function registerMockDiagLogger() { warn: sinon.stub(), error: sinon.stub(), }; - diag.setLogger(stubs); + diag.setLogger(stubs, DiagLogLevel.ALL); stubs.warn.resetHistory(); // reset history setLogger will warn if another has already been set return stubs;