Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/telemetry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 18 additions & 1 deletion packages/telemetry/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand Down
79 changes: 79 additions & 0 deletions packages/telemetry/src/logging/installation-id-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
59 changes: 59 additions & 0 deletions packages/telemetry/src/logging/installation-id-provider.ts
Original file line number Diff line number Diff line change
@@ -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<LogEntryAttribute | null> {
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];
}
}
45 changes: 42 additions & 3 deletions packages/telemetry/src/logging/logger-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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'
Expand All @@ -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<void> {
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<void> {
return this._delegate.shutdown();
}
}

/** OTLP exporter that uses custom FetchTransport. */
class OTLPLogExporter
extends OTLPExporterBase<ReadableLogRecord[]>
Expand Down
14 changes: 13 additions & 1 deletion packages/telemetry/src/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
Expand Down
32 changes: 29 additions & 3 deletions packages/telemetry/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LogEntryAttribute | null>;
}

/**
* 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.
Expand All @@ -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<string, string>} 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<HttpHeader | null>;
}
7 changes: 7 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading