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
133 changes: 124 additions & 9 deletions packages/telemetry/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,18 @@ 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';

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[] = [];

Expand Down Expand Up @@ -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<string, string> = {};

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<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(async () => {
await deleteApp(app);
Object.defineProperty(global, 'sessionStorage', {
value: originalSessionStorage,
writable: true
});
Object.defineProperty(global, 'crypto', {
value: originalCrypto,
writable: true
});
});

describe('getTelemetry()', () => {
Expand Down Expand Up @@ -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
});
});

Expand All @@ -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
});
});

Expand All @@ -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
});
});

Expand All @@ -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
});
});

Expand All @@ -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
});
});

Expand All @@ -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
});
});

Expand All @@ -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<Storage> = {
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<Storage> = {
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;
});
});
});
Expand Down
25 changes: 23 additions & 2 deletions packages/telemetry/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down
10 changes: 10 additions & 0 deletions packages/telemetry/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
};
5 changes: 3 additions & 2 deletions packages/telemetry/src/logging/installation-id-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand All @@ -54,6 +55,6 @@ export class InstallationIdProvider implements DynamicLogAttributeProvider {
}

this._iid = iid;
return ['user.id', iid];
return [LOG_ENTRY_ATTRIBUTE_KEYS.USER_ID, iid];
}
}
Loading