diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 4af679b319e..c81c64bb7ce 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -16,6 +16,8 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :rocket: Features +* feat(otlp-exporter-base): Add fetch transport for fetch-only environments like service workers. [#5807](https://github.com/open-telemetry/opentelemetry-js/pull/5807) + * when using headers, the Browser exporter now prefers `fetch` over `XMLHttpRequest` if present. Sending via `XMLHttpRequest` will be removed in a future release. * feat(opentelemetry-configuration): creation of basic ConfigProvider [#5809](https://github.com/open-telemetry/opentelemetry-js/pull/5809) @maryliag * feat(opentelemetry-configuration): creation of basic FileConfigProvider [#5863](https://github.com/open-telemetry/opentelemetry-js/pull/5863) @maryliag * feat(sdk-node): Add support for multiple metric readers via the new `metricReaders` option in NodeSDK configuration. Users can now register multiple metric readers (e.g., Console, Prometheus) directly through the NodeSDK constructor. The old `metricReader` (singular) option is now deprecated and will show a warning if used, but remains supported for backward compatibility. Comprehensive tests and documentation have been added. [#5760](https://github.com/open-telemetry/opentelemetry-js/issues/5760) @@ -37,6 +39,7 @@ 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): prioritize `esnext` export condition as it is more specific [#5458](https://github.com/open-telemetry/opentelemetry-js/pull/5458) +* fix(otlp-exporter-base): consider relative urls as valid in browser environments [#5807](https://github.com/open-telemetry/opentelemetry-js/pull/5807) * fix(instrumentation-fetch): Use ESM version of semconv instead of CJS. Users expecting mixed ESM and CJS modules will now only get ESM modules. [#5878](https://github.com/open-telemetry/opentelemetry-js/pull/5878) @overbalance ### :books: Documentation 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 9aebe671d2d..d347676deeb 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 @@ -64,26 +64,22 @@ describe('OTLPLogExporter', function () { (window.navigator as any).sendBeacon = false; }); - it('should successfully send data using XMLHttpRequest', async function () { + it('should successfully send data using fetch', async function () { // arrange - const server = sinon.fakeServer.create(); + const stubFetch = sinon.stub(window, 'fetch'); const loggerProvider = new LoggerProvider({ processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], }); // act loggerProvider.getLogger('test-logger').emit({ body: 'test-body' }); - queueMicrotask(() => { - // simulate success response - server.requests[0].respond(200, {}, ''); - }); await loggerProvider.shutdown(); // assert - const request = server.requests[0]; - const body = request.requestBody as unknown as Uint8Array; + const request = new Request(...stubFetch.args[0]); + const body = await request.text(); assert.doesNotThrow( - () => JSON.parse(new TextDecoder().decode(body)), + () => 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 9cc15b63f38..f5cc36f66d4 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 @@ -64,26 +64,22 @@ describe('OTLPLogExporter', function () { (window.navigator as any).sendBeacon = false; }); - it('should successfully send data using XMLHttpRequest', async function () { + it('should successfully send data using fetch', async function () { // arrange - const server = sinon.fakeServer.create(); + const stubFetch = sinon.stub(window, 'fetch'); const loggerProvider = new LoggerProvider({ processors: [new SimpleLogRecordProcessor(new OTLPLogExporter())], }); // act loggerProvider.getLogger('test-logger').emit({ body: 'test-body' }); - queueMicrotask(() => { - // simulate success response - server.requests[0].respond(200, {}, ''); - }); await loggerProvider.shutdown(); // assert - const request = server.requests[0]; - const body = request.requestBody as unknown as Uint8Array; + const request = new Request(...stubFetch.args[0]); + const body = await request.text(); assert.throws( - () => JSON.parse(new TextDecoder().decode(body)), + () => 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 a5ef3ad0023..e9fd56cac7c 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 @@ -65,26 +65,22 @@ describe('OTLPTraceExporter', () => { (window.navigator as any).sendBeacon = false; }); - it('should successfully send data using XMLHttpRequest', async function () { + it('should successfully send data using fetch', async function () { // arrange - const server = sinon.fakeServer.create(); + const stubFetch = sinon.stub(window, 'fetch'); const tracerProvider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], }); // act tracerProvider.getTracer('test-tracer').startSpan('test-span').end(); - queueMicrotask(() => { - // simulate success response - server.requests[0].respond(200, {}, ''); - }); await tracerProvider.shutdown(); // assert - const request = server.requests[0]; - const body = request.requestBody as unknown as Uint8Array; + const request = new Request(...stubFetch.args[0]); + const body = await request.text(); assert.doesNotThrow( - () => JSON.parse(new TextDecoder().decode(body)), + () => 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 24e517c8b75..9df61daa07b 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 @@ -65,27 +65,23 @@ describe('OTLPTraceExporter', () => { (window.navigator as any).sendBeacon = false; }); - it('should successfully send data using XMLHttpRequest', async function () { + it('should successfully send data using fetch', async function () { // arrange - const server = sinon.fakeServer.create(); + const stubFetch = sinon.stub(window, 'fetch'); const tracerProvider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(new OTLPTraceExporter())], }); // act tracerProvider.getTracer('test-tracer').startSpan('test-span').end(); - queueMicrotask(() => { - // simulate success response - server.requests[0].respond(200, {}, ''); - }); await tracerProvider.shutdown(); // assert - const request = server.requests[0]; - const body = request.requestBody as unknown as Uint8Array; + const request = new Request(...stubFetch.args[0]); + const body = await request.text(); assert.throws( - () => JSON.parse(new TextDecoder().decode(body)), - 'expected requestBody to be in protobuf format, but parsing as JSON succeeded' + () => 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 d2ef4e10715..f9e61e85eff 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 @@ -76,12 +76,9 @@ describe('OTLPMetricExporter', function () { (window.navigator as any).sendBeacon = false; }); - it('should successfully send data using XMLHttpRequest', async function () { + it('should successfully send data using fetch', async function () { // arrange - const server = sinon.fakeServer.create(); - server.respondWith('OK'); - server.respondImmediately = true; - server.autoRespond = true; + const stubFetch = sinon.stub(window, 'fetch'); const meterProvider = new MeterProvider({ readers: [ new PeriodicExportingMetricReader({ @@ -95,15 +92,14 @@ describe('OTLPMetricExporter', function () { .getMeter('test-meter') .createCounter('test-counter') .add(1); - await meterProvider.shutdown(); // assert - const request = server.requests[0]; - const body = request.requestBody as unknown as Uint8Array; + const request = new Request(...stubFetch.args[0]); + const body = await request.text(); assert.doesNotThrow( - () => JSON.parse(new TextDecoder().decode(body)), - 'expected requestBody to be in JSON format, but parsing failed' + () => 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 c14cfe7d797..fc1e3ed955b 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 @@ -72,12 +72,9 @@ describe('OTLPTraceExporter', () => { (window.navigator as any).sendBeacon = false; }); - it('should successfully send data using XMLHttpRequest', async function () { + it('should successfully send data using fetch', async function () { // arrange - const server = sinon.fakeServer.create(); - server.respondWith('OK'); - server.respondImmediately = true; - server.autoRespond = true; + const stubFetch = sinon.stub(window, 'fetch'); const meterProvider = new MeterProvider({ readers: [ new PeriodicExportingMetricReader({ @@ -95,10 +92,10 @@ describe('OTLPTraceExporter', () => { await meterProvider.shutdown(); // assert - const request = server.requests[0]; - const body = request.requestBody as unknown as Uint8Array; + const request = new Request(...stubFetch.args[0]); + const body = await request.text(); assert.throws( - () => JSON.parse(new TextDecoder().decode(body)), + () => 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 923ed9919b8..b1c3823e235 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 @@ -15,6 +15,7 @@ */ import { ISerializer } from '@opentelemetry/otlp-transformer'; import { + createOtlpFetchExportDelegate, createOtlpSendBeaconExportDelegate, createOtlpXhrExportDelegate, } from '../otlp-browser-http-export-delegate'; @@ -35,7 +36,7 @@ export function createLegacyOtlpBrowserExportDelegate( signalResourcePath: string, requiredHeaders: Record ): IOtlpExportDelegate { - const useXhr = !!config.headers || typeof navigator.sendBeacon !== 'function'; + const createOtlpExportDelegate = inferExportDelegateToUse(config.headers); const options = convertLegacyBrowserHttpOptions( config, @@ -43,9 +44,17 @@ export function createLegacyOtlpBrowserExportDelegate( requiredHeaders ); - if (useXhr) { - return createOtlpXhrExportDelegate(options, serializer); + return createOtlpExportDelegate(options, serializer); +} + +export function inferExportDelegateToUse( + configHeaders: OTLPExporterConfigBase['headers'] +) { + if (!configHeaders && typeof navigator.sendBeacon === 'function') { + return createOtlpSendBeaconExportDelegate; + } else if (typeof globalThis.fetch !== 'undefined') { + return createOtlpFetchExportDelegate; } else { - return createOtlpSendBeaconExportDelegate(options, serializer); + return createOtlpXhrExportDelegate; } } diff --git a/experimental/packages/otlp-exporter-base/src/configuration/otlp-http-configuration.ts b/experimental/packages/otlp-exporter-base/src/configuration/otlp-http-configuration.ts index 3965ba6406d..f1ef6e22b0c 100644 --- a/experimental/packages/otlp-exporter-base/src/configuration/otlp-http-configuration.ts +++ b/experimental/packages/otlp-exporter-base/src/configuration/otlp-http-configuration.ts @@ -76,8 +76,9 @@ function validateUserProvidedUrl(url: string | undefined): string | undefined { return undefined; } try { - new URL(url); - return url; + // NOTE: In non-browser environments, `globalThis.location` will be `undefined`. + const base = globalThis.location?.href; + return new URL(url, base).href; } catch { throw new Error( `Configuration: Could not parse user-provided export URL: '${url}'` 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 402348e338a..e98f6d3dbbb 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,7 +20,11 @@ import { createRetryingTransport } from './retrying-transport'; import { createXhrTransport } from './transport/xhr-transport'; import { createSendBeaconTransport } from './transport/send-beacon-transport'; import { createOtlpNetworkExportDelegate } from './otlp-network-export-delegate'; +import { createFetchTransport } from './transport/fetch-transport'; +/** + * @deprecated use {@link createOtlpFetchExportDelegate} + */ export function createOtlpXhrExportDelegate( options: OtlpHttpConfiguration, serializer: ISerializer @@ -34,6 +38,19 @@ export function createOtlpXhrExportDelegate( ); } +export function createOtlpFetchExportDelegate( + options: OtlpHttpConfiguration, + serializer: ISerializer +): IOtlpExportDelegate { + return createOtlpNetworkExportDelegate( + options, + serializer, + createRetryingTransport({ + transport: createFetchTransport(options), + }) + ); +} + export function createOtlpSendBeaconExportDelegate( options: OtlpHttpConfiguration, serializer: ISerializer diff --git a/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts b/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts new file mode 100644 index 00000000000..b3d63930c0a --- /dev/null +++ b/experimental/packages/otlp-exporter-base/src/transport/fetch-transport.ts @@ -0,0 +1,93 @@ +/* + * 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 { + isExportRetryable, + parseRetryAfterToMills, +} from '../is-export-retryable'; + +export interface FetchTransportParameters { + url: string; + headers: () => Record; +} + +class FetchTransport implements IExporterTransport { + constructor(private _parameters: FetchTransportParameters) {} + + async send(data: Uint8Array, timeoutMillis: number): Promise { + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), timeoutMillis); + try { + const isBrowserEnvironment = !!globalThis.location; + const url = new URL(this._parameters.url); + const response = await fetch(url.href, { + method: 'POST', + headers: this._parameters.headers(), + body: data, + signal: abortController.signal, + keepalive: isBrowserEnvironment, + mode: isBrowserEnvironment + ? globalThis.location?.origin === url.origin + ? 'same-origin' + : 'cors' + : 'no-cors', + }); + + if (response.status >= 200 && response.status <= 299) { + diag.debug('response success'); + return { status: 'success' }; + } else if (isExportRetryable(response.status)) { + const retryAfter = response.headers.get('Retry-After'); + const retryInMillis = parseRetryAfterToMills(retryAfter); + return { status: 'retryable', retryInMillis }; + } + return { + status: 'failure', + error: new Error('Fetch request failed with non-retryable status'), + }; + } catch (error) { + if (error?.name === 'AbortError') { + return { + status: 'failure', + error: new Error('Fetch request timed out', { cause: error }), + }; + } + return { + status: 'failure', + error: new Error('Fetch request errored', { cause: error }), + }; + } finally { + clearTimeout(timeout); + } + } + + shutdown() { + // Intentionally left empty, nothing to do. + } +} + +/** + * Creates an exporter transport that uses `fetch` to send the data + * @param parameters applied to each request made by transport + */ +export function createFetchTransport( + parameters: FetchTransportParameters +): IExporterTransport { + return new FetchTransport(parameters); +} diff --git a/experimental/packages/otlp-exporter-base/src/transport/xhr-transport.ts b/experimental/packages/otlp-exporter-base/src/transport/xhr-transport.ts index 17c02096478..31ff3bb9b0f 100644 --- a/experimental/packages/otlp-exporter-base/src/transport/xhr-transport.ts +++ b/experimental/packages/otlp-exporter-base/src/transport/xhr-transport.ts @@ -21,7 +21,13 @@ import { isExportRetryable, parseRetryAfterToMills, } from '../is-export-retryable'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { createFetchTransport } from './fetch-transport'; +/** + * @deprecated favor the fetch transport + * @see {@link createFetchTransport} + */ export interface XhrRequestParameters { url: string; headers: () => Record; @@ -91,6 +97,8 @@ class XhrTransport implements IExporterTransport { } /** + * @deprecated use {@link createFetchTransport} instead + * * Creates an exporter transport that uses XHR to send the data * @param parameters applied to each request made by transport */ diff --git a/experimental/packages/otlp-exporter-base/test/browser/configuration/otlp-http-configuration.test.ts b/experimental/packages/otlp-exporter-base/test/browser/configuration/otlp-http-configuration.test.ts new file mode 100644 index 00000000000..9b980fdc722 --- /dev/null +++ b/experimental/packages/otlp-exporter-base/test/browser/configuration/otlp-http-configuration.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { + mergeOtlpHttpConfigurationWithDefaults, + type OtlpHttpConfiguration, +} from '../../../src/configuration/otlp-http-configuration'; + +describe('mergeOtlpHttpConfigurationWithDefaults (browser)', function () { + const testDefaults: OtlpHttpConfiguration = { + url: 'http://default.example.test', + timeoutMillis: 1, + compression: 'none', + concurrencyLimit: 2, + headers: () => ({ 'User-Agent': 'default-user-agent' }), + agentFactory: () => null!, + }; + + it('resolves user-provided relative url to document', function () { + const configuration = mergeOtlpHttpConfigurationWithDefaults( + { url: '/test' }, + {}, + testDefaults + ); + const expectedHref = new URL('/test', window.location.href).href; + assert.equal(configuration.url, expectedHref); + }); +}); 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 new file mode 100644 index 00000000000..ff0fe985acb --- /dev/null +++ b/experimental/packages/otlp-exporter-base/test/browser/create-legacy-browser-delegate.test.ts @@ -0,0 +1,93 @@ +/* + * 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, + createOtlpXhrExportDelegate, +} 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); + }); + }); + + describe('when fetch is unavailable', function () { + const fetch = window.fetch; + beforeEach(function () { + // fake fetch being unavailable + (window as any).fetch = undefined; + }); + afterEach(() => { + window.fetch = fetch; + }); + + it('uses xhr delegate', function () { + const delegate = inferExportDelegateToUse(undefined); + assert.equal(delegate, createOtlpXhrExportDelegate); + }); + }); + }); + + describe('when fetch is unavailable but beacon and xhr are', function () { + const fetch = window.fetch; + beforeEach(function () { + // fake fetch being unavailable + (window as any).fetch = undefined; + }); + afterEach(function () { + window.fetch = fetch; + }); + + it('uses xhr when beacon is available but headers are provided', function () { + const fetch = window.fetch; + // @ts-expect-error one should not be able to mutate the window but this is a test. + window.fetch = undefined; + + const delegate = inferExportDelegateToUse({ foo: 'bar' }); + assert.equal(delegate, createOtlpXhrExportDelegate); + + window.fetch = fetch; + }); + }); +}); 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 new file mode 100644 index 00000000000..82efa2411b1 --- /dev/null +++ b/experimental/packages/otlp-exporter-base/test/browser/fetch-transport.test.ts @@ -0,0 +1,173 @@ +/* + * 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 { createFetchTransport } from '../../src/transport/fetch-transport'; +import { + ExportResponseRetryable, + ExportResponseFailure, + ExportResponseSuccess, +} from '../../src'; + +const testTransportParameters = { + url: 'http://example.test', + headers: () => ({ + foo: 'foo-value', + bar: 'bar-value', + 'Content-Type': 'application/json', + }), +}; + +const requestTimeout = 1000; +const testPayload = Uint8Array.from([1, 2, 3]); + +describe('FetchTransport', function () { + afterEach(function () { + sinon.restore(); + }); + + describe('send', function () { + it('returns success when request succeeds', function (done) { + // arrange + const fetchStub = sinon + .stub(globalThis, 'fetch') + .resolves(new Response('test response', { status: 200 })); + const transport = createFetchTransport(testTransportParameters); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'success'); + // currently we don't do anything with the response yet, so it's dropped by the transport. + assert.strictEqual( + (response as ExportResponseSuccess).data, + undefined + ); + sinon.assert.calledOnceWithMatch( + fetchStub, + testTransportParameters.url, + { + method: 'POST', + headers: { + foo: 'foo-value', + bar: 'bar-value', + 'Content-Type': 'application/json', + }, + body: testPayload, + } + ); + } catch (e) { + done(e); + } + done(); + }, done /* catch any rejections */); + }); + + it('returns failure when request fails', function (done) { + // arrange + sinon + .stub(globalThis, 'fetch') + .resolves(new Response('', { status: 404 })); + const transport = createFetchTransport(testTransportParameters); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'failure'); + } catch (e) { + done(e); + } + done(); + }, done /* catch any rejections */); + }); + + it('returns retryable when request is retryable', function (done) { + // arrange + sinon + .stub(globalThis, 'fetch') + .resolves( + new Response('', { status: 503, headers: { 'Retry-After': '5' } }) + ); + const transport = createFetchTransport(testTransportParameters); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'retryable'); + assert.strictEqual( + (response as ExportResponseRetryable).retryInMillis, + 5000 + ); + } catch (e) { + done(e); + } + done(); + }, done /* catch any rejections */); + }); + + it('returns failure when request times out', function (done) { + // arrange + const abortError = new Error('aborted request'); + abortError.name = 'AbortError'; + sinon.stub(globalThis, 'fetch').rejects(abortError); + const clock = sinon.useFakeTimers(); + const transport = createFetchTransport(testTransportParameters); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'failure'); + assert.strictEqual( + (response as ExportResponseFailure).error.message, + 'Fetch request timed out' + ); + } catch (e) { + done(e); + } + done(); + }, done /* catch any rejections */); + clock.tick(requestTimeout + 100); + }); + + it('returns failure when no server exists', function (done) { + // arrange + sinon.stub(globalThis, 'fetch').throws(new Error('fetch failed')); + const clock = sinon.useFakeTimers(); + const transport = createFetchTransport(testTransportParameters); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'failure'); + assert.strictEqual( + (response as ExportResponseFailure).error.message, + 'Fetch request errored' + ); + } catch (e) { + done(e); + } + done(); + }, done /* catch any rejections */); + clock.tick(requestTimeout + 100); + }); + }); +}); diff --git a/experimental/packages/otlp-exporter-base/test/common/configuration/otlp-http-configuration.test.ts b/experimental/packages/otlp-exporter-base/test/common/configuration/otlp-http-configuration.test.ts index ed9a1546d4a..8e78f12453b 100644 --- a/experimental/packages/otlp-exporter-base/test/common/configuration/otlp-http-configuration.test.ts +++ b/experimental/packages/otlp-exporter-base/test/common/configuration/otlp-http-configuration.test.ts @@ -21,7 +21,7 @@ import { import * as assert from 'assert'; import { testSharedConfigBehavior } from './shared-configuration.test'; -describe('mergeOtlpHttpConfigurationWithDefaults', function () { +describe('mergeOtlpHttpConfigurationWithDefaults (common)', function () { const testDefaults: OtlpHttpConfiguration = { url: 'http://default.example.test', timeoutMillis: 1, @@ -119,16 +119,6 @@ describe('mergeOtlpHttpConfigurationWithDefaults', function () { ); assert.strictEqual(config.url, testDefaults.url); }); - - it('throws error when the user-provided url is not parseable', function () { - assert.throws(() => { - mergeOtlpHttpConfigurationWithDefaults( - { url: 'this is not a URL' }, - {}, - testDefaults - ); - }, new Error("Configuration: Could not parse user-provided export URL: 'this is not a URL'")); - }); }); testSharedConfigBehavior( diff --git a/experimental/packages/otlp-exporter-base/test/node/configuration/otlp-http-configuration.test.ts b/experimental/packages/otlp-exporter-base/test/node/configuration/otlp-http-configuration.test.ts new file mode 100644 index 00000000000..a251b06e6e0 --- /dev/null +++ b/experimental/packages/otlp-exporter-base/test/node/configuration/otlp-http-configuration.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { + mergeOtlpHttpConfigurationWithDefaults, + OtlpHttpConfiguration, +} from '../../../src/configuration/otlp-http-configuration'; + +describe('mergeOtlpHttpConfigurationWithDefaults (node)', function () { + const testDefaults: OtlpHttpConfiguration = { + url: 'http://default.example.test', + timeoutMillis: 1, + compression: 'none', + concurrencyLimit: 2, + headers: () => ({ 'User-Agent': 'default-user-agent' }), + agentFactory: () => null!, + }; + + it('throws error when the user-provided url is not parseable', function () { + assert.throws(() => { + mergeOtlpHttpConfigurationWithDefaults( + { url: 'this is not a URL' }, + {}, + testDefaults + ); + }, new Error("Configuration: Could not parse user-provided export URL: 'this is not a URL'")); + }); +});