diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index e18a2c8d2e6..155acb0c223 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -90,8 +90,10 @@ }, "dependencies": { "@firebase/component": "0.7.0", + "@firebase/installations": "0.6.19", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.203.0", + "@opentelemetry/core": "2.2.0", "@opentelemetry/exporter-logs-otlp-http": "0.203.0", "@opentelemetry/otlp-exporter-base": "0.205.0", "@opentelemetry/otlp-transformer": "0.205.0", diff --git a/packages/telemetry/src/api.test.ts b/packages/telemetry/src/api.test.ts index ece52760e84..62529a166e2 100644 --- a/packages/telemetry/src/api.test.ts +++ b/packages/telemetry/src/api.test.ts @@ -37,9 +37,11 @@ import { FirebaseAppCheckInternal } from '@firebase/app-check-interop-types'; import { captureError, flush, getTelemetry } from './api'; import { TelemetryService } from './service'; import { registerTelemetry } from './register'; +import { _FirebaseInstallationsInternal } from '@firebase/installations'; const PROJECT_ID = 'my-project'; const APP_ID = 'my-appid'; +const API_KEY = 'my-api-key'; const emittedLogs: LogRecord[] = []; @@ -263,6 +265,17 @@ describe('Top level API', () => { function getFakeApp(): FirebaseApp { registerTelemetry(); + _registerComponent( + new Component( + 'installations-internal', + () => + ({ + getId: async () => 'iid', + getToken: async () => 'authToken' + } as _FirebaseInstallationsInternal), + ComponentType.PUBLIC + ) + ); _registerComponent( new Component( 'app-check-internal', @@ -272,7 +285,11 @@ function getFakeApp(): FirebaseApp { ComponentType.PUBLIC ) ); - const app = initializeApp({}); + const app = initializeApp({ + projectId: PROJECT_ID, + appId: APP_ID, + apiKey: API_KEY + }); _addOrOverwriteComponent( app, //@ts-ignore diff --git a/packages/telemetry/src/logging/installation-id-provider.test.ts b/packages/telemetry/src/logging/installation-id-provider.test.ts new file mode 100644 index 00000000000..0de1143a753 --- /dev/null +++ b/packages/telemetry/src/logging/installation-id-provider.test.ts @@ -0,0 +1,79 @@ +/** + * @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 { InstallationIdProvider } from './installation-id-provider'; +import { _FirebaseInstallationsInternal } from '@firebase/installations'; +import { expect } from 'chai'; + +describe('InstallationIdProvider', () => { + it('should cache the installation id after the first call', async () => { + let callCount = 0; + const mockInstallations = { + getId: async () => { + callCount++; + return 'iid-123'; + } + } as unknown as _FirebaseInstallationsInternal; + + const mockProvider = { + getImmediate: () => mockInstallations, + get: async () => mockInstallations + } as any; + + const provider = new InstallationIdProvider(mockProvider); + + const attr1 = await provider.getAttribute(); + expect(attr1).to.deep.equal(['user.id', 'iid-123']); + expect(callCount).to.equal(1); + + const attr2 = await provider.getAttribute(); + expect(attr2).to.deep.equal(['user.id', 'iid-123']); + expect(callCount).to.equal(1); // Should still be 1 + }); + + it('should not cache if installation id is null', async () => { + let callCount = 0; + let returnValue: string | null = null; + const mockInstallations = { + getId: async () => { + callCount++; + return returnValue; + } + } as unknown as _FirebaseInstallationsInternal; + + const mockProvider = { + getImmediate: () => mockInstallations, + get: async () => mockInstallations + } as any; + + const provider = new InstallationIdProvider(mockProvider); + + const attr1 = await provider.getAttribute(); + expect(attr1).to.be.null; + expect(callCount).to.equal(1); + + returnValue = 'iid-456'; + const attr2 = await provider.getAttribute(); + expect(attr2).to.deep.equal(['user.id', 'iid-456']); + expect(callCount).to.equal(2); + + // Should cache now + const attr3 = await provider.getAttribute(); + expect(attr3).to.deep.equal(['user.id', 'iid-456']); + expect(callCount).to.equal(2); + }); +}); diff --git a/packages/telemetry/src/logging/installation-id-provider.ts b/packages/telemetry/src/logging/installation-id-provider.ts new file mode 100644 index 00000000000..3e507a33856 --- /dev/null +++ b/packages/telemetry/src/logging/installation-id-provider.ts @@ -0,0 +1,59 @@ +/** + * @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 { Provider } from '@firebase/component'; +import { DynamicLogAttributeProvider, LogEntryAttribute } from '../types'; +import { _FirebaseInstallationsInternal } from '@firebase/installations'; + +/** + * Allows logging to include the client's installation ID. + * + * @internal + */ +export class InstallationIdProvider implements DynamicLogAttributeProvider { + private installations: _FirebaseInstallationsInternal | null; + private _iid: string | undefined; + + constructor(installationsProvider: Provider<'installations-internal'>) { + this.installations = installationsProvider?.getImmediate({ + optional: true + }); + if (!this.installations) { + void installationsProvider + ?.get() + .then(installations => (this.installations = installations)) + .catch(); + } + } + + async getAttribute(): Promise { + if (!this.installations) { + return null; + } + if (this._iid) { + return ['user.id', this._iid]; + } + + const iid = await this.installations.getId(); + if (!iid) { + return null; + } + + this._iid = iid; + return ['user.id', iid]; + } +} diff --git a/packages/telemetry/src/logging/logger-provider.ts b/packages/telemetry/src/logging/logger-provider.ts index 3cb4e0e947c..7c87b76719f 100644 --- a/packages/telemetry/src/logging/logger-provider.ts +++ b/packages/telemetry/src/logging/logger-provider.ts @@ -30,8 +30,9 @@ import { createOtlpNetworkExportDelegate } from '@opentelemetry/otlp-exporter-base'; import { FetchTransport } from './fetch-transport'; -import { DynamicHeaderProvider } from '../types'; +import { DynamicHeaderProvider, DynamicLogAttributeProvider } from '../types'; import { FirebaseApp } from '@firebase/app'; +import { ExportResult } from '@opentelemetry/core'; /** * Create a logger provider for the current execution environment. @@ -41,7 +42,8 @@ import { FirebaseApp } from '@firebase/app'; export function createLoggerProvider( app: FirebaseApp, endpointUrl: string, - dynamicHeaderProviders: DynamicHeaderProvider[] = [] + dynamicHeaderProviders: DynamicHeaderProvider[] = [], + dynamicLogAttributeProviders: DynamicLogAttributeProvider[] = [] ): LoggerProvider { const resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'firebase_telemetry_service' @@ -64,11 +66,48 @@ export function createLoggerProvider( return new LoggerProvider({ resource, - processors: [new BatchLogRecordProcessor(logExporter)], + processors: [ + new BatchLogRecordProcessor( + new AsyncAttributeLogExporter(logExporter, dynamicLogAttributeProviders) + ) + ], logRecordLimits: {} }); } +/** A log exporter that appends log entries with resolved async attributes before exporting. */ +class AsyncAttributeLogExporter implements LogRecordExporter { + private readonly _delegate: LogRecordExporter; + + constructor( + exporter: OTLPLogExporter, + private dynamicLogAttributeProviders: DynamicLogAttributeProvider[] + ) { + this._delegate = exporter; + } + + async export( + logs: ReadableLogRecord[], + resultCallback: (result: ExportResult) => void + ): Promise { + await Promise.all( + this.dynamicLogAttributeProviders.map(async provider => { + const attribute = await provider.getAttribute(); + if (attribute) { + logs.forEach(log => { + log.attributes[attribute[0]] = attribute[1]; + }); + } + }) + ); + this._delegate.export(logs, resultCallback); + } + + shutdown(): Promise { + return this._delegate.shutdown(); + } +} + /** OTLP exporter that uses custom FetchTransport. */ class OTLPLogExporter extends OTLPExporterBase diff --git a/packages/telemetry/src/register.ts b/packages/telemetry/src/register.ts index 9cf0d885a0b..ae1e5855929 100644 --- a/packages/telemetry/src/register.ts +++ b/packages/telemetry/src/register.ts @@ -22,6 +22,11 @@ import { name, version } from '../package.json'; import { TelemetryService } from './service'; import { createLoggerProvider } from './logging/logger-provider'; import { AppCheckProvider } from './logging/appcheck-provider'; +import { InstallationIdProvider } from './logging/installation-id-provider'; + +// We only import types from this package elsewhere in the `telemetry` package, so this +// explicit import is needed here to prevent this module from being tree-shaken out. +import '@firebase/installations'; export function registerTelemetry(): void { _registerComponent( @@ -38,11 +43,18 @@ export function registerTelemetry(): void { // getImmediate for FirebaseApp will always succeed const app = container.getProvider('app').getImmediate(); const appCheckProvider = container.getProvider('app-check-internal'); + const installationsProvider = container.getProvider( + 'installations-internal' + ); const dynamicHeaderProviders = [new AppCheckProvider(appCheckProvider)]; + const dynamicLogAttributeProviders = [ + new InstallationIdProvider(installationsProvider) + ]; const loggerProvider = createLoggerProvider( app, endpointUrl, - dynamicHeaderProviders + dynamicHeaderProviders, + dynamicLogAttributeProviders ); return new TelemetryService(app, loggerProvider); diff --git a/packages/telemetry/src/types.ts b/packages/telemetry/src/types.ts index 95c517148cb..3c9d915a0ed 100644 --- a/packages/telemetry/src/types.ts +++ b/packages/telemetry/src/types.ts @@ -15,12 +15,38 @@ * limitations under the License. */ +type KeyValuePair = [key: string, value: string]; + +/** + * A type for Cloud Logging log entry attributes + * + * @internal + */ +export type LogEntryAttribute = KeyValuePair; + +/** + * An interface for classes that provide dynamic log entry attributes. + * + * Classes that implement this interface can be used to supply custom headers for logging. + * + * @internal + */ +export interface DynamicLogAttributeProvider { + /** + * Returns a record of attributes to be added to a log entry. + * + * @returns A {@link Promise} that resolves to a {@link LogEntryAttribute} key-value pair, + * or null if no attribute is to be added. + */ + getAttribute(): Promise; +} + /** * A type for HTTP Headers * * @internal */ -export type HttpHeader = [key: string, value: string]; +export type HttpHeader = KeyValuePair; /** * An interface for classes that provide dynamic headers. @@ -33,8 +59,8 @@ 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. + * @returns A {@link Promise} that resolves to a {@link HttpHeader} key-value pair, + * or null if no header is to be added. */ getHeader(): Promise; } diff --git a/yarn.lock b/yarn.lock index a5e54f121ef..2128373edfc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2777,6 +2777,13 @@ dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" +"@opentelemetry/core@2.2.0": + version "2.2.0" + resolved "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz#2f857d7790ff160a97db3820889b5f4cade6eaee" + integrity sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw== + dependencies: + "@opentelemetry/semantic-conventions" "^1.29.0" + "@opentelemetry/exporter-logs-otlp-http@0.203.0": version "0.203.0" resolved "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.203.0.tgz#cdecb5c5b39561aa8520c8bb78347c6e11c91a81"