diff --git a/packages/telemetry/src/api.test.ts b/packages/telemetry/src/api.test.ts index 62529a166e2..b954987b696 100644 --- a/packages/telemetry/src/api.test.ts +++ b/packages/telemetry/src/api.test.ts @@ -35,6 +35,10 @@ import { import { Component, ComponentType } from '@firebase/component'; import { FirebaseAppCheckInternal } from '@firebase/app-check-interop-types'; import { captureError, flush, getTelemetry } from './api'; +import { + LOG_ENTRY_ATTRIBUTE_KEYS, + TELEMETRY_SESSION_ID_KEY +} from './constants'; import { TelemetryService } from './service'; import { registerTelemetry } from './register'; import { _FirebaseInstallationsInternal } from '@firebase/installations'; @@ -42,6 +46,7 @@ import { _FirebaseInstallationsInternal } from '@firebase/installations'; const PROJECT_ID = 'my-project'; const APP_ID = 'my-appid'; const API_KEY = 'my-api-key'; +const MOCK_SESSION_ID = '00000000-0000-0000-0000-000000000000'; const emittedLogs: LogRecord[] = []; @@ -74,15 +79,51 @@ const fakeTelemetry: Telemetry = { describe('Top level API', () => { let app: FirebaseApp; + let originalSessionStorage: Storage | undefined; + let originalCrypto: Crypto | undefined; + let storage: Record = {}; beforeEach(() => { // Clear the logs before each test. emittedLogs.length = 0; app = getFakeApp(); + 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(async () => { await deleteApp(app); + Object.defineProperty(global, 'sessionStorage', { + value: originalSessionStorage, + writable: true + }); + Object.defineProperty(global, 'crypto', { + value: originalCrypto, + writable: true + }); }); describe('getTelemetry()', () => { @@ -127,7 +168,8 @@ describe('Top level API', () => { expect(log.attributes).to.deep.equal({ 'error.type': 'TestError', 'error.stack': '...stack trace...', - 'app.version': 'unset' + [LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION]: 'unset', + [LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]: MOCK_SESSION_ID }); }); @@ -144,7 +186,8 @@ describe('Top level API', () => { expect(log.attributes).to.deep.equal({ 'error.type': 'Error', 'error.stack': 'No stack trace available', - 'app.version': 'unset' + [LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION]: 'unset', + [LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]: MOCK_SESSION_ID }); }); @@ -156,7 +199,8 @@ describe('Top level API', () => { expect(log.severityNumber).to.equal(SeverityNumber.ERROR); expect(log.body).to.equal('a string error'); expect(log.attributes).to.deep.equal({ - 'app.version': 'unset' + [LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION]: 'unset', + [LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]: MOCK_SESSION_ID }); }); @@ -168,7 +212,8 @@ describe('Top level API', () => { expect(log.severityNumber).to.equal(SeverityNumber.ERROR); expect(log.body).to.equal('Unknown error type: number'); expect(log.attributes).to.deep.equal({ - 'app.version': 'unset' + [LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION]: 'unset', + [LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]: MOCK_SESSION_ID }); }); @@ -195,9 +240,10 @@ describe('Top level API', () => { expect(emittedLogs[0].attributes).to.deep.equal({ 'error.type': 'TestError', 'error.stack': '...stack trace...', - 'app.version': 'unset', + [LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION]: 'unset', 'logging.googleapis.com/trace': `projects/${PROJECT_ID}/traces/my-trace`, - 'logging.googleapis.com/spanId': `my-span` + 'logging.googleapis.com/spanId': `my-span`, + [LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]: MOCK_SESSION_ID }); }); @@ -220,13 +266,14 @@ describe('Top level API', () => { expect(log.attributes).to.deep.equal({ 'error.type': 'TestError', 'error.stack': '...stack trace...', - 'app.version': 'unset', + [LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION]: 'unset', strAttr: 'string attribute', mapAttr: { boolAttr: true, numAttr: 2 }, - arrAttr: [1, 2, 3] + arrAttr: [1, 2, 3], + [LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]: MOCK_SESSION_ID }); }); @@ -244,7 +291,75 @@ describe('Top level API', () => { expect(emittedLogs.length).to.equal(1); const log = emittedLogs[0]; expect(log.attributes).to.deep.equal({ - 'app.version': '1.0.0' + [LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION]: '1.0.0', + [LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]: MOCK_SESSION_ID + }); + }); + + 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'; + + captureError(fakeTelemetry, 'error'); + + expect(emittedLogs.length).to.equal(1); + const log = emittedLogs[0]; + expect(log.attributes![LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]).to.equal( + 'existing-session-id' + ); + }); + + it('should handle errors when sessionStorage.getItem throws', () => { + const sessionStorageMock: Partial = { + getItem: () => { + throw new Error('SecurityError'); + }, + setItem: () => {} + }; + + Object.defineProperty(global, 'sessionStorage', { + value: sessionStorageMock, + writable: true + }); + + captureError(fakeTelemetry, 'error'); + + expect(emittedLogs.length).to.equal(1); + const log = emittedLogs[0]; + expect(log.attributes![LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]).to.be + .undefined; + }); + + it('should handle errors when sessionStorage.setItem throws', () => { + const sessionStorageMock: Partial = { + getItem: () => null, // Emulate no existing session ID + setItem: () => { + throw new Error('SecurityError'); + } + }; + + Object.defineProperty(global, 'sessionStorage', { + value: sessionStorageMock, + writable: true + }); + + captureError(fakeTelemetry, 'error'); + + expect(emittedLogs.length).to.equal(1); + const log = emittedLogs[0]; + expect(log.attributes![LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID]).to.be + .undefined; }); }); }); diff --git a/packages/telemetry/src/api.ts b/packages/telemetry/src/api.ts index 72b6c123e23..f1f16896d8b 100644 --- a/packages/telemetry/src/api.ts +++ b/packages/telemetry/src/api.ts @@ -16,7 +16,11 @@ */ import { _getProvider, FirebaseApp, getApp } from '@firebase/app'; -import { TELEMETRY_TYPE } from './constants'; +import { + LOG_ENTRY_ATTRIBUTE_KEYS, + TELEMETRY_SESSION_ID_KEY, + TELEMETRY_TYPE +} from './constants'; import { Telemetry, TelemetryOptions } from './public-types'; import { Provider } from '@firebase/component'; import { AnyValueMap, SeverityNumber } from '@opentelemetry/api-logs'; @@ -98,7 +102,24 @@ export function captureError( if ((telemetry as TelemetryService).options?.appVersion) { appVersion = (telemetry as TelemetryService).options!.appVersion!; } - customAttributes['app.version'] = appVersion; + customAttributes[LOG_ENTRY_ATTRIBUTE_KEYS.APP_VERSION] = appVersion; + + // Add session ID metadata + 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); + } + customAttributes[LOG_ENTRY_ATTRIBUTE_KEYS.SESSION_ID] = sessionId; + } catch (e) { + // Ignore errors accessing sessionStorage (e.g. security restrictions) + } + } if (error instanceof Error) { logger.emit({ diff --git a/packages/telemetry/src/constants.ts b/packages/telemetry/src/constants.ts index 5928220b811..52ce36d8db6 100644 --- a/packages/telemetry/src/constants.ts +++ b/packages/telemetry/src/constants.ts @@ -17,3 +17,13 @@ /** Type constant for Firebase Telemetry. */ export const TELEMETRY_TYPE = 'telemetry'; + +/** Key for storing the session ID in sessionStorage. */ +export const TELEMETRY_SESSION_ID_KEY = 'firebasetelemetry.sessionid'; + +/** Keys for attributes in log entries. */ +export const LOG_ENTRY_ATTRIBUTE_KEYS = { + USER_ID: 'user.id', + SESSION_ID: 'session.id', + APP_VERSION: 'app.version' +}; diff --git a/packages/telemetry/src/logging/installation-id-provider.ts b/packages/telemetry/src/logging/installation-id-provider.ts index 3e507a33856..6d68d688482 100644 --- a/packages/telemetry/src/logging/installation-id-provider.ts +++ b/packages/telemetry/src/logging/installation-id-provider.ts @@ -18,6 +18,7 @@ import { Provider } from '@firebase/component'; import { DynamicLogAttributeProvider, LogEntryAttribute } from '../types'; import { _FirebaseInstallationsInternal } from '@firebase/installations'; +import { LOG_ENTRY_ATTRIBUTE_KEYS } from '../constants'; /** * Allows logging to include the client's installation ID. @@ -45,7 +46,7 @@ export class InstallationIdProvider implements DynamicLogAttributeProvider { return null; } if (this._iid) { - return ['user.id', this._iid]; + return [LOG_ENTRY_ATTRIBUTE_KEYS.USER_ID, this._iid]; } const iid = await this.installations.getId(); @@ -54,6 +55,6 @@ export class InstallationIdProvider implements DynamicLogAttributeProvider { } this._iid = iid; - return ['user.id', iid]; + return [LOG_ENTRY_ATTRIBUTE_KEYS.USER_ID, iid]; } }