diff --git a/packages/telemetry/src/logging/appcheck-provider.ts b/packages/telemetry/src/logging/appcheck-provider.ts new file mode 100644 index 00000000000..e2e0b199ebb --- /dev/null +++ b/packages/telemetry/src/logging/appcheck-provider.ts @@ -0,0 +1,57 @@ +/** + * @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 { DynamicHeaderProvider } from '../types'; +import { Provider } from '@firebase/component'; +import { + FirebaseAppCheckInternal, + AppCheckInternalComponentName +} from '@firebase/app-check-interop-types'; + +/** + * An implementation of DynamicHeaderProvider that can be used to provide App Check token headers. + * + * @internal + */ +export class AppCheckProvider implements DynamicHeaderProvider { + appCheck: FirebaseAppCheckInternal | null; + + constructor(appCheckProvider: Provider) { + this.appCheck = appCheckProvider?.getImmediate({ optional: true }); + if (!this.appCheck) { + void appCheckProvider + ?.get() + .then(appCheck => (this.appCheck = appCheck)) + .catch(); + } + } + + async getHeader(): Promise | null> { + if (!this.appCheck) { + return null; + } + + const appCheckToken = await this.appCheck.getToken(); + // The error field must be checked as when there is an error, the token field is populated with + // a dummy token. + if (!appCheckToken || !!appCheckToken.error) { + return null; + } + + return { 'X-Firebase-AppCheck': appCheckToken.token }; + } +} diff --git a/packages/telemetry/src/logging/fetch-transport.edge.test.ts b/packages/telemetry/src/logging/fetch-transport.test.ts similarity index 63% rename from packages/telemetry/src/logging/fetch-transport.edge.test.ts rename to packages/telemetry/src/logging/fetch-transport.test.ts index 0e954495067..a3ae5501a7e 100644 --- a/packages/telemetry/src/logging/fetch-transport.edge.test.ts +++ b/packages/telemetry/src/logging/fetch-transport.test.ts @@ -20,7 +20,8 @@ import * as sinon from 'sinon'; import * as assert from 'assert'; -import { FetchTransportEdge } from './fetch-transport.edge'; +import { DynamicHeaderProvider } from '../types'; +import { FetchTransport } from './fetch-transport'; import { ExportResponseRetryable, ExportResponseFailure, @@ -39,7 +40,7 @@ const testTransportParameters = { const requestTimeout = 1000; const testPayload = Uint8Array.from([1, 2, 3]); -describe('FetchTransportEdge', () => { +describe('FetchTransport', () => { afterEach(() => { sinon.restore(); }); @@ -50,7 +51,7 @@ describe('FetchTransportEdge', () => { const fetchStub = sinon .stub(globalThis, 'fetch') .resolves(new Response('test response', { status: 200 })); - const transport = new FetchTransportEdge(testTransportParameters); + const transport = new FetchTransport(testTransportParameters); //act transport.send(testPayload, requestTimeout).then(response => { @@ -87,7 +88,7 @@ describe('FetchTransportEdge', () => { sinon .stub(globalThis, 'fetch') .resolves(new Response('', { status: 404 })); - const transport = new FetchTransportEdge(testTransportParameters); + const transport = new FetchTransport(testTransportParameters); //act transport.send(testPayload, requestTimeout).then(response => { @@ -108,7 +109,7 @@ describe('FetchTransportEdge', () => { .resolves( new Response('', { status: 503, headers: { 'Retry-After': '5' } }) ); - const transport = new FetchTransportEdge(testTransportParameters); + const transport = new FetchTransport(testTransportParameters); //act transport.send(testPayload, requestTimeout).then(response => { @@ -132,7 +133,7 @@ describe('FetchTransportEdge', () => { abortError.name = 'AbortError'; sinon.stub(globalThis, 'fetch').rejects(abortError); const clock = sinon.useFakeTimers(); - const transport = new FetchTransportEdge(testTransportParameters); + const transport = new FetchTransport(testTransportParameters); //act transport.send(testPayload, requestTimeout).then(response => { @@ -155,7 +156,7 @@ describe('FetchTransportEdge', () => { // arrange sinon.stub(globalThis, 'fetch').throws(new Error('fetch failed')); const clock = sinon.useFakeTimers(); - const transport = new FetchTransportEdge(testTransportParameters); + const transport = new FetchTransport(testTransportParameters); //act transport.send(testPayload, requestTimeout).then(response => { @@ -173,5 +174,82 @@ describe('FetchTransportEdge', () => { }, done /* catch any rejections */); clock.tick(requestTimeout + 100); }); + + it('attaches static and dynamic headers to the request', done => { + // arrange + const fetchStub = sinon + .stub(globalThis, 'fetch') + .resolves(new Response('test response', { status: 200 })); + + const dynamicProvider: DynamicHeaderProvider = { + getHeader: sinon.stub().resolves({ 'dynamic-header': 'dynamic-value' }) + }; + + const transport = new FetchTransport({ + ...testTransportParameters, + dynamicHeaders: [dynamicProvider] + }); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'success'); + sinon.assert.calledOnceWithMatch( + fetchStub, + testTransportParameters.url, + { + method: 'POST', + headers: { + foo: 'foo-value', + bar: 'bar-value', + 'Content-Type': 'application/json', + 'dynamic-header': 'dynamic-value' + }, + body: testPayload + } + ); + done(); + } catch (e) { + done(e); + } + }, done /* catch any rejections */); + }); + + it('handles dynamic header providers that return null', done => { + // arrange + const fetchStub = sinon + .stub(globalThis, 'fetch') + .resolves(new Response('test response', { status: 200 })); + + const dynamicProvider: DynamicHeaderProvider = { + getHeader: sinon.stub().resolves(null) + }; + + const transport = new FetchTransport({ + ...testTransportParameters, + dynamicHeaders: [dynamicProvider] + }); + + //act + transport.send(testPayload, requestTimeout).then(response => { + // assert + try { + assert.strictEqual(response.status, 'success'); + sinon.assert.calledOnceWithMatch( + fetchStub, + testTransportParameters.url, + { + method: 'POST', + headers: testTransportParameters.headers(), + body: testPayload + } + ); + done(); + } catch (e) { + done(e); + } + }, done /* catch any rejections */); + }); }); }); diff --git a/packages/telemetry/src/logging/fetch-transport.edge.ts b/packages/telemetry/src/logging/fetch-transport.ts similarity index 80% rename from packages/telemetry/src/logging/fetch-transport.edge.ts rename to packages/telemetry/src/logging/fetch-transport.ts index 9b095b779fc..ad5a4b253a7 100644 --- a/packages/telemetry/src/logging/fetch-transport.edge.ts +++ b/packages/telemetry/src/logging/fetch-transport.ts @@ -23,6 +23,7 @@ import { ExportResponse } from '@opentelemetry/otlp-exporter-base'; import { diag } from '@opentelemetry/api'; +import { DynamicHeaderProvider } from '../types'; function isExportRetryable(statusCode: number): boolean { const retryCodes = [429, 502, 503, 504]; @@ -53,14 +54,15 @@ function parseRetryAfterToMills( export interface FetchTransportParameters { url: string; headers: () => Record; + dynamicHeaders?: DynamicHeaderProvider[]; } /** - * An implementation of IExporterTransport that can be used in the Edge Runtime. + * An implementation of IExporterTransport. * * @internal */ -export class FetchTransportEdge implements IExporterTransport { +export class FetchTransport implements IExporterTransport { constructor(private parameters: FetchTransportParameters) {} async send(data: Uint8Array, timeoutMillis: number): Promise { @@ -68,9 +70,27 @@ export class FetchTransportEdge implements IExporterTransport { const timeout = setTimeout(() => abortController.abort(), timeoutMillis); try { const url = new URL(this.parameters.url); + const headers = this.parameters.headers(); + + if ( + this.parameters.dynamicHeaders && + this.parameters.dynamicHeaders.length > 0 + ) { + const dynamicHeaderPromises = this.parameters.dynamicHeaders.map( + provider => provider.getHeader() + ); + const resolvedHeaders = await Promise.all(dynamicHeaderPromises); + + for (const header of resolvedHeaders) { + if (header) { + Object.assign(headers, header); + } + } + } + const body = { method: 'POST', - headers: this.parameters.headers(), + headers, signal: abortController.signal, keepalive: false, mode: 'cors', diff --git a/packages/telemetry/src/logging/logger-provider.ts b/packages/telemetry/src/logging/logger-provider.ts index cc22e22a297..24a626e11a9 100644 --- a/packages/telemetry/src/logging/logger-provider.ts +++ b/packages/telemetry/src/logging/logger-provider.ts @@ -23,21 +23,24 @@ import { } 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'; +import { FetchTransport } from './fetch-transport'; +import { DynamicHeaderProvider } from '../types'; /** * Create a logger provider for the current execution environment. * * @internal */ -export function createLoggerProvider(endpointUrl: string): LoggerProvider { +export function createLoggerProvider( + endpointUrl: string, + dynamicHeaders: DynamicHeaderProvider[] = [] +): LoggerProvider { const resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'firebase_telemetry_service' }); @@ -45,32 +48,27 @@ export function createLoggerProvider(endpointUrl: string): LoggerProvider { endpointUrl = endpointUrl.slice(0, -1); } const otlpEndpoint = `${endpointUrl}/api/v1/logs`; + const logExporter = new OTLPLogExporter( + { url: otlpEndpoint }, + dynamicHeaders + ); - if (typeof process !== 'undefined' && 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)] - }); - } + return new LoggerProvider({ + resource, + processors: [new BatchLogRecordProcessor(logExporter)], + logRecordLimits: {} + }); } -/** OTLP exporter that uses custom FetchTransport for use in the Edge Runtime. */ -class OTLPLogExporterEdge +/** OTLP exporter that uses custom FetchTransport. */ +class OTLPLogExporter extends OTLPExporterBase implements LogRecordExporter { - constructor(config: OTLPExporterConfigBase = {}) { + constructor( + config: OTLPExporterConfigBase = {}, + dynamicHeaders: DynamicHeaderProvider[] = [] + ) { super( createOtlpNetworkExportDelegate( { @@ -79,11 +77,12 @@ class OTLPLogExporterEdge compression: 'none' }, JsonLogsSerializer, - new FetchTransportEdge({ + new FetchTransport({ url: config.url!, headers: () => ({ 'Content-Type': 'application/json' - }) + }), + dynamicHeaders }) ) ); diff --git a/packages/telemetry/src/register.node.ts b/packages/telemetry/src/register.node.ts index 58988dd6f13..8a5b233f960 100644 --- a/packages/telemetry/src/register.node.ts +++ b/packages/telemetry/src/register.node.ts @@ -37,9 +37,8 @@ export function registerTelemetry(): void { // getImmediate for FirebaseApp will always succeed const app = container.getProvider('app').getImmediate(); const loggerProvider = createLoggerProvider(endpointUrl); - const appCheckProvider = container.getProvider('app-check-internal'); - return new TelemetryService(app, loggerProvider, appCheckProvider); + return new TelemetryService(app, loggerProvider); }, ComponentType.PUBLIC ).setMultipleInstances(true) diff --git a/packages/telemetry/src/register.ts b/packages/telemetry/src/register.ts index 8d7fb8d5397..f410a59d8ae 100644 --- a/packages/telemetry/src/register.ts +++ b/packages/telemetry/src/register.ts @@ -21,6 +21,7 @@ import { TELEMETRY_TYPE } from './constants'; import { name, version } from '../package.json'; import { TelemetryService } from './service'; import { createLoggerProvider } from './logging/logger-provider'; +import { AppCheckProvider } from './logging/appcheck-provider'; export function registerTelemetry(): void { _registerComponent( @@ -36,10 +37,14 @@ export function registerTelemetry(): void { // getImmediate for FirebaseApp will always succeed const app = container.getProvider('app').getImmediate(); - const loggerProvider = createLoggerProvider(endpointUrl); const appCheckProvider = container.getProvider('app-check-internal'); + const dynamicHeaders = [new AppCheckProvider(appCheckProvider)]; + const loggerProvider = createLoggerProvider( + endpointUrl, + dynamicHeaders + ); - return new TelemetryService(app, loggerProvider, appCheckProvider); + return new TelemetryService(app, loggerProvider); }, ComponentType.PUBLIC ).setMultipleInstances(true) diff --git a/packages/telemetry/src/service.ts b/packages/telemetry/src/service.ts index a93059ad9fa..e09e861d3dc 100644 --- a/packages/telemetry/src/service.ts +++ b/packages/telemetry/src/service.ts @@ -18,23 +18,9 @@ import { _FirebaseService, FirebaseApp } from '@firebase/app'; import { Telemetry } from './public-types'; import { LoggerProvider } from '@opentelemetry/sdk-logs'; -import { Provider } from '@firebase/component'; -import { - AppCheckInternalComponentName, - FirebaseAppCheckInternal -} from '@firebase/app-check-interop-types'; export class TelemetryService implements Telemetry, _FirebaseService { - appCheck: FirebaseAppCheckInternal | null; - - constructor( - public app: FirebaseApp, - public loggerProvider: LoggerProvider, - appCheckProvider?: Provider - ) { - const appCheck = appCheckProvider?.getImmediate({ optional: true }); - this.appCheck = appCheck || null; - } + constructor(public app: FirebaseApp, public loggerProvider: LoggerProvider) {} _delete(): Promise { return Promise.resolve(); diff --git a/packages/telemetry/src/types.ts b/packages/telemetry/src/types.ts new file mode 100644 index 00000000000..f1041e5dbc9 --- /dev/null +++ b/packages/telemetry/src/types.ts @@ -0,0 +1,33 @@ +/** + * @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. + */ + +/** + * An interface for classes that provide dynamic headers. + * + * Classes that implement this interface can be used to supply custom headers for logging. + * + * @internal + */ +export interface DynamicHeaderProvider { + /** + * Returns a record of headers to be added to a request. + * + * @returns A {@link Promise} that resolves to a {@link Record} of header + * key-value pairs, or null if no headers are to be added. + */ + getHeader(): Promise | null>; +} diff --git a/yarn.lock b/yarn.lock index e6e4692d51e..70c7e806773 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15718,7 +15718,7 @@ string-argv@~0.3.1: resolved "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -15736,15 +15736,6 @@ string-width@^1.0.1, string-width@^1.0.2: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -15808,7 +15799,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15829,13 +15820,6 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -17515,7 +17499,7 @@ workerpool@6.2.0: resolved "https://registry.npmjs.org/workerpool/-/workerpool-6.2.0.tgz#827d93c9ba23ee2019c3ffaff5c27fccea289e8b" integrity sha512-Rsk5qQHJ9eowMH28Jwhe8HEbmdYDX4lwoMWshiCXugjtHqMD9ZbiqSDLxcsfdqsETPzVUtX5s1Z5kStiIM6l4A== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -17549,15 +17533,6 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"