Skip to content

Commit b107f7d

Browse files
authored
Add support for logging in Edge Runtime (#9252)
* Add support for logging in Edge Runtime * format * format
1 parent caa9b71 commit b107f7d

File tree

8 files changed

+423
-47
lines changed

8 files changed

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

0 commit comments

Comments
 (0)