diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index aaf43dffa67..6cd9ee81f9f 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -48,6 +48,8 @@ "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/exporter-logs-otlp-http": "0.203.0", + "@opentelemetry/otlp-exporter-base": "0.205.0", + "@opentelemetry/otlp-transformer": "0.205.0", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.203.0", "@opentelemetry/semantic-conventions": "1.36.0", diff --git a/packages/telemetry/src/helpers.ts b/packages/telemetry/src/helpers.ts deleted file mode 100644 index bfc7ab6260b..00000000000 --- a/packages/telemetry/src/helpers.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * - * 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 - * - * http://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 { - LoggerProvider, - BatchLogRecordProcessor -} from '@opentelemetry/sdk-logs'; -import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; -import { resourceFromAttributes } from '@opentelemetry/resources'; -import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'; - -/** - * Create default logger provider. - * - * @internal - */ -export function createLoggerProvider(): LoggerProvider { - const resource = resourceFromAttributes({ - [ATTR_SERVICE_NAME]: 'firebase_telemetry_service' - }); - - const otlpEndpoint = process.env.OTEL_ENDPOINT; - - const logExporter = new OTLPLogExporter({ - url: `${otlpEndpoint}/api/v1/logs` - }); - return new LoggerProvider({ - resource, - processors: [new BatchLogRecordProcessor(logExporter)] - }); -} diff --git a/packages/telemetry/src/logging/fetch-transport.edge.test.ts b/packages/telemetry/src/logging/fetch-transport.edge.test.ts new file mode 100644 index 00000000000..0e954495067 --- /dev/null +++ b/packages/telemetry/src/logging/fetch-transport.edge.test.ts @@ -0,0 +1,177 @@ +/* + * @license + * Copyright The OpenTelemetry Authors + * Copyright 2025 Google LLC + * + * This file has been modified by Google LLC + * + * 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 { FetchTransportEdge } from './fetch-transport.edge'; +import { + ExportResponseRetryable, + ExportResponseFailure, + ExportResponseSuccess +} from '@opentelemetry/otlp-exporter-base'; + +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('FetchTransportEdge', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('send', () => { + it('returns success when request succeeds', done => { + // arrange + const fetchStub = sinon + .stub(globalThis, 'fetch') + .resolves(new Response('test response', { status: 200 })); + const transport = new FetchTransportEdge(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 + } + ); + done(); + } catch (e) { + done(e); + } + }, done /* catch any rejections */); + }); + + it('returns failure when request fails', done => { + // arrange + sinon + .stub(globalThis, 'fetch') + .resolves(new Response('', { status: 404 })); + const transport = new FetchTransportEdge(testTransportParameters); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'failure'); + done(); + } catch (e) { + done(e); + } + }, done /* catch any rejections */); + }); + + it('returns retryable when request is retryable', done => { + // arrange + sinon + .stub(globalThis, 'fetch') + .resolves( + new Response('', { status: 503, headers: { 'Retry-After': '5' } }) + ); + const transport = new FetchTransportEdge(testTransportParameters); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'retryable'); + assert.strictEqual( + (response as ExportResponseRetryable).retryInMillis, + 5000 + ); + done(); + } catch (e) { + done(e); + } + }, done /* catch any rejections */); + }); + + it('returns failure when request times out', done => { + // arrange + const abortError = new Error('aborted request'); + abortError.name = 'AbortError'; + sinon.stub(globalThis, 'fetch').rejects(abortError); + const clock = sinon.useFakeTimers(); + const transport = new FetchTransportEdge(testTransportParameters); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'failure'); + assert.strictEqual( + (response as ExportResponseFailure).error.message, + 'aborted request' + ); + done(); + } catch (e) { + done(e); + } + }, done /* catch any rejections */); + clock.tick(requestTimeout + 100); + }); + + it('returns failure when no server exists', done => { + // arrange + sinon.stub(globalThis, 'fetch').throws(new Error('fetch failed')); + const clock = sinon.useFakeTimers(); + const transport = new FetchTransportEdge(testTransportParameters); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'failure'); + assert.strictEqual( + (response as ExportResponseFailure).error.message, + 'fetch failed' + ); + done(); + } catch (e) { + done(e); + } + }, done /* catch any rejections */); + clock.tick(requestTimeout + 100); + }); + }); +}); diff --git a/packages/telemetry/src/logging/fetch-transport.edge.ts b/packages/telemetry/src/logging/fetch-transport.edge.ts new file mode 100644 index 00000000000..9b095b779fc --- /dev/null +++ b/packages/telemetry/src/logging/fetch-transport.edge.ts @@ -0,0 +1,109 @@ +/* + * @license + * Copyright The OpenTelemetry Authors + * Copyright 2025 Google LLC + * + * This file has been modified by Google LLC + * + * 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, + ExportResponse +} from '@opentelemetry/otlp-exporter-base'; +import { diag } from '@opentelemetry/api'; + +function isExportRetryable(statusCode: number): boolean { + const retryCodes = [429, 502, 503, 504]; + return retryCodes.includes(statusCode); +} + +function parseRetryAfterToMills( + retryAfter?: string | undefined | null +): number | undefined { + if (retryAfter == null) { + return undefined; + } + + const seconds = Number.parseInt(retryAfter, 10); + if (Number.isInteger(seconds)) { + return seconds > 0 ? seconds * 1000 : -1; + } + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After#directives + const delay = new Date(retryAfter).getTime() - Date.now(); + + if (delay >= 0) { + return delay; + } + return 0; +} + +/** @internal */ +export interface FetchTransportParameters { + url: string; + headers: () => Record; +} + +/** + * An implementation of IExporterTransport that can be used in the Edge Runtime. + * + * @internal + */ +export class FetchTransportEdge 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 url = new URL(this.parameters.url); + const body = { + method: 'POST', + headers: this.parameters.headers(), + signal: abortController.signal, + keepalive: false, + mode: 'cors', + body: data + } as RequestInit; + const response = await fetch(url.href, body); + + 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 instanceof Error) { + return { status: 'failure', error }; + } + return { + status: 'failure', + error: new Error(`Fetch request errored: ${error}`) + }; + } finally { + clearTimeout(timeout); + } + } + + shutdown(): void { + // Intentionally left empty, nothing to do. + } +} diff --git a/packages/telemetry/src/logging/logger-provider.ts b/packages/telemetry/src/logging/logger-provider.ts new file mode 100644 index 00000000000..bd982f04cfe --- /dev/null +++ b/packages/telemetry/src/logging/logger-provider.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * 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 + * + * http://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 { + LoggerProvider, + BatchLogRecordProcessor, + ReadableLogRecord, + LogRecordExporter +} from '@opentelemetry/sdk-logs'; +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'; +import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer'; +import type { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base'; +import { + OTLPExporterBase, + createOtlpNetworkExportDelegate +} from '@opentelemetry/otlp-exporter-base'; +import { FetchTransportEdge } from './fetch-transport.edge'; + +/** + * Create a logger provider for the current execution environment. + * + * @internal + */ +export function createLoggerProvider(): LoggerProvider { + const resource = resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'firebase_telemetry_service' + }); + const otlpEndpoint = `${process.env.OTEL_ENDPOINT}/api/v1/logs`; + + if (process?.env?.NEXT_RUNTIME === 'edge') { + // We need a slightly custom implementation for the Edge Runtime, because it doesn't have access + // to many features available in Node. + const logExporter = new OTLPLogExporterEdge({ url: otlpEndpoint }); + const provider = new LoggerProvider({ + resource, + processors: [new BatchLogRecordProcessor(logExporter)], + logRecordLimits: {} + }); + return provider; + } else { + const logExporter = new OTLPLogExporter({ url: otlpEndpoint }); + return new LoggerProvider({ + resource, + processors: [new BatchLogRecordProcessor(logExporter)] + }); + } +} + +/** OTLP exporter that uses custom FetchTransport for use in the Edge Runtime. */ +class OTLPLogExporterEdge + extends OTLPExporterBase + implements LogRecordExporter +{ + constructor(config: OTLPExporterConfigBase = {}) { + super( + createOtlpNetworkExportDelegate( + { + timeoutMillis: 10000, + concurrencyLimit: 5, + compression: 'none' + }, + JsonLogsSerializer, + new FetchTransportEdge({ + url: config.url!, + headers: () => ({ + 'Content-Type': 'application/json' + }) + }) + ) + ); + } +} diff --git a/packages/telemetry/src/register.node.ts b/packages/telemetry/src/register.node.ts index 35fe7bbfbfd..c5b17dc3388 100644 --- a/packages/telemetry/src/register.node.ts +++ b/packages/telemetry/src/register.node.ts @@ -20,7 +20,7 @@ import { Component, ComponentType } from '@firebase/component'; import { TELEMETRY_TYPE } from './constants'; import { name, version } from '../package.json'; import { TelemetryService } from './service'; -import { createLoggerProvider } from './helpers'; +import { createLoggerProvider } from './logging/logger-provider'; export function registerTelemetry(): void { _registerComponent( diff --git a/packages/telemetry/src/register.ts b/packages/telemetry/src/register.ts index 12a743ff906..2a75bf96157 100644 --- a/packages/telemetry/src/register.ts +++ b/packages/telemetry/src/register.ts @@ -20,7 +20,7 @@ import { Component, ComponentType } from '@firebase/component'; import { TELEMETRY_TYPE } from './constants'; import { name, version } from '../package.json'; import { TelemetryService } from './service'; -import { createLoggerProvider } from './helpers'; +import { createLoggerProvider } from './logging/logger-provider'; export function registerTelemetry(): void { _registerComponent( diff --git a/yarn.lock b/yarn.lock index 53d876ea096..487d37e1695 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2697,6 +2697,13 @@ dependencies: "@opentelemetry/api" "^1.3.0" +"@opentelemetry/api-logs@0.205.0": + version "0.205.0" + resolved "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.205.0.tgz#7d334958c0eaa0725a1fe51aadc9b39ed7d4b6f2" + integrity sha512-wBlPk1nFB37Hsm+3Qy73yQSobVn28F4isnWIBvKpd5IUH/eat8bwcL02H9yzmHyyPmukeccSl2mbN5sDQZYnPg== + dependencies: + "@opentelemetry/api" "^1.3.0" + "@opentelemetry/api@1.9.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@~1.9.0": version "1.9.0" resolved "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" @@ -2735,6 +2742,14 @@ "@opentelemetry/core" "2.0.1" "@opentelemetry/otlp-transformer" "0.203.0" +"@opentelemetry/otlp-exporter-base@0.205.0": + version "0.205.0" + resolved "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.205.0.tgz#3a4a09382e517af88d152c2f31e47ac16a84b43c" + integrity sha512-2MN0C1IiKyo34M6NZzD6P9Nv9Dfuz3OJ3rkZwzFmF6xzjDfqqCTatc9v1EpNfaP55iDOCLHFyYNCgs61FFgtUQ== + dependencies: + "@opentelemetry/core" "2.1.0" + "@opentelemetry/otlp-transformer" "0.205.0" + "@opentelemetry/otlp-transformer@0.203.0": version "0.203.0" resolved "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.203.0.tgz#e93220ed56aae573640c85e158221094d1f2905b" @@ -2748,6 +2763,19 @@ "@opentelemetry/sdk-trace-base" "2.0.1" protobufjs "^7.3.0" +"@opentelemetry/otlp-transformer@0.205.0": + version "0.205.0" + resolved "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.205.0.tgz#bf53729676a3f80a701141762ed6e3c92ec82963" + integrity sha512-KmObgqPtk9k/XTlWPJHdMbGCylRAmMJNXIRh6VYJmvlRDMfe+DonH41G7eenG8t4FXn3fxOGh14o/WiMRR6vPg== + dependencies: + "@opentelemetry/api-logs" "0.205.0" + "@opentelemetry/core" "2.1.0" + "@opentelemetry/resources" "2.1.0" + "@opentelemetry/sdk-logs" "0.205.0" + "@opentelemetry/sdk-metrics" "2.1.0" + "@opentelemetry/sdk-trace-base" "2.1.0" + protobufjs "^7.3.0" + "@opentelemetry/resources@2.0.1": version "2.0.1" resolved "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz#0365d134291c0ed18d96444a1e21d0e6a481c840" @@ -2773,6 +2801,15 @@ "@opentelemetry/core" "2.0.1" "@opentelemetry/resources" "2.0.1" +"@opentelemetry/sdk-logs@0.205.0": + version "0.205.0" + resolved "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.205.0.tgz#4a302b1507e753d2c4d9bddb5243aecf5eb7156b" + integrity sha512-nyqhNQ6eEzPWQU60Nc7+A5LIq8fz3UeIzdEVBQYefB4+msJZ2vuVtRuk9KxPMw1uHoHDtYEwkr2Ct0iG29jU8w== + dependencies: + "@opentelemetry/api-logs" "0.205.0" + "@opentelemetry/core" "2.1.0" + "@opentelemetry/resources" "2.1.0" + "@opentelemetry/sdk-metrics@2.0.1": version "2.0.1" resolved "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz#efb6e9349e8a9038ac622e172692bfcdcad8010b" @@ -2781,6 +2818,14 @@ "@opentelemetry/core" "2.0.1" "@opentelemetry/resources" "2.0.1" +"@opentelemetry/sdk-metrics@2.1.0": + version "2.1.0" + resolved "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.1.0.tgz#fbb9b270ee56c29feba885062e5c0418213fccf2" + integrity sha512-J9QX459mzqHLL9Y6FZ4wQPRZG4TOpMCyPOh6mkr/humxE1W2S3Bvf4i75yiMW9uyed2Kf5rxmLhTm/UK8vNkAw== + dependencies: + "@opentelemetry/core" "2.1.0" + "@opentelemetry/resources" "2.1.0" + "@opentelemetry/sdk-trace-base@2.0.1": version "2.0.1" resolved "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz#25808bb6a3d08a501ad840249e4d43d3493eb6e5"