From 8ecbba6aab62a6ff026b5f21598975fa8cb60738 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 15 Nov 2024 09:51:17 -0800 Subject: [PATCH] feat: Implement browser telemetry client. --- .../__tests__/BrowserTelemetryImpl.test.ts | 210 +++++++++++++++ .../telemetry/browser-telemetry/package.json | 4 +- .../src/BrowserTelemetryImpl.ts | 243 ++++++++++++++++++ .../src/api/BrowserTelemetry.ts | 77 ++++++ .../browser-telemetry/src/api/EventData.ts | 4 + .../src/api/client/LDClientTracking.ts | 7 + .../browser-telemetry/src/api/client/index.ts | 1 + .../browser-telemetry/src/api/index.ts | 1 + .../browser-telemetry/src/inspectors.ts | 32 +++ 9 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts create mode 100644 packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts create mode 100644 packages/telemetry/browser-telemetry/src/api/BrowserTelemetry.ts create mode 100644 packages/telemetry/browser-telemetry/src/api/EventData.ts create mode 100644 packages/telemetry/browser-telemetry/src/api/client/LDClientTracking.ts create mode 100644 packages/telemetry/browser-telemetry/src/api/client/index.ts create mode 100644 packages/telemetry/browser-telemetry/src/inspectors.ts diff --git a/packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts b/packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts new file mode 100644 index 000000000..8233f46fb --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts @@ -0,0 +1,210 @@ +import { LDClientTracking } from '../src/api/client/LDClientTracking'; +import BrowserTelemetryImpl from '../src/BrowserTelemetryImpl'; +import { ParsedOptions } from '../src/options'; + +const mockClient: jest.Mocked = { + track: jest.fn(), +}; + +afterEach(() => { + jest.resetAllMocks(); +}); + +const defaultOptions: ParsedOptions = { + maxPendingEvents: 100, + breadcrumbs: { + maxBreadcrumbs: 50, + click: true, + keyboardInput: true, + http: { + instrumentFetch: true, + instrumentXhr: true, + }, + evaluations: true, + flagChange: true, + }, + stack: { + source: { + beforeLines: 5, + afterLines: 5, + maxLineLength: 120, + }, + }, + collectors: [], +}; + +it('sends buffered events when client is registered', () => { + const telemetry = new BrowserTelemetryImpl(defaultOptions); + const error = new Error('Test error'); + + telemetry.captureError(error); + telemetry.register(mockClient); + + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + type: 'Error', + message: 'Test error', + stack: { frames: expect.any(Array) }, + breadcrumbs: [], + sessionId: expect.any(String), + }), + ); +}); + +it('limits pending events to maxPendingEvents', () => { + const options: ParsedOptions = { + ...defaultOptions, + maxPendingEvents: 2, + }; + const telemetry = new BrowserTelemetryImpl(options); + + telemetry.captureError(new Error('Error 1')); + telemetry.captureError(new Error('Error 2')); + telemetry.captureError(new Error('Error 3')); + + telemetry.register(mockClient); + + // Should only see the last 2 errors tracked + expect(mockClient.track).toHaveBeenCalledTimes(2); + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + message: 'Error 2', + }), + ); + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + message: 'Error 3', + }), + ); +}); + +it('manages breadcrumbs with size limit', () => { + const options: ParsedOptions = { + ...defaultOptions, + breadcrumbs: { ...defaultOptions.breadcrumbs, maxBreadcrumbs: 2 }, + }; + const telemetry = new BrowserTelemetryImpl(options); + + telemetry.addBreadcrumb({ + type: 'custom', + data: { id: 1 }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }); + + telemetry.addBreadcrumb({ + type: 'custom', + data: { id: 2 }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }); + + telemetry.addBreadcrumb({ + type: 'custom', + data: { id: 3 }, + timestamp: Date.now(), + class: 'custom', + level: 'info', + }); + + const error = new Error('Test error'); + telemetry.captureError(error); + telemetry.register(mockClient); + + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + breadcrumbs: expect.arrayContaining([ + expect.objectContaining({ data: { id: 2 } }), + expect.objectContaining({ data: { id: 3 } }), + ]), + }), + ); +}); + +it('handles null/undefined errors gracefully', () => { + const telemetry = new BrowserTelemetryImpl(defaultOptions); + + // @ts-ignore - Testing runtime behavior with invalid input + telemetry.captureError(undefined); + telemetry.register(mockClient); + + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + type: 'generic', + message: 'exception was null or undefined', + breadcrumbs: [], + }), + ); +}); + +it('captures error events', () => { + const telemetry = new BrowserTelemetryImpl(defaultOptions); + const error = new Error('Test error'); + const errorEvent = new ErrorEvent('error', { error }); + + telemetry.captureErrorEvent(errorEvent); + telemetry.register(mockClient); + + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + type: 'Error', + message: 'Test error', + breadcrumbs: [], + }), + ); +}); + +it('handles flag evaluation breadcrumbs', () => { + const telemetry = new BrowserTelemetryImpl(defaultOptions); + + telemetry.handleFlagUsed('test-flag', { + value: true, + variationIndex: 1, + reason: { kind: 'OFF' }, + }); + + const error = new Error('Test error'); + telemetry.captureError(error); + telemetry.register(mockClient); + + expect(mockClient.track).toHaveBeenCalledWith( + '$ld:telemetry:error', + expect.objectContaining({ + breadcrumbs: expect.arrayContaining([ + expect.objectContaining({ + type: 'flag-evaluated', + data: { + key: 'test-flag', + value: true, + }, + class: 'feature-management', + }), + ]), + }), + ); +}); + +it('unregisters collectors on close', () => { + const mockCollector = { + register: jest.fn(), + unregister: jest.fn(), + }; + + const options: ParsedOptions = { + ...defaultOptions, + collectors: [mockCollector], + }; + + const telemetry = new BrowserTelemetryImpl(options); + telemetry.close(); + + expect(mockCollector.unregister).toHaveBeenCalled(); +}); diff --git a/packages/telemetry/browser-telemetry/package.json b/packages/telemetry/browser-telemetry/package.json index cb5963475..49c2a1902 100644 --- a/packages/telemetry/browser-telemetry/package.json +++ b/packages/telemetry/browser-telemetry/package.json @@ -47,10 +47,8 @@ "rrweb": "2.0.0-alpha.4", "tracekit": "^0.4.6" }, - "peerDependencies": { - "launchdarkly-js-client-sdk": "^3.4.0" - }, "devDependencies": { + "@launchdarkly/js-client-sdk": "0.3.2", "@jest/globals": "^29.7.0", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/css-font-loading-module": "^0.0.13", diff --git a/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts b/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts new file mode 100644 index 000000000..41ce9a351 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts @@ -0,0 +1,243 @@ +import * as TraceKit from 'tracekit'; + +/** + * A limited selection of type information is provided by the browser client SDK. + * This is only a type dependency and these types should be compatible between + * SDKs. + */ +import type { LDContext, LDEvaluationDetail, LDInspection } from '@launchdarkly/js-client-sdk'; + +import { LDClientTracking } from './api'; +import { Breadcrumb, FeatureManagementBreadcrumb } from './api/Breadcrumb'; +import { BrowserTelemetry } from './api/BrowserTelemetry'; +import { Collector } from './api/Collector'; +import { ErrorData } from './api/ErrorData'; +import { EventData } from './api/EventData'; +import ClickCollector from './collectors/dom/ClickCollector'; +import KeypressCollector from './collectors/dom/KeypressCollector'; +import ErrorCollector from './collectors/error'; +import FetchCollector from './collectors/http/fetch'; +import XhrCollector from './collectors/http/xhr'; +import defaultUrlFilter from './filters/defaultUrlFilter'; +import makeInspectors from './inspectors'; +import { ParsedOptions, ParsedStackOptions } from './options'; +import randomUuidV4 from './randomUuidV4'; +import parse from './stack/StackParser'; + +// TODO: Use a ring buffer for the breadcrumbs/pending events instead of shifting. (SDK-914) + +const CUSTOM_KEY_PREFIX = '$ld:telemetry'; +const ERROR_KEY = `${CUSTOM_KEY_PREFIX}:error`; +const SESSION_CAPTURE_KEY = `${CUSTOM_KEY_PREFIX}:sessionCapture`; +const GENERIC_EXCEPTION = 'generic'; +const NULL_EXCEPTION_MESSAGE = 'exception was null or undefined'; +const MISSING_MESSAGE = 'exception had no message'; + +/** + * Given a flag value ensure it is safe for analytics. + * + * If the parameter is not safe, then return undefined. + * + * TODO: Add limited JSON support. (SDK-916) + * @param u The value to check. + * @returns Either the value or undefined. + */ +function safeValue(u: unknown): string | boolean | number | undefined { + switch (typeof u) { + case 'string': + case 'boolean': + case 'number': + return u; + default: + return undefined; + } +} + +function configureTraceKit(options: ParsedStackOptions) { + // Include before + after + source line. + // TraceKit only takes a total context size, so we have to over capture and then reduce the lines. + // So, for instance if before is 3 and after is 4 we need to capture 4 and 4 and then drop a line + // from the before context. + // The typing for this is a bool, but it accepts a number. + const beforeAfterMax = Math.max(options.source.afterLines, options.source.beforeLines); + (TraceKit as any).linesOfContext = beforeAfterMax * 2 + 1; +} + +export default class BrowserTelemetryImpl implements BrowserTelemetry { + private _maxPendingEvents: number; + private _maxBreadcrumbs: number; + + private _pendingEvents: { type: string; data: EventData }[] = []; + private _client?: LDClientTracking; + + private _breadcrumbs: Breadcrumb[] = []; + + private _inspectorInstances: LDInspection[] = []; + private _collectors: Collector[] = []; + private _sessionId: string = randomUuidV4(); + + constructor(private _options: ParsedOptions) { + configureTraceKit(_options.stack); + + // Error collector is always required. + this._collectors.push(new ErrorCollector()); + this._collectors.push(..._options.collectors); + + this._maxPendingEvents = _options.maxPendingEvents; + this._maxBreadcrumbs = _options.breadcrumbs.maxBreadcrumbs; + + const urlFilters = [defaultUrlFilter]; + if (_options.breadcrumbs.http.customUrlFilter) { + urlFilters.push(_options.breadcrumbs.http.customUrlFilter); + } + + if (_options.breadcrumbs.http.instrumentFetch) { + this._collectors.push( + new FetchCollector({ + urlFilters, + }), + ); + } + + if (_options.breadcrumbs.http.instrumentXhr) { + this._collectors.push( + new XhrCollector({ + urlFilters, + }), + ); + } + + if (_options.breadcrumbs.click) { + this._collectors.push(new ClickCollector()); + } + + if (_options.breadcrumbs.keyboardInput) { + this._collectors.push(new KeypressCollector()); + } + + this._collectors.forEach((collector) => + collector.register(this as BrowserTelemetry, this._sessionId), + ); + + const impl = this; + const inspectors: LDInspection[] = []; + makeInspectors(_options, inspectors, impl); + this._inspectorInstances.push(...inspectors); + } + + register(client: LDClientTracking): void { + this._client = client; + this._pendingEvents.forEach((event) => { + this._client?.track(event.type, event.data); + }); + } + + inspectors(): LDInspection[] { + return this._inspectorInstances; + } + + /** + * Capture an event. + * + * If the LaunchDarkly client SDK is not yet registered, then the event + * will be buffered until the client is registered. + * @param type The type of event to capture. + * @param event The event data. + */ + private _capture(type: string, event: EventData) { + if (this._client === undefined) { + this._pendingEvents.push({ type, data: event }); + if (this._pendingEvents.length > this._maxPendingEvents) { + // TODO: Log when pending events must be dropped. (SDK-915) + this._pendingEvents.shift(); + } + } + this._client?.track(type, event); + } + + captureError(exception: Error): void { + const validException = exception !== undefined && exception !== null; + + const data: ErrorData = validException + ? { + type: exception.name || exception.constructor?.name || GENERIC_EXCEPTION, + // Only coalesce null/undefined, not empty. + message: exception.message ?? MISSING_MESSAGE, + stack: parse(exception, this._options.stack), + breadcrumbs: [...this._breadcrumbs], + sessionId: this._sessionId, + } + : { + type: GENERIC_EXCEPTION, + message: NULL_EXCEPTION_MESSAGE, + stack: { frames: [] }, + breadcrumbs: [...this._breadcrumbs], + sessionId: this._sessionId, + }; + this._capture(ERROR_KEY, data); + } + + captureErrorEvent(errorEvent: ErrorEvent): void { + this.captureError(errorEvent.error); + } + + captureSession(sessionEvent: EventData): void { + this._capture(SESSION_CAPTURE_KEY, { ...sessionEvent, breadcrumbs: [...this._breadcrumbs] }); + } + + addBreadcrumb(breadcrumb: Breadcrumb): void { + this._breadcrumbs.push(breadcrumb); + if (this._breadcrumbs.length > this._maxBreadcrumbs) { + this._breadcrumbs.shift(); + } + } + + close(): void { + this._collectors.forEach((collector) => collector.unregister()); + } + + /** + * Used to automatically collect flag usage for breacrumbs. + * + * When session replay is in use the data is also forwarded to the session + * replay collector. + * + * @internal + */ + handleFlagUsed(flagKey: string, detail: LDEvaluationDetail, _context?: LDContext): void { + const breadcrumb: FeatureManagementBreadcrumb = { + type: 'flag-evaluated', + data: { + key: flagKey, + value: safeValue(detail.value), + }, + timestamp: new Date().getTime(), + class: 'feature-management', + level: 'info', + }; + this.addBreadcrumb(breadcrumb); + } + + /** + * Used to automatically collect flag detail changes. + * + * When session replay is in use the data is also forwarded to the session + * replay collector. + * + * @internal + */ + handleFlagDetailChanged(flagKey: string, detail: LDEvaluationDetail): void { + const breadcrumb: FeatureManagementBreadcrumb = { + type: 'flag-detail-changed', + data: { + key: flagKey, + value: safeValue(detail.value), + }, + timestamp: new Date().getTime(), + class: 'feature-management', + level: 'info', + }; + + this.addBreadcrumb(breadcrumb); + } +} diff --git a/packages/telemetry/browser-telemetry/src/api/BrowserTelemetry.ts b/packages/telemetry/browser-telemetry/src/api/BrowserTelemetry.ts new file mode 100644 index 000000000..79a06afea --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/api/BrowserTelemetry.ts @@ -0,0 +1,77 @@ +import type { LDInspection } from '@launchdarkly/js-client-sdk'; + +import { Breadcrumb } from './Breadcrumb'; +import { LDClientTracking } from './client/LDClientTracking'; + +/** + * Interface for browser-based telemetry collection in LaunchDarkly SDKs. + * + * This interface provides methods for collecting diagnostic information, error + * tracking, and SDK usage data in browser environments. It is designed to work + * with LaunchDarkly's JavaScript client-side SDKs for browser environments. + */ +export interface BrowserTelemetry { + /** + * Returns an array of active SDK inspectors to use with SDK versions that do + * not support hooks. + * + * @returns An array of {@link LDInspection} objects. + */ + inspectors(): LDInspection[]; + + /** + * Captures an Error object for telemetry purposes. + * + * Use this method to manually capture errors during application operation. + * Unhandled errors are automatically captures, but this method can be used + * to capture errors which were hanled, but are still useful for telemetry. + * + * @param exception The Error object to capture + */ + captureError(exception: Error): void; + + /** + * Captures a browser ErrorEvent for telemetry purposes. + * + * This method can be used to capture a manually created error event. Use this + * function to represent application specific errors which cannot be captured + * automatically or are not `Error` types. + * + * For most errors {@link captureError} should be used. + * + * @param errorEvent The ErrorEvent to capture + */ + captureErrorEvent(errorEvent: ErrorEvent): void; + + /** + * Add a breadcrumb which will be included with telemetry events. + * + * Many breadcrumbs can be automatically captured, but this method can be + * used for capturing manual breadcrumbs. For application specific breadcrumbs + * the {@link CustomBreadcrumb} type can be used. + * + * @param breadcrumb The breadcrumb to add. + */ + addBreadcrumb(breadcrumb: Breadcrumb): void; + + /** + * Registers a LaunchDarkly client instance for telemetry tracking. + * + * This method connects the telemetry system to the specific LaunchDarkly + * client instance. The client instance will be used to report telemetry + * to LaunchDarkly and also for collecting flag and context data. + * + * @param client The {@link LDClientTracking} instance to register for + * telemetry + */ + register(client: LDClientTracking): void; + + /** + * Closes the telemetry system and stops data collection. + * + * In general usage this method is not required, but it can be used in cases + * where collection needs to be stopped independent of application + * lifecycle. + */ + close(): void; +} diff --git a/packages/telemetry/browser-telemetry/src/api/EventData.ts b/packages/telemetry/browser-telemetry/src/api/EventData.ts new file mode 100644 index 000000000..8e490e17a --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/api/EventData.ts @@ -0,0 +1,4 @@ +import { ErrorData } from './ErrorData'; + +// Each type of event should be added to this union. +export type EventData = ErrorData; diff --git a/packages/telemetry/browser-telemetry/src/api/client/LDClientTracking.ts b/packages/telemetry/browser-telemetry/src/api/client/LDClientTracking.ts new file mode 100644 index 000000000..6110f4583 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/api/client/LDClientTracking.ts @@ -0,0 +1,7 @@ +/** + * Minimal client interface which allows for tracking. Should work with all client-side + * JavaScript packages. + */ +export interface LDClientTracking { + track(key: string, data?: any, metricValue?: number): void; +} diff --git a/packages/telemetry/browser-telemetry/src/api/client/index.ts b/packages/telemetry/browser-telemetry/src/api/client/index.ts new file mode 100644 index 000000000..d363ce8c7 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/api/client/index.ts @@ -0,0 +1 @@ +export * from './LDClientTracking'; diff --git a/packages/telemetry/browser-telemetry/src/api/index.ts b/packages/telemetry/browser-telemetry/src/api/index.ts index 5e9233fae..b71214eb4 100644 --- a/packages/telemetry/browser-telemetry/src/api/index.ts +++ b/packages/telemetry/browser-telemetry/src/api/index.ts @@ -4,3 +4,4 @@ export * from './ErrorData'; export * from './Options'; export * from './Recorder'; export * from './stack'; +export * from './client'; diff --git a/packages/telemetry/browser-telemetry/src/inspectors.ts b/packages/telemetry/browser-telemetry/src/inspectors.ts new file mode 100644 index 000000000..9e5415cac --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/inspectors.ts @@ -0,0 +1,32 @@ +import type { LDContext, LDEvaluationDetail, LDInspection } from '@launchdarkly/js-client-sdk'; + +import BrowserTelemetryImpl from './BrowserTelemetryImpl.js'; +import { ParsedOptions } from './options.js'; + +export default function makeInspectors( + options: ParsedOptions, + inspectors: LDInspection[], + telemetry: BrowserTelemetryImpl, +) { + if (options.breadcrumbs.evaluations) { + inspectors.push({ + type: 'flag-used', + name: 'launchdarkly-browser-telemetry-flag-used', + synchronous: true, + method(flagKey: string, flagDetail: LDEvaluationDetail, context?: LDContext): void { + telemetry.handleFlagUsed(flagKey, flagDetail, context); + }, + }); + } + + if (options.breadcrumbs.flagChange) { + inspectors.push({ + type: 'flag-detail-changed', + name: 'launchdarkly-browser-telemetry-flag-used', + synchronous: true, + method(flagKey: string, detail: LDEvaluationDetail): void { + telemetry.handleFlagDetailChanged(flagKey, detail); + }, + }); + } +}