From 3a0f8a9fa46789610087425b0dd7b0fd4c37747b Mon Sep 17 00:00:00 2001 From: Anthony Barone Date: Wed, 3 Dec 2025 03:01:25 +0000 Subject: [PATCH 1/5] Create session eagerly in registerTelemetry --- packages/telemetry/src/api.test.ts | 36 +++++++++++++------ packages/telemetry/src/helpers.ts | 50 +++++++++++++++++++++------ packages/telemetry/src/react/index.ts | 14 +++----- packages/telemetry/src/register.ts | 8 ++++- 4 files changed, 75 insertions(+), 33 deletions(-) diff --git a/packages/telemetry/src/api.test.ts b/packages/telemetry/src/api.test.ts index e953da94d3..a3252de7c9 100644 --- a/packages/telemetry/src/api.test.ts +++ b/packages/telemetry/src/api.test.ts @@ -113,6 +113,9 @@ describe('Top level API', () => { value: cryptoMock, writable: true }); + + // Simulate session creation that now happens in registerTelemetry + storage[TELEMETRY_SESSION_ID_KEY] = MOCK_SESSION_ID; }); afterEach(async () => { @@ -154,6 +157,28 @@ describe('Top level API', () => { }); }); + describe('registerTelemetry()', () => { + it('should create a session and emit a log entry if none exists', () => { + // Clear storage to simulate no session + storage = {}; + emittedLogs.length = 0; + + getTelemetry(getFakeApp()); + + // Check if session ID was created in storage + expect(storage[TELEMETRY_SESSION_ID_KEY]).to.equal(MOCK_SESSION_ID); + }); + + it('should not create a new session if one exists', () => { + storage[TELEMETRY_SESSION_ID_KEY] = 'existing-session'; + emittedLogs.length = 0; + + getTelemetry(getFakeApp()); + + expect(storage[TELEMETRY_SESSION_ID_KEY]).to.equal('existing-session'); + }); + }); + describe('captureError()', () => { it('should capture an Error object correctly', () => { const error = new Error('This is a test error'); @@ -312,17 +337,6 @@ describe('Top level API', () => { }); describe('Session Metadata', () => { - it('should generate and store a new session ID if none exists', () => { - captureError(fakeTelemetry, 'error'); - - expect(emittedLogs.length).to.equal(1); - const log = emittedLogs[0]; - expect(log.attributes![LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]).to.equal( - MOCK_SESSION_ID - ); - expect(storage[TELEMETRY_SESSION_ID_KEY]).to.equal(MOCK_SESSION_ID); - }); - it('should retrieve existing session ID from sessionStorage', () => { storage[TELEMETRY_SESSION_ID_KEY] = 'existing-session-id'; diff --git a/packages/telemetry/src/helpers.ts b/packages/telemetry/src/helpers.ts index 64593816d1..b7bc61de0e 100644 --- a/packages/telemetry/src/helpers.ts +++ b/packages/telemetry/src/helpers.ts @@ -15,11 +15,18 @@ * limitations under the License. */ +import { LoggerProvider, SeverityNumber } from '@opentelemetry/api-logs'; import * as constants from './auto-constants'; -import { TELEMETRY_SESSION_ID_KEY } from './constants'; +import { + LOG_ENTRY_ATTRIBUTE_KEYS, + TELEMETRY_SESSION_ID_KEY +} from './constants'; import { Telemetry } from './public-types'; import { TelemetryService } from './service'; +/** + * Returns the app version from the provided Telemetry instance, if available. + */ export function getAppVersion(telemetry: Telemetry): string { if ((telemetry as TelemetryService).options?.appVersion) { return (telemetry as TelemetryService).options!.appVersion!; @@ -29,18 +36,39 @@ export function getAppVersion(telemetry: Telemetry): string { return 'unset'; } +/** + * Returns the session ID stored in sessionStorage, if available. + */ export function getSessionId(): string | undefined { - if ( - typeof sessionStorage !== 'undefined' && - typeof crypto?.randomUUID === 'function' - ) { + if (typeof sessionStorage !== 'undefined') { + try { + return sessionStorage.getItem(TELEMETRY_SESSION_ID_KEY) || undefined; + } catch (e) { + // Ignore errors accessing sessionStorage (e.g. security restrictions) + } + } +} + +/** + * Generate a new session UUID. We record it in two places: + * 1. The client browser's sessionStorage (if available) + * 2. In Cloud Logging as its own log entry + */ +export function startNewSession(loggerProvider: LoggerProvider): void { + if (typeof sessionStorage !== 'undefined') { try { - let sessionId = sessionStorage.getItem(TELEMETRY_SESSION_ID_KEY); - if (!sessionId) { - sessionId = crypto.randomUUID(); - sessionStorage.setItem(TELEMETRY_SESSION_ID_KEY, sessionId); - } - return sessionId; + const sessionId = crypto.randomUUID(); + sessionStorage.setItem(TELEMETRY_SESSION_ID_KEY, sessionId); + + // Emit session creation log + const logger = loggerProvider.getLogger('session-logger'); + logger.emit({ + severityNumber: SeverityNumber.DEBUG, + body: 'Session created', + attributes: { + [LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]: sessionId + } + }); } catch (e) { // Ignore errors accessing sessionStorage (e.g. security restrictions) } diff --git a/packages/telemetry/src/react/index.ts b/packages/telemetry/src/react/index.ts index 3c28327ede..7718a8df9a 100644 --- a/packages/telemetry/src/react/index.ts +++ b/packages/telemetry/src/react/index.ts @@ -53,24 +53,18 @@ export function FirebaseTelemetry({ telemetryOptions?: TelemetryOptions; }): null { useEffect(() => { + const telemetry = getTelemetry(firebaseApp, telemetryOptions); + if (typeof window === 'undefined') { return; } const errorListener = (event: ErrorEvent): void => { - captureError( - getTelemetry(firebaseApp, telemetryOptions), - event.error, - {} - ); + captureError(telemetry, event.error, {}); }; const unhandledRejectionListener = (event: PromiseRejectionEvent): void => { - captureError( - getTelemetry(firebaseApp, telemetryOptions), - event.reason, - {} - ); + captureError(telemetry, event.reason, {}); }; try { diff --git a/packages/telemetry/src/register.ts b/packages/telemetry/src/register.ts index ae1e585592..e02760da92 100644 --- a/packages/telemetry/src/register.ts +++ b/packages/telemetry/src/register.ts @@ -17,16 +17,17 @@ import { _registerComponent, registerVersion } from '@firebase/app'; import { Component, ComponentType } from '@firebase/component'; -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'; import { InstallationIdProvider } from './logging/installation-id-provider'; +import { TELEMETRY_TYPE } from './constants'; // 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'; +import { getSessionId, startNewSession } from './helpers'; export function registerTelemetry(): void { _registerComponent( @@ -57,6 +58,11 @@ export function registerTelemetry(): void { dynamicLogAttributeProviders ); + // Immediately track this as a new client session (if one doesn't exist yet) + if (!getSessionId()) { + startNewSession(loggerProvider); + } + return new TelemetryService(app, loggerProvider); }, ComponentType.PUBLIC From f779bc54ebc342faf213d8dd34a2bcf9edd2de8d Mon Sep 17 00:00:00 2001 From: Anthony Barone Date: Mon, 8 Dec 2025 17:04:01 +0000 Subject: [PATCH 2/5] Address feedback --- packages/telemetry/src/helpers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/telemetry/src/helpers.ts b/packages/telemetry/src/helpers.ts index b7bc61de0e..ce2f58a25b 100644 --- a/packages/telemetry/src/helpers.ts +++ b/packages/telemetry/src/helpers.ts @@ -55,7 +55,10 @@ export function getSessionId(): string | undefined { * 2. In Cloud Logging as its own log entry */ export function startNewSession(loggerProvider: LoggerProvider): void { - if (typeof sessionStorage !== 'undefined') { + if ( + typeof sessionStorage !== 'undefined' && + typeof crypto?.randomUUID === 'function' + ) { try { const sessionId = crypto.randomUUID(); sessionStorage.setItem(TELEMETRY_SESSION_ID_KEY, sessionId); From b3e8d14b84a90e7443757242a396b3ed5fb1df3a Mon Sep 17 00:00:00 2001 From: Anthony Barone Date: Mon, 8 Dec 2025 17:17:12 +0000 Subject: [PATCH 3/5] Add app version to session created log entry --- packages/telemetry/src/helpers.test.ts | 139 +++++++++++++++++++++++++ packages/telemetry/src/helpers.ts | 8 +- packages/telemetry/src/register.ts | 6 +- 3 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 packages/telemetry/src/helpers.test.ts diff --git a/packages/telemetry/src/helpers.test.ts b/packages/telemetry/src/helpers.test.ts new file mode 100644 index 0000000000..868ffc8b97 --- /dev/null +++ b/packages/telemetry/src/helpers.test.ts @@ -0,0 +1,139 @@ +/** + * @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 { expect } from 'chai'; +import { LoggerProvider } from '@opentelemetry/sdk-logs'; +import { Logger, LogRecord, SeverityNumber } from '@opentelemetry/api-logs'; +import { Telemetry } from './public-types'; +import { startNewSession } from './helpers'; +import { + LOG_ENTRY_ATTRIBUTE_KEYS, + TELEMETRY_SESSION_ID_KEY +} from './constants'; +import { AUTO_CONSTANTS } from './auto-constants'; +import { TelemetryService } from './service'; + +const MOCK_SESSION_ID = '00000000-0000-0000-0000-000000000000'; + +describe('helpers', () => { + let originalSessionStorage: Storage | undefined; + let originalCrypto: Crypto | undefined; + let storage: Record = {}; + let emittedLogs: LogRecord[] = []; + + const fakeLoggerProvider = { + getLogger: (): Logger => { + return { + emit: (logRecord: LogRecord) => { + emittedLogs.push(logRecord); + } + }; + }, + forceFlush: () => Promise.resolve(), + shutdown: () => Promise.resolve() + } as unknown as LoggerProvider; + + const fakeTelemetry: Telemetry = { + app: { + name: 'DEFAULT', + automaticDataCollectionEnabled: true, + options: { + projectId: 'my-project', + appId: 'my-appid' + } + }, + loggerProvider: fakeLoggerProvider + }; + + beforeEach(() => { + emittedLogs = []; + storage = {}; + // @ts-ignore + originalSessionStorage = global.sessionStorage; + // @ts-ignore + originalCrypto = global.crypto; + + const sessionStorageMock: Partial = { + getItem: (key: string) => storage[key] || null, + setItem: (key: string, value: string) => { + storage[key] = value; + } + }; + const cryptoMock: Partial = { + randomUUID: () => MOCK_SESSION_ID + }; + + Object.defineProperty(global, 'sessionStorage', { + value: sessionStorageMock, + writable: true + }); + Object.defineProperty(global, 'crypto', { + value: cryptoMock, + writable: true + }); + }); + + afterEach(() => { + Object.defineProperty(global, 'sessionStorage', { + value: originalSessionStorage, + writable: true + }); + Object.defineProperty(global, 'crypto', { + value: originalCrypto, + writable: true + }); + delete AUTO_CONSTANTS.appVersion; + }); + + describe('startNewSession', () => { + it('should create a new session and log it with app version (unset)', () => { + startNewSession(fakeTelemetry); + + expect(storage[TELEMETRY_SESSION_ID_KEY]).to.equal(MOCK_SESSION_ID); + expect(emittedLogs.length).to.equal(1); + expect(emittedLogs[0].attributes).to.deep.equal({ + [LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]: MOCK_SESSION_ID, + [LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION]: 'unset' + }); + }); + + it('should log app version from AUTO_CONSTANTS', () => { + AUTO_CONSTANTS.appVersion = '1.2.3'; + startNewSession(fakeTelemetry); + + expect(emittedLogs[0].attributes).to.deep.equal({ + [LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]: MOCK_SESSION_ID, + [LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION]: '1.2.3' + }); + }); + + it('should log app version from telemetry options', () => { + const telemetryWithVersion = new TelemetryService( + fakeTelemetry.app, + fakeTelemetry.loggerProvider + ); + telemetryWithVersion.options = { appVersion: '9.9.9' }; + + startNewSession(telemetryWithVersion); + + expect(emittedLogs[0].attributes).to.deep.equal({ + [LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]: MOCK_SESSION_ID, + [LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION]: '9.9.9' + }); + }); + }); +}); diff --git a/packages/telemetry/src/helpers.ts b/packages/telemetry/src/helpers.ts index ce2f58a25b..94c8e77670 100644 --- a/packages/telemetry/src/helpers.ts +++ b/packages/telemetry/src/helpers.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { LoggerProvider, SeverityNumber } from '@opentelemetry/api-logs'; +import { SeverityNumber } from '@opentelemetry/api-logs'; import * as constants from './auto-constants'; import { LOG_ENTRY_ATTRIBUTE_KEYS, @@ -54,7 +54,8 @@ export function getSessionId(): string | undefined { * 1. The client browser's sessionStorage (if available) * 2. In Cloud Logging as its own log entry */ -export function startNewSession(loggerProvider: LoggerProvider): void { +export function startNewSession(telemetry: Telemetry): void { + const { loggerProvider } = telemetry; if ( typeof sessionStorage !== 'undefined' && typeof crypto?.randomUUID === 'function' @@ -69,7 +70,8 @@ export function startNewSession(loggerProvider: LoggerProvider): void { severityNumber: SeverityNumber.DEBUG, body: 'Session created', attributes: { - [LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]: sessionId + [LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]: sessionId, + [LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION]: getAppVersion(telemetry) } }); } catch (e) { diff --git a/packages/telemetry/src/register.ts b/packages/telemetry/src/register.ts index e02760da92..7bfeb05b91 100644 --- a/packages/telemetry/src/register.ts +++ b/packages/telemetry/src/register.ts @@ -58,12 +58,14 @@ export function registerTelemetry(): void { dynamicLogAttributeProviders ); + const telemetryService = new TelemetryService(app, loggerProvider); + // Immediately track this as a new client session (if one doesn't exist yet) if (!getSessionId()) { - startNewSession(loggerProvider); + startNewSession(telemetryService); } - return new TelemetryService(app, loggerProvider); + return telemetryService; }, ComponentType.PUBLIC ).setMultipleInstances(true) From 835fe811bdc16114236f8e42e2230d4bb554ac16 Mon Sep 17 00:00:00 2001 From: Anthony Barone Date: Mon, 8 Dec 2025 17:19:12 +0000 Subject: [PATCH 4/5] Use log entry attribute constant in api --- packages/telemetry/src/api.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/telemetry/src/api.ts b/packages/telemetry/src/api.ts index e65039d5b5..9f2f38fc1c 100644 --- a/packages/telemetry/src/api.ts +++ b/packages/telemetry/src/api.ts @@ -94,7 +94,8 @@ export function captureError( } // Add app version metadata - customAttributes['app.version'] = getAppVersion(telemetry); + customAttributes[LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION] = + getAppVersion(telemetry); // Add session ID metadata const sessionId = getSessionId(); From e0606cc5d62ac48e9a614bf9bcb99e5bda426a2e Mon Sep 17 00:00:00 2001 From: Anthony Barone Date: Mon, 8 Dec 2025 17:30:54 +0000 Subject: [PATCH 5/5] Remove unused var --- packages/telemetry/src/helpers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/telemetry/src/helpers.test.ts b/packages/telemetry/src/helpers.test.ts index 868ffc8b97..0d0a96fdf8 100644 --- a/packages/telemetry/src/helpers.test.ts +++ b/packages/telemetry/src/helpers.test.ts @@ -17,7 +17,7 @@ import { expect } from 'chai'; import { LoggerProvider } from '@opentelemetry/sdk-logs'; -import { Logger, LogRecord, SeverityNumber } from '@opentelemetry/api-logs'; +import { Logger, LogRecord } from '@opentelemetry/api-logs'; import { Telemetry } from './public-types'; import { startNewSession } from './helpers'; import {