Skip to content

Commit 14403f8

Browse files
committed
Add support for logging in Edge Runtime
1 parent 968c588 commit 14403f8

File tree

8 files changed

+411
-47
lines changed

8 files changed

+411
-47
lines changed

packages/telemetry/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
"@opentelemetry/api": "1.9.0",
4949
"@opentelemetry/api-logs": "0.203.0",
5050
"@opentelemetry/exporter-logs-otlp-http": "0.203.0",
51+
"@opentelemetry/otlp-exporter-base": "0.205.0",
52+
"@opentelemetry/otlp-transformer": "0.205.0",
5153
"@opentelemetry/resources": "2.0.1",
5254
"@opentelemetry/sdk-logs": "0.203.0",
5355
"@opentelemetry/semantic-conventions": "1.36.0",

packages/telemetry/src/helpers.ts

Lines changed: 0 additions & 45 deletions
This file was deleted.
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* Copyright 2025 Google LLC
4+
*
5+
* This file has been modified by Google LLC
6+
*
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* https://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
*/
19+
20+
import * as sinon from 'sinon';
21+
import * as assert from 'assert';
22+
import { FetchTransportEdge } from './fetch-transport.edge';
23+
import { ExportResponseRetryable, ExportResponseFailure, ExportResponseSuccess } from '@opentelemetry/otlp-exporter-base';
24+
25+
const testTransportParameters = {
26+
url: 'http://example.test',
27+
headers: () => ({
28+
foo: 'foo-value',
29+
bar: 'bar-value',
30+
'Content-Type': 'application/json',
31+
}),
32+
};
33+
34+
const requestTimeout = 1000;
35+
const testPayload = Uint8Array.from([1, 2, 3]);
36+
37+
describe('FetchTransportEdge', () => {
38+
afterEach(() => {
39+
sinon.restore();
40+
});
41+
42+
describe('send', () => {
43+
it('returns success when request succeeds', (done) => {
44+
// arrange
45+
const fetchStub = sinon
46+
.stub(globalThis, 'fetch')
47+
.resolves(new Response('test response', { status: 200 }));
48+
const transport = new FetchTransportEdge(testTransportParameters);
49+
50+
//act
51+
transport.send(testPayload, requestTimeout).then(response => {
52+
// assert
53+
try {
54+
assert.strictEqual(response.status, 'success');
55+
// currently we don't do anything with the response yet, so it's dropped by the transport.
56+
assert.strictEqual(
57+
(response as ExportResponseSuccess).data,
58+
undefined
59+
);
60+
sinon.assert.calledOnceWithMatch(
61+
fetchStub,
62+
testTransportParameters.url,
63+
{
64+
method: 'POST',
65+
headers: {
66+
foo: 'foo-value',
67+
bar: 'bar-value',
68+
'Content-Type': 'application/json',
69+
},
70+
body: testPayload,
71+
}
72+
);
73+
done();
74+
} catch (e) {
75+
done(e);
76+
}
77+
}, done /* catch any rejections */);
78+
});
79+
80+
it('returns failure when request fails', (done) => {
81+
// arrange
82+
sinon
83+
.stub(globalThis, 'fetch')
84+
.resolves(new Response('', { status: 404 }));
85+
const transport = new FetchTransportEdge(testTransportParameters);
86+
87+
//act
88+
transport.send(testPayload, requestTimeout).then(response => {
89+
// assert
90+
try {
91+
assert.strictEqual(response.status, 'failure');
92+
done();
93+
} catch (e) {
94+
done(e);
95+
}
96+
}, done /* catch any rejections */);
97+
});
98+
99+
it('returns retryable when request is retryable', (done) => {
100+
// arrange
101+
sinon
102+
.stub(globalThis, 'fetch')
103+
.resolves(
104+
new Response('', { status: 503, headers: { 'Retry-After': '5' } })
105+
);
106+
const transport = new FetchTransportEdge(testTransportParameters);
107+
108+
//act
109+
transport.send(testPayload, requestTimeout).then(response => {
110+
// assert
111+
try {
112+
assert.strictEqual(response.status, 'retryable');
113+
assert.strictEqual(
114+
(response as ExportResponseRetryable).retryInMillis,
115+
5000
116+
);
117+
done();
118+
} catch (e) {
119+
done(e);
120+
}
121+
}, done /* catch any rejections */);
122+
});
123+
124+
it('returns failure when request times out', (done) => {
125+
// arrange
126+
const abortError = new Error('aborted request');
127+
abortError.name = 'AbortError';
128+
sinon.stub(globalThis, 'fetch').rejects(abortError);
129+
const clock = sinon.useFakeTimers();
130+
const transport = new FetchTransportEdge(testTransportParameters);
131+
132+
//act
133+
transport.send(testPayload, requestTimeout).then(response => {
134+
// assert
135+
try {
136+
assert.strictEqual(response.status, 'failure');
137+
assert.strictEqual(
138+
(response as ExportResponseFailure).error.message,
139+
'aborted request'
140+
);
141+
done();
142+
} catch (e) {
143+
done(e);
144+
}
145+
}, done /* catch any rejections */);
146+
clock.tick(requestTimeout + 100);
147+
});
148+
149+
it('returns failure when no server exists', (done) => {
150+
// arrange
151+
sinon.stub(globalThis, 'fetch').throws(new Error('fetch failed'));
152+
const clock = sinon.useFakeTimers();
153+
const transport = new FetchTransportEdge(testTransportParameters);
154+
155+
//act
156+
transport.send(testPayload, requestTimeout).then(response => {
157+
// assert
158+
try {
159+
assert.strictEqual(response.status, 'failure');
160+
assert.strictEqual(
161+
(response as ExportResponseFailure).error.message,
162+
'fetch failed'
163+
);
164+
done();
165+
} catch (e) {
166+
done(e);
167+
}
168+
}, done /* catch any rejections */);
169+
clock.tick(requestTimeout + 100);
170+
});
171+
});
172+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* Copyright 2025 Google LLC
4+
*
5+
* This file has been modified by Google LLC
6+
*
7+
* Licensed under the Apache License, Version 2.0 (the "License");
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* https://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
*/
19+
20+
import { IExporterTransport, ExportResponse } from '@opentelemetry/otlp-exporter-base';
21+
import { diag } from '@opentelemetry/api';
22+
23+
function isExportRetryable(statusCode: number): boolean {
24+
const retryCodes = [429, 502, 503, 504];
25+
return retryCodes.includes(statusCode);
26+
}
27+
28+
function parseRetryAfterToMills(
29+
retryAfter?: string | undefined | null
30+
): number | undefined {
31+
if (retryAfter == null) {
32+
return undefined;
33+
}
34+
35+
const seconds = Number.parseInt(retryAfter, 10);
36+
if (Number.isInteger(seconds)) {
37+
return seconds > 0 ? seconds * 1000 : -1;
38+
}
39+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After#directives
40+
const delay = new Date(retryAfter).getTime() - Date.now();
41+
42+
if (delay >= 0) {
43+
return delay;
44+
}
45+
return 0;
46+
}
47+
48+
/** @internal */
49+
export interface FetchTransportParameters {
50+
url: string;
51+
headers: () => Record<string, string>;
52+
}
53+
54+
/**
55+
* An implementation of IExporterTransport that can be used in the Edge Runtime.
56+
*
57+
* @internal
58+
*/
59+
export class FetchTransportEdge implements IExporterTransport {
60+
constructor(private parameters: FetchTransportParameters) {}
61+
62+
async send(data: Uint8Array, timeoutMillis: number): Promise<ExportResponse> {
63+
const abortController = new AbortController();
64+
const timeout = setTimeout(() => abortController.abort(), timeoutMillis);
65+
try {
66+
const url = new URL(this.parameters.url);
67+
const body = {
68+
method: 'POST',
69+
headers: this.parameters.headers(),
70+
signal: abortController.signal,
71+
keepalive: false,
72+
mode: 'cors',
73+
body: data
74+
} as RequestInit;
75+
const response = await fetch(url.href, body);
76+
77+
if (response.status >= 200 && response.status <= 299) {
78+
diag.debug('response success');
79+
return { status: 'success' };
80+
} else if (isExportRetryable(response.status)) {
81+
const retryAfter = response.headers.get('Retry-After');
82+
const retryInMillis = parseRetryAfterToMills(retryAfter);
83+
return { status: 'retryable', retryInMillis };
84+
}
85+
return {
86+
status: 'failure',
87+
error: new Error('Fetch request failed with non-retryable status'),
88+
};
89+
} catch (error) {
90+
if (error instanceof Error) {
91+
return {status: 'failure', error,};
92+
}
93+
return {
94+
status: 'failure',
95+
error: new Error(`Fetch request errored: ${error}`),
96+
};
97+
} finally {
98+
clearTimeout(timeout);
99+
}
100+
}
101+
102+
shutdown(): void {
103+
// Intentionally left empty, nothing to do.
104+
}
105+
}

0 commit comments

Comments
 (0)