Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions packages/telemetry/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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';

Expand Down
3 changes: 2 additions & 1 deletion packages/telemetry/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
139 changes: 139 additions & 0 deletions packages/telemetry/src/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
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<Storage> = {
getItem: (key: string) => storage[key] || null,
setItem: (key: string, value: string) => {
storage[key] = value;
}
};
const cryptoMock: Partial<Crypto> = {
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'
});
});
});
});
47 changes: 40 additions & 7 deletions packages/telemetry/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!;
Expand All @@ -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)
}
Expand Down
14 changes: 4 additions & 10 deletions packages/telemetry/src/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 10 additions & 2 deletions packages/telemetry/src/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
Loading