Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
dd4ec25
feat(browser otlp exporter): add fetch transport if xhr and beacon ar…
SacDeNoeuds Jul 22, 2025
e3cd53a
test(create legacy browser delegate): test the decisions between beac…
SacDeNoeuds Jul 22, 2025
b722a83
feat(otlp exporter fetch transport): support non-browser environments
SacDeNoeuds Jul 22, 2025
c61c687
fix(fetch transport): clear timeout in finally block
SacDeNoeuds Jul 23, 2025
7994b96
docs(changelog): add entry for fetch-transport
SacDeNoeuds Jul 23, 2025
1872256
refactor(fetch transport test): remove helper function
SacDeNoeuds Jul 23, 2025
14bdf0b
Merge branch 'main' into feat/otlp-exporter--base-fetch-transport
SacDeNoeuds Jul 23, 2025
673af6f
Merge branch 'main' into feat/otlp-exporter--base-fetch-transport
SacDeNoeuds Aug 14, 2025
008f175
Merge branch 'main' into feat/otlp-exporter--base-fetch-transport
SacDeNoeuds Aug 20, 2025
65da6f9
Merge branch 'main' into feat/otlp-exporter--base-fetch-transport
SacDeNoeuds Aug 21, 2025
6a3cbfb
docs(xhr transport): deprecate in favor of fetch transport
SacDeNoeuds Aug 21, 2025
0bd231b
feat(otlp export delegate): start using fetch when headers or when se…
SacDeNoeuds Aug 21, 2025
fccddd4
refactor(fetch transport): remove timeoutMillis guard against 0
SacDeNoeuds Aug 21, 2025
bc4320a
feat(fetch): add the keepalive option in browser environments
SacDeNoeuds Aug 21, 2025
ac93c9f
test(fetch transport): fix inconsistent afterEach hook
SacDeNoeuds Aug 21, 2025
d63ab8e
fix(otlp-http-configuration): consider relative urls as valid in brow…
SacDeNoeuds Aug 21, 2025
23e7330
docs(otlp-xhr-export-delegate): add deprecated mention
SacDeNoeuds Aug 21, 2025
dd0b129
Merge branch 'main' into feat/otlp-exporter--base-fetch-transport
SacDeNoeuds Aug 22, 2025
8a07ae1
Merge branch 'main' into feat/otlp-exporter--base-fetch-transport
SacDeNoeuds Aug 23, 2025
4fad1df
Update experimental/CHANGELOG.md
SacDeNoeuds Aug 27, 2025
27fe4a9
Update experimental/CHANGELOG.md
SacDeNoeuds Aug 27, 2025
35d935e
Merge branch 'main' into feat/otlp-exporter--base-fetch-transport
SacDeNoeuds Aug 27, 2025
82e013b
test(exporter-metrics-otlp-http): replace XMLHttpRequest by fetch
SacDeNoeuds Aug 27, 2025
96bbff6
test(exporter-trace-otlp-proto): replace XMLHttpRequest by fetch
SacDeNoeuds Aug 27, 2025
5530e4e
test(exporter-logs-otlp-proto): replace XMLHttpRequest by fetch
SacDeNoeuds Aug 27, 2025
63e7a80
test(exporter-trace-otlp-http): replace XMLHttpRequest by fetch
SacDeNoeuds Aug 27, 2025
f9aeb0a
test(exporter-logs-otlp-http): replace XMLHttpRequest by fetch
SacDeNoeuds Aug 27, 2025
cfffa6a
test(exporter-metrics-otlp-proto): replace XMLHttpRequest by fetch
SacDeNoeuds Aug 27, 2025
daa5b39
Merge branch 'main' into feat/otlp-exporter--base-fetch-transport
SacDeNoeuds Aug 27, 2025
bc12329
test(exporter-trace-otlp-proto): remove unneeded changes
SacDeNoeuds Aug 27, 2025
84f971e
Merge branch 'main' into feat/otlp-exporter--base-fetch-transport
SacDeNoeuds Aug 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions experimental/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
SacDeNoeuds marked this conversation as resolved.
* 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)
Expand All @@ -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)

### :books: Documentation

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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'
);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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'
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
import { ISerializer } from '@opentelemetry/otlp-transformer';
import {
createOtlpFetchExportDelegate,
createOtlpSendBeaconExportDelegate,
createOtlpXhrExportDelegate,
} from '../otlp-browser-http-export-delegate';
Expand All @@ -35,17 +36,25 @@ export function createLegacyOtlpBrowserExportDelegate<Internal, Response>(
signalResourcePath: string,
requiredHeaders: Record<string, string>
): IOtlpExportDelegate<Internal> {
const useXhr = !!config.headers || typeof navigator.sendBeacon !== 'function';
const createOtlpExportDelegate = inferExportDelegateToUse(config.headers);

const options = convertLegacyBrowserHttpOptions(
config,
signalResourcePath,
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;
}
}
Comment on lines +50 to 60
Copy link
Copy Markdown
Contributor Author

@SacDeNoeuds SacDeNoeuds Aug 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The former test I wrote was ineffective, every createOtlpXxExporterDelegate returns an instance of the class OTLPExportDelegate, the initial tests were flawed considering they were testing the instance constructor and it was the same constructor – OTLPExportDelegate – in all cases.

I added an intermediary testable function and updated the test.

Original file line number Diff line number Diff line change
Expand Up @@ -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}'`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Internal, Response>(
options: OtlpHttpConfiguration,
serializer: ISerializer<Internal, Response>
Expand All @@ -34,6 +38,19 @@ export function createOtlpXhrExportDelegate<Internal, Response>(
);
}

export function createOtlpFetchExportDelegate<Internal, Response>(
options: OtlpHttpConfiguration,
serializer: ISerializer<Internal, Response>
): IOtlpExportDelegate<Internal> {
return createOtlpNetworkExportDelegate(
options,
serializer,
createRetryingTransport({
transport: createFetchTransport(options),
})
);
}

export function createOtlpSendBeaconExportDelegate<Internal, Response>(
options: OtlpHttpConfiguration,
serializer: ISerializer<Internal, Response>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

class FetchTransport implements IExporterTransport {
constructor(private _parameters: FetchTransportParameters) {}

async send(data: Uint8Array, timeoutMillis: number): Promise<ExportResponse> {
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
Comment thread
pichlermarc marked this conversation as resolved.
? globalThis.location?.origin === url.origin
? 'same-origin'
: 'cors'
Comment thread
SacDeNoeuds marked this conversation as resolved.
: '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);
}
Loading
Loading