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/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(); diff --git a/packages/telemetry/src/helpers.test.ts b/packages/telemetry/src/helpers.test.ts new file mode 100644 index 0000000000..0d0a96fdf8 --- /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 } 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 64593816d1..94c8e77670 100644 --- a/packages/telemetry/src/helpers.ts +++ b/packages/telemetry/src/helpers.ts @@ -15,11 +15,18 @@ * limitations under the License. */ +import { 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,44 @@ 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') { + 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(telemetry: Telemetry): void { + const { loggerProvider } = telemetry; if ( typeof sessionStorage !== 'undefined' && typeof crypto?.randomUUID === 'function' ) { 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, + [LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION]: getAppVersion(telemetry) + } + }); } 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..7bfeb05b91 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,7 +58,14 @@ export function registerTelemetry(): void { dynamicLogAttributeProviders ); - return new TelemetryService(app, loggerProvider); + const telemetryService = new TelemetryService(app, loggerProvider); + + // Immediately track this as a new client session (if one doesn't exist yet) + if (!getSessionId()) { + startNewSession(telemetryService); + } + + return telemetryService; }, ComponentType.PUBLIC ).setMultipleInstances(true)