From 7485675e00f9f26fcd48f83be93e69a1f8e563bd Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 20 Nov 2024 13:10:32 -0800 Subject: [PATCH 1/5] WIP --- .../collectors/dom/ClickCollector.test.ts | 1 + .../collectors/dom/KeyPressCollector.test.ts | 1 + .../__tests__/collectors/http/fetch.test.ts | 1 + .../__tests__/collectors/http/xhr.test.ts | 4 + .../collectors/rrweb/SessionBuffer.test.ts | 29 ++++++ .../telemetry/browser-telemetry/package.json | 3 +- .../src/BrowserTelemetryImpl.ts | 15 ++-- .../src/api/BrowserTelemetry.ts | 9 +- .../browser-telemetry/src/api/EventData.ts | 3 +- .../browser-telemetry/src/api/Recorder.ts | 11 +++ .../browser-telemetry/src/api/SessionData.ts | 6 ++ .../browser-telemetry/src/api/index.ts | 2 + .../src/collectors/rrweb/ContinuousReplay.ts | 89 +++++++++++++++++++ .../src/collectors/rrweb/EventBuffer.ts | 28 ++++++ .../src/collectors/rrweb/ReplayCollector.ts | 10 +++ .../src/collectors/rrweb/RollingBuffer.ts | 66 ++++++++++++++ .../src/collectors/rrweb/RollingReplay.ts | 58 ++++++++++++ .../src/collectors/rrweb/SessionReplay.ts | 57 ++++++++++++ .../collectors/rrweb/SessionReplayOptions.ts | 14 +++ .../browser-telemetry/src/defaultRecorder.ts | 42 +++++++++ .../browser-telemetry/src/inspectors.ts | 12 ++- 21 files changed, 448 insertions(+), 13 deletions(-) create mode 100644 packages/telemetry/browser-telemetry/__tests__/collectors/rrweb/SessionBuffer.test.ts create mode 100644 packages/telemetry/browser-telemetry/src/api/SessionData.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/rrweb/ContinuousReplay.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/rrweb/EventBuffer.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/rrweb/ReplayCollector.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/rrweb/RollingBuffer.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/rrweb/RollingReplay.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/rrweb/SessionReplay.ts create mode 100644 packages/telemetry/browser-telemetry/src/collectors/rrweb/SessionReplayOptions.ts create mode 100644 packages/telemetry/browser-telemetry/src/defaultRecorder.ts diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/dom/ClickCollector.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/dom/ClickCollector.test.ts index e386507a5d..e4e1b9a5a5 100644 --- a/packages/telemetry/browser-telemetry/__tests__/collectors/dom/ClickCollector.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/dom/ClickCollector.test.ts @@ -40,6 +40,7 @@ describe('given a ClickCollector with a mock recorder', () => { addBreadcrumb: jest.fn(), captureError: jest.fn(), captureErrorEvent: jest.fn(), + captureSession: jest.fn(), }; // Create collector diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/dom/KeyPressCollector.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/dom/KeyPressCollector.test.ts index 63b0ca1456..66c4144689 100644 --- a/packages/telemetry/browser-telemetry/__tests__/collectors/dom/KeyPressCollector.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/dom/KeyPressCollector.test.ts @@ -41,6 +41,7 @@ describe('given a KeypressCollector with a mock recorder', () => { addBreadcrumb: jest.fn(), captureError: jest.fn(), captureErrorEvent: jest.fn(), + captureSession: jest.fn(), }; // Create collector diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts index b11c9a506c..56e570945a 100644 --- a/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/http/fetch.test.ts @@ -14,6 +14,7 @@ describe('given a FetchCollector with a mock recorder', () => { addBreadcrumb: jest.fn(), captureError: jest.fn(), captureErrorEvent: jest.fn(), + captureSession: jest.fn(), }; // Create collector with default options collector = new FetchCollector({ diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts index 125b639ab3..9a8df01683 100644 --- a/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/http/xhr.test.ts @@ -9,6 +9,7 @@ it('registers recorder and uses it for xhr calls', () => { addBreadcrumb: jest.fn(), captureError: jest.fn(), captureErrorEvent: jest.fn(), + captureSession: jest.fn(), }; const collector = new XhrCollector({ @@ -47,6 +48,7 @@ it('stops adding breadcrumbs after unregistering', () => { addBreadcrumb: jest.fn(), captureError: jest.fn(), captureErrorEvent: jest.fn(), + captureSession: jest.fn(), }; const collector = new XhrCollector({ @@ -70,6 +72,7 @@ it('marks requests with error events as errors', () => { addBreadcrumb: jest.fn(), captureError: jest.fn(), captureErrorEvent: jest.fn(), + captureSession: jest.fn(), }; const collector = new XhrCollector({ @@ -106,6 +109,7 @@ it('applies URL filters to requests', () => { addBreadcrumb: jest.fn(), captureError: jest.fn(), captureErrorEvent: jest.fn(), + captureSession: jest.fn(), }; const collector = new XhrCollector({ diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/rrweb/SessionBuffer.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/rrweb/SessionBuffer.test.ts new file mode 100644 index 0000000000..2bdf2cf069 --- /dev/null +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/rrweb/SessionBuffer.test.ts @@ -0,0 +1,29 @@ +import RollingBuffer from '../../../src/collectors/rrweb/RollingBuffer'; + +it('can fill the entire expected buffer size', () => { + const bufferSize = 5; + const numberBuffers = 4; + const buffer = new RollingBuffer(bufferSize, numberBuffers); + const demoItems = Array.from(new Array(bufferSize * numberBuffers), (_, i) => i); + + demoItems.forEach(buffer.push.bind(buffer)); + + expect(buffer.toArray()).toEqual(demoItems); +}); + +it('when the buffer is exceeded it will wrap around', () => { + const bufferSize = 5; + const numberBuffers = 4; + const buffer = new RollingBuffer(bufferSize, numberBuffers); + const dropRatio = 1.5; + const extraItems = Math.trunc(bufferSize * dropRatio); + const itemsToMake = bufferSize * numberBuffers + extraItems; + const demoItems = Array.from(new Array(itemsToMake), (_, i) => i); + + demoItems.forEach(buffer.push.bind(buffer)); + + // We need to remove the number of chunks, not the specific number of items. + const expectedItems = demoItems.slice(Math.ceil(dropRatio) * bufferSize); + + expect(buffer.toArray()).toEqual(expectedItems); +}); diff --git a/packages/telemetry/browser-telemetry/package.json b/packages/telemetry/browser-telemetry/package.json index 49c2a1902b..b1c4927e00 100644 --- a/packages/telemetry/browser-telemetry/package.json +++ b/packages/telemetry/browser-telemetry/package.json @@ -48,8 +48,9 @@ "tracekit": "^0.4.6" }, "devDependencies": { - "@launchdarkly/js-client-sdk": "0.3.2", "@jest/globals": "^29.7.0", + "@launchdarkly/js-client-sdk": "0.3.2", + "@rrweb/types": "^2.0.0-alpha.17", "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/css-font-loading-module": "^0.0.13", "@types/jest": "^29.5.11", diff --git a/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts b/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts index 41ce9a3510..bbbb0fad93 100644 --- a/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts +++ b/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts @@ -7,8 +7,13 @@ import * as TraceKit from 'tracekit'; */ import type { LDContext, LDEvaluationDetail, LDInspection } from '@launchdarkly/js-client-sdk'; -import { LDClientTracking } from './api'; -import { Breadcrumb, FeatureManagementBreadcrumb } from './api/Breadcrumb'; +import { + Breadcrumb, + FeatureManagementBreadcrumb, + LDClientTracking, + Recorder, + SessionData, +} from './api'; import { BrowserTelemetry } from './api/BrowserTelemetry'; import { Collector } from './api/Collector'; import { ErrorData } from './api/ErrorData'; @@ -115,9 +120,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { this._collectors.push(new KeypressCollector()); } - this._collectors.forEach((collector) => - collector.register(this as BrowserTelemetry, this._sessionId), - ); + this._collectors.forEach((collector) => collector.register(this as Recorder, this._sessionId)); const impl = this; const inspectors: LDInspection[] = []; @@ -181,7 +184,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { this.captureError(errorEvent.error); } - captureSession(sessionEvent: EventData): void { + captureSession(sessionEvent: SessionData): void { this._capture(SESSION_CAPTURE_KEY, { ...sessionEvent, breadcrumbs: [...this._breadcrumbs] }); } diff --git a/packages/telemetry/browser-telemetry/src/api/BrowserTelemetry.ts b/packages/telemetry/browser-telemetry/src/api/BrowserTelemetry.ts index 74ec31f575..28e2eaefc7 100644 --- a/packages/telemetry/browser-telemetry/src/api/BrowserTelemetry.ts +++ b/packages/telemetry/browser-telemetry/src/api/BrowserTelemetry.ts @@ -33,11 +33,12 @@ export interface BrowserTelemetry { /** * 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. + * This can be used to capture error events, such as those emitted by 'error' + * or 'unhandledrejection' events. Error events are automatically collected + * so this method is only generally required if synthesizing error events + * manually. * - * For most errors {@link captureError} should be used. + * For most manual error reporting {@link captureError} should be used. * * @param errorEvent The ErrorEvent to capture */ diff --git a/packages/telemetry/browser-telemetry/src/api/EventData.ts b/packages/telemetry/browser-telemetry/src/api/EventData.ts index 8e490e17a0..c94c804ae2 100644 --- a/packages/telemetry/browser-telemetry/src/api/EventData.ts +++ b/packages/telemetry/browser-telemetry/src/api/EventData.ts @@ -1,4 +1,5 @@ import { ErrorData } from './ErrorData'; +import { SessionData } from './SessionData'; // Each type of event should be added to this union. -export type EventData = ErrorData; +export type EventData = ErrorData | SessionData; diff --git a/packages/telemetry/browser-telemetry/src/api/Recorder.ts b/packages/telemetry/browser-telemetry/src/api/Recorder.ts index dbbf21012c..a8dfb38713 100644 --- a/packages/telemetry/browser-telemetry/src/api/Recorder.ts +++ b/packages/telemetry/browser-telemetry/src/api/Recorder.ts @@ -1,4 +1,5 @@ import { Breadcrumb } from './Breadcrumb'; +import { SessionData } from './SessionData'; /** * Interface for capturing telemetry data. @@ -25,4 +26,14 @@ export interface Recorder { * @param breadcrumb The breadcrumb to add. */ addBreadcrumb(breadcrumb: Breadcrumb): void; + + /** + * Capture rrweb session data. + * + * Currently capturing session replay data is only possible via a collector. It cannot be manually + * captured using the browser telemetry instance. + * + * @param sessionEvent Event containing rrweb session data. + */ + captureSession(sessionEvent: SessionData): void; } diff --git a/packages/telemetry/browser-telemetry/src/api/SessionData.ts b/packages/telemetry/browser-telemetry/src/api/SessionData.ts new file mode 100644 index 0000000000..5280464d16 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/api/SessionData.ts @@ -0,0 +1,6 @@ +import type { eventWithTime } from '@rrweb/types'; + +export interface SessionData { + events: eventWithTime[]; + index: number; +} diff --git a/packages/telemetry/browser-telemetry/src/api/index.ts b/packages/telemetry/browser-telemetry/src/api/index.ts index b71214eb41..83aa3687d2 100644 --- a/packages/telemetry/browser-telemetry/src/api/index.ts +++ b/packages/telemetry/browser-telemetry/src/api/index.ts @@ -5,3 +5,5 @@ export * from './Options'; export * from './Recorder'; export * from './stack'; export * from './client'; +export * from './EventData'; +export * from './SessionData'; diff --git a/packages/telemetry/browser-telemetry/src/collectors/rrweb/ContinuousReplay.ts b/packages/telemetry/browser-telemetry/src/collectors/rrweb/ContinuousReplay.ts new file mode 100644 index 0000000000..9dae2b25b3 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/rrweb/ContinuousReplay.ts @@ -0,0 +1,89 @@ +import type { eventWithTime } from '@rrweb/types'; +import * as rrweb from 'rrweb'; + +import { Recorder } from '../../api'; +import { Collector } from '../../api/Collector'; +import { ContinuousCapture } from './SessionReplayOptions'; + +export default class ContinuousReplay implements Collector { + private _telemetry?: Recorder; + // TODO: Use a better buffer. + private _buffer: eventWithTime[] = []; + private _stopper?: () => void; + private _visibilityHandler: any; + private _timerHandle: any; + private _index: number = 0; + private _sessionId?: string; + + constructor(options: ContinuousCapture) { + this._visibilityHandler = () => { + this._handleVisibilityChange(); + }; + + document.addEventListener('visibilitychange', this._visibilityHandler, true); + + this._timerHandle = setInterval(() => { + // If there are only 2 events, then we just have a snapshot. + // We can wait to capture until there is some activity. + + // No activity has ocurred, so we don't need to send continuous messages + // to indicate no state change. + if (this._buffer.length === 2) { + return; + } + this._recordCapture(); + this._restartCapture(); + }, options.intervalMs); + + this._startCapture(); + } + + register(recorder: Recorder, sessionId: string): void { + this._telemetry = recorder; + this._sessionId = sessionId; + } + + unregister(): void { + this._stopper?.(); + this._telemetry = undefined; + document.removeEventListener('visibilitychange', this._visibilityHandler); + clearInterval(this._timerHandle); + } + + private _handleVisibilityChange() { + if (document.visibilityState === 'hidden') { + // When the visibility changes we want to be sure we record what we have, and then we + // restart recording in case this visibility change was not for the end of the session. + this._recordCapture(); + this._restartCapture(); + } + } + + private _recordCapture(): void { + // Telemetry and sessionId should always be set at the same time, but check both + // for correctness. + if (this._telemetry && this._sessionId) { + this._telemetry.captureSession({ + events: [...this._buffer], + index: this._index, + }); + } + + this._index += 1; + } + + private _startCapture() { + const { _buffer: buffer } = this; + this._stopper = rrweb.record({ + emit: (event) => { + buffer.push(event); + }, + }); + } + + private _restartCapture() { + this._stopper?.(); + this._buffer.length = 0; + this._startCapture(); + } +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/rrweb/EventBuffer.ts b/packages/telemetry/browser-telemetry/src/collectors/rrweb/EventBuffer.ts new file mode 100644 index 0000000000..a834522e49 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/rrweb/EventBuffer.ts @@ -0,0 +1,28 @@ +export default class EventBuffer { + public content: any[] = []; + private _size: number; + + constructor(size: number) { + this._size = size; + } + + push(item: any): void { + if (this.content.length < this._size) { + this.content.push(item); + } + // TODO: Something? + } + + hasSpace(): boolean { + return this.content.length < this._size; + } + + isPopulated(): boolean { + return this.content.length !== 0; + } + + clear(): void { + // TODO: Re-use the buffer. Keep write index instead of pushing. + this.content = []; + } +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/rrweb/ReplayCollector.ts b/packages/telemetry/browser-telemetry/src/collectors/rrweb/ReplayCollector.ts new file mode 100644 index 0000000000..9d1cc050b7 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/rrweb/ReplayCollector.ts @@ -0,0 +1,10 @@ +import type { LDContext, LDEvaluationDetail } from '@launchdarkly/js-client-sdk'; + +/** + * For session replay there is a need to annotate the session data with + */ +export interface SessionMetadata { + handleFlagUsed?(flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext): void; + handleFlagDetailChanged?(flagKey: string, detail: LDEvaluationDetail): void; + handleErrorEvent(name: string, message: string): void; +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/rrweb/RollingBuffer.ts b/packages/telemetry/browser-telemetry/src/collectors/rrweb/RollingBuffer.ts new file mode 100644 index 0000000000..df2ecd2ddb --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/rrweb/RollingBuffer.ts @@ -0,0 +1,66 @@ +import EventBuffer from './EventBuffer'; + +/** + * A session buffer is a circular buffer of buffers. Each buffer has a fixed size and is intended + * to match the "checkout" size of rrweb. Each individual buffer should contain a valid replay + * and then the sum of all the buffers should also be a valid replay containing each of the other + * session chunks. + * + * The buffer can continuously capture events while always remaining in a playable state + * and dropping old events. + */ +export default class RollingBuffer { + private _buffers: EventBuffer[] = []; + private _writePointer: number = 0; + private _headPointer: number = 0; + + constructor(bufferSize: number, numBuffers: number) { + for (let index = 0; index < numBuffers; index += 1) { + this._buffers.push(new EventBuffer(bufferSize)); + } + } + + push(item: any): void { + const buffer = this._buffers[this._writePointer]; + if (!buffer.hasSpace()) { + if (this._writePointer < this._buffers.length - 1) { + this._writePointer += 1; + } else { + this._writePointer = 0; + } + this._buffers[this._writePointer].clear(); + if (this._writePointer === this._headPointer) { + this._headPointer += 1; + if (this._headPointer >= this._buffers.length - 1) { + this._headPointer = 0; + } + } + this.push(item); + return; + } + buffer.push(item); + } + + toArray(): any[] { + const asArray: any[] = []; + const size = this._buffers.reduce((acc: number, item: EventBuffer) => { + if (item.isPopulated()) { + return acc + 1; + } + return acc; + }, 0); + + for (let index = this._headPointer; index < this._headPointer + size; index += 1) { + const realIndex = index % this._buffers.length; + asArray.push(...this._buffers[realIndex].content); + } + + return asArray; + } + + reset(): void { + this._writePointer = 0; + this._headPointer = 0; + this._buffers.forEach((buffer) => buffer.clear()); + } +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/rrweb/RollingReplay.ts b/packages/telemetry/browser-telemetry/src/collectors/rrweb/RollingReplay.ts new file mode 100644 index 0000000000..ed51e3861a --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/rrweb/RollingReplay.ts @@ -0,0 +1,58 @@ +import * as rrweb from 'rrweb'; + +import { Recorder } from '../../api'; +import { Collector } from '../../api/Collector'; +import RollingBuffer from './RollingBuffer'; +import { RollingCapture } from './SessionReplayOptions'; + +export default class RollingReplay implements Collector { + private _recorder?: Recorder; + private _buffer: RollingBuffer; + private _stopper?: () => void; + private _index: number = 0; + private _sessionId?: string; + + constructor(private _options: RollingCapture) { + this._buffer = new RollingBuffer(_options.eventSegmentLength, _options.segmentBufferLength); + + this._startCapture(); + } + + private _startCapture() { + this._stopper = rrweb.record({ + checkoutEveryNth: this._options.eventSegmentLength, + emit: (event) => { + this._buffer.push(event); + }, + }); + } + + register(recorder: Recorder, sessionId: string): void { + this._recorder = recorder; + this._sessionId = sessionId; + } + + unregister(): void { + this._stopper?.(); + this._recorder = undefined; + } + + capture(): void { + // Telemetry and sessionId should always be set at the same time, but check both + // for correctness. + if (this._recorder && this._sessionId) { + this._recorder?.captureSession({ + events: this._buffer.toArray(), + index: this._index, + }); + } + this._index += 1; + this._restartCapture(); + } + + private _restartCapture() { + this._buffer.reset(); + this._stopper?.(); + this._startCapture(); + } +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/rrweb/SessionReplay.ts b/packages/telemetry/browser-telemetry/src/collectors/rrweb/SessionReplay.ts new file mode 100644 index 0000000000..c90a230f88 --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/rrweb/SessionReplay.ts @@ -0,0 +1,57 @@ +import { type LDContext, type LDEvaluationDetail } from 'launchdarkly-js-client-sdk'; +import * as rrweb from 'rrweb'; + +import { Recorder } from '../../api'; +import { Collector } from '../../api/Collector'; +import ContinuousReplay from './ContinuousReplay'; +import RollingReplay from './RollingReplay'; +import { ContinuousCapture, RollingCapture, SessionReplayOptions } from './SessionReplayOptions'; + +function isRollingCapture(capture: unknown): capture is RollingCapture { + return (capture as { type: string })?.type === 'rolling'; +} + +function isContinuousCapture(capture: unknown): capture is ContinuousCapture { + return (capture as { type: string })?.type === 'continuous'; +} + +/** + * Experimental capture of sessions using rrweb. + */ +export default class SessionReplay implements Collector { + impl: Collector; + + constructor(options?: SessionReplayOptions) { + const captureOptions = options?.capture; + if (isContinuousCapture(captureOptions)) { + this.impl = new ContinuousReplay(captureOptions); + } else if (isRollingCapture(captureOptions)) { + this.impl = new RollingReplay(captureOptions); + } else { + this.impl = new ContinuousReplay({ + type: 'continuous', + intervalMs: 5 * 1000, + }); + } + } + + register(recorder: Recorder, sessionId: string): void { + this.impl.register(recorder, sessionId); + } + + unregister(): void { + this.impl.unregister(); + } + + handleFlagUsed?(flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext): void { + rrweb.record.addCustomEvent('flag-used', { key: flagKey, detail: flagDetail }); + } + + handleFlagDetailChanged?(flagKey: string, detail: LDEvaluationDetail): void { + rrweb.record.addCustomEvent('flag-detail-changed', { key: flagKey, detail }); + } + + handleErrorEvent(name: string, message: string): void { + rrweb.record.addCustomEvent('error', { name, message }); + } +} diff --git a/packages/telemetry/browser-telemetry/src/collectors/rrweb/SessionReplayOptions.ts b/packages/telemetry/browser-telemetry/src/collectors/rrweb/SessionReplayOptions.ts new file mode 100644 index 0000000000..96b6340fbe --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/collectors/rrweb/SessionReplayOptions.ts @@ -0,0 +1,14 @@ +export interface RollingCapture { + type: 'rolling'; + eventSegmentLength: number; + segmentBufferLength: number; +} + +export interface ContinuousCapture { + type: 'continuous'; + intervalMs: number; +} + +export interface SessionReplayOptions { + capture?: RollingCapture | ContinuousCapture; +} diff --git a/packages/telemetry/browser-telemetry/src/defaultRecorder.ts b/packages/telemetry/browser-telemetry/src/defaultRecorder.ts new file mode 100644 index 0000000000..6462536eff --- /dev/null +++ b/packages/telemetry/browser-telemetry/src/defaultRecorder.ts @@ -0,0 +1,42 @@ +import { Breadcrumb, EventData, Recorder, SessionData } from './api'; + +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'; + +export default function defaultRecorder(capture: (data: EventData) => void): Recorder { + const captureError = (error: Error) => { + const validException = error !== undefined && error !== null; + + const data: ErrorData = validException + ? { + type: error.name || error.constructor?.name || GENERIC_EXCEPTION, + // Only coalesce null/undefined, not empty. + message: error.message ?? MISSING_MESSAGE, + stack: parse(error, this._options.stack), + breadcrumbs: [...this._breadcrumbs], + sessionId: this._sessionId, + } + : { + type: GENERIC_EXCEPTION, + message: NULL_EXCEPTION_MESSAGE, + stack: { frames: [] }, + breadcrumbs: [...this._breadcrumbs], + sessionId: this._sessionId, + }; + capture(ERROR_KEY, data); + }; + const captureErrorEvent = (error: ErrorEvent) => {}; + const addBreadcrumb = (breadcrumb: Breadcrumb) => {}; + const captureSession = (sessionData: SessionData) => {}; + + return { + captureError, + captureErrorEvent, + addBreadcrumb, + captureSession, + }; +} diff --git a/packages/telemetry/browser-telemetry/src/inspectors.ts b/packages/telemetry/browser-telemetry/src/inspectors.ts index 5f8e65079e..e57920bde9 100644 --- a/packages/telemetry/browser-telemetry/src/inspectors.ts +++ b/packages/telemetry/browser-telemetry/src/inspectors.ts @@ -29,11 +29,21 @@ export default function makeInspectors( if (options.breadcrumbs.flagChange) { inspectors.push({ type: 'flag-detail-changed', - name: 'launchdarkly-browser-telemetry-flag-used', + name: 'launchdarkly-browser-telemetry-flag-detail-changed', synchronous: true, method(flagKey: string, detail: LDEvaluationDetail): void { telemetry.handleFlagDetailChanged(flagKey, detail); }, }); + inspectors.push({ + type: 'flag-details-changed', + name: 'launchdarkly-browser-telemetry-flag-details-changed', + synchronous: true, + method(details: Record) { + Object.entries(details).forEach(([key, detail]) => { + telemetry.handleFlagDetailChanged(key, detail); + }); + }, + }); } } From 0fc69f789b10686da57e9056fa1148e7f746f736 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Dec 2024 10:57:37 -0800 Subject: [PATCH 2/5] Add missing imports. --- packages/telemetry/browser-telemetry/src/defaultRecorder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/telemetry/browser-telemetry/src/defaultRecorder.ts b/packages/telemetry/browser-telemetry/src/defaultRecorder.ts index 6462536eff..265ac99202 100644 --- a/packages/telemetry/browser-telemetry/src/defaultRecorder.ts +++ b/packages/telemetry/browser-telemetry/src/defaultRecorder.ts @@ -1,4 +1,4 @@ -import { Breadcrumb, EventData, Recorder, SessionData } from './api'; +import { Breadcrumb, ErrorData, EventData, Recorder, SessionData } from './api'; const CUSTOM_KEY_PREFIX = '$ld:telemetry'; const ERROR_KEY = `${CUSTOM_KEY_PREFIX}:error`; From 6f0df3851e7df4c46ea67d6e808787131867c586 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 10 Dec 2024 13:26:24 -0800 Subject: [PATCH 3/5] Connect together session data and add recorder abstraction. --- .../src/BrowserTelemetryImpl.ts | 62 ++++++++++++++++++- .../SessionMetadata.ts} | 8 ++- .../src/collectors/rrweb/SessionReplay.ts | 8 ++- .../browser-telemetry/src/defaultRecorder.ts | 42 ------------- 4 files changed, 70 insertions(+), 50 deletions(-) rename packages/telemetry/browser-telemetry/src/{collectors/rrweb/ReplayCollector.ts => api/SessionMetadata.ts} (50%) delete mode 100644 packages/telemetry/browser-telemetry/src/defaultRecorder.ts diff --git a/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts b/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts index 2103b60ecb..97c376a8de 100644 --- a/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts +++ b/packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts @@ -18,6 +18,7 @@ import { BrowserTelemetry } from './api/BrowserTelemetry'; import { Collector } from './api/Collector'; import { ErrorData } from './api/ErrorData'; import { EventData } from './api/EventData'; +import { SessionMetadata } from './api/SessionMetadata'; import ClickCollector from './collectors/dom/ClickCollector'; import KeypressCollector from './collectors/dom/KeypressCollector'; import ErrorCollector from './collectors/error'; @@ -81,6 +82,8 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { private _collectors: Collector[] = []; private _sessionId: string = randomUuidV4(); + private _recorder: Recorder; + constructor(private _options: ParsedOptions) { configureTraceKit(_options.stack); @@ -120,7 +123,9 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { this._collectors.push(new KeypressCollector()); } - this._collectors.forEach((collector) => collector.register(this as Recorder, this._sessionId)); + this._recorder = this._makeRecorder(); + + this._collectors.forEach((collector) => collector.register(this._recorder, this._sessionId)); const impl = this; const inspectors: LDInspection[] = []; @@ -128,6 +133,49 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { this._inspectorInstances.push(...inspectors); } + /** + * Make a recorder for use by collectors. + * + * The recorder interface isn't directly implemented on the telemetry implementation because + * that would not allow us to expose methods via the recorder that are not available directly + * on the telemetry instance. + * + * @returns A recorder instance. + */ + private _makeRecorder() { + const captureError = (error: Error) => { + const validException = error !== undefined && error !== null; + + const data: ErrorData = validException + ? { + type: error.name || error.constructor?.name || GENERIC_EXCEPTION, + // Only coalesce null/undefined, not empty. + message: error.message ?? MISSING_MESSAGE, + stack: parse(error, 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); + }; + const captureErrorEvent = (error: ErrorEvent) => { + captureError(error.error); + }; + const addBreadcrumb = (breadcrumb: Breadcrumb) => { + this.addBreadcrumb(breadcrumb); + }; + const captureSession = (sessionData: SessionData) => { + this._captureSession(sessionData); + }; + return { captureError, captureErrorEvent, addBreadcrumb, captureSession }; + } + register(client: LDClientTracking): void { this._client = client; this._pendingEvents.forEach((event) => { @@ -184,7 +232,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { this.captureError(errorEvent.error); } - captureSession(sessionEvent: SessionData): void { + private _captureSession(sessionEvent: SessionData): void { this._capture(SESSION_CAPTURE_KEY, { ...sessionEvent, breadcrumbs: [...this._breadcrumbs] }); } @@ -207,7 +255,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { * * @internal */ - handleFlagUsed(flagKey: string, detail: LDEvaluationDetail, _context?: LDContext): void { + handleFlagUsed(flagKey: string, detail: LDEvaluationDetail, context?: LDContext): void { const breadcrumb: FeatureManagementBreadcrumb = { type: 'flag-evaluated', data: { @@ -219,6 +267,10 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { level: 'info', }; this.addBreadcrumb(breadcrumb); + this._collectors.forEach((collector) => { + const metaDataCollector = collector as unknown as SessionMetadata; + metaDataCollector.handleFlagUsed?.(flagKey, detail, context); + }); } /** @@ -242,5 +294,9 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry { }; this.addBreadcrumb(breadcrumb); + this._collectors.forEach((collector) => { + const metaDataCollector = collector as unknown as SessionMetadata; + metaDataCollector.handleFlagDetailChanged?.(flagKey, detail); + }); } } diff --git a/packages/telemetry/browser-telemetry/src/collectors/rrweb/ReplayCollector.ts b/packages/telemetry/browser-telemetry/src/api/SessionMetadata.ts similarity index 50% rename from packages/telemetry/browser-telemetry/src/collectors/rrweb/ReplayCollector.ts rename to packages/telemetry/browser-telemetry/src/api/SessionMetadata.ts index 9d1cc050b7..5391cd655c 100644 --- a/packages/telemetry/browser-telemetry/src/collectors/rrweb/ReplayCollector.ts +++ b/packages/telemetry/browser-telemetry/src/api/SessionMetadata.ts @@ -1,10 +1,14 @@ import type { LDContext, LDEvaluationDetail } from '@launchdarkly/js-client-sdk'; /** - * For session replay there is a need to annotate the session data with + * In some cases collectors may need additional runtime information about feature management + * and errors. This may be used to annotate other events, or insert events into a stream. + * + * For example session replay data may include markers for when a flag is used or when an error + * happened. */ export interface SessionMetadata { - handleFlagUsed?(flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext): void; + handleFlagUsed?(flagKey: string, flagDetail: LDEvaluationDetail, context?: LDContext): void; handleFlagDetailChanged?(flagKey: string, detail: LDEvaluationDetail): void; handleErrorEvent(name: string, message: string): void; } diff --git a/packages/telemetry/browser-telemetry/src/collectors/rrweb/SessionReplay.ts b/packages/telemetry/browser-telemetry/src/collectors/rrweb/SessionReplay.ts index c90a230f88..85e305204d 100644 --- a/packages/telemetry/browser-telemetry/src/collectors/rrweb/SessionReplay.ts +++ b/packages/telemetry/browser-telemetry/src/collectors/rrweb/SessionReplay.ts @@ -1,8 +1,10 @@ -import { type LDContext, type LDEvaluationDetail } from 'launchdarkly-js-client-sdk'; import * as rrweb from 'rrweb'; +import type { LDContext, LDEvaluationDetail } from '@launchdarkly/js-client-sdk'; + import { Recorder } from '../../api'; import { Collector } from '../../api/Collector'; +import { SessionMetadata } from '../../api/SessionMetadata'; import ContinuousReplay from './ContinuousReplay'; import RollingReplay from './RollingReplay'; import { ContinuousCapture, RollingCapture, SessionReplayOptions } from './SessionReplayOptions'; @@ -18,7 +20,7 @@ function isContinuousCapture(capture: unknown): capture is ContinuousCapture { /** * Experimental capture of sessions using rrweb. */ -export default class SessionReplay implements Collector { +export default class SessionReplay implements Collector, SessionMetadata { impl: Collector; constructor(options?: SessionReplayOptions) { @@ -43,7 +45,7 @@ export default class SessionReplay implements Collector { this.impl.unregister(); } - handleFlagUsed?(flagKey: string, flagDetail: LDEvaluationDetail, _context: LDContext): void { + handleFlagUsed?(flagKey: string, flagDetail: LDEvaluationDetail, _context?: LDContext): void { rrweb.record.addCustomEvent('flag-used', { key: flagKey, detail: flagDetail }); } diff --git a/packages/telemetry/browser-telemetry/src/defaultRecorder.ts b/packages/telemetry/browser-telemetry/src/defaultRecorder.ts deleted file mode 100644 index 265ac99202..0000000000 --- a/packages/telemetry/browser-telemetry/src/defaultRecorder.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Breadcrumb, ErrorData, EventData, Recorder, SessionData } from './api'; - -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'; - -export default function defaultRecorder(capture: (data: EventData) => void): Recorder { - const captureError = (error: Error) => { - const validException = error !== undefined && error !== null; - - const data: ErrorData = validException - ? { - type: error.name || error.constructor?.name || GENERIC_EXCEPTION, - // Only coalesce null/undefined, not empty. - message: error.message ?? MISSING_MESSAGE, - stack: parse(error, this._options.stack), - breadcrumbs: [...this._breadcrumbs], - sessionId: this._sessionId, - } - : { - type: GENERIC_EXCEPTION, - message: NULL_EXCEPTION_MESSAGE, - stack: { frames: [] }, - breadcrumbs: [...this._breadcrumbs], - sessionId: this._sessionId, - }; - capture(ERROR_KEY, data); - }; - const captureErrorEvent = (error: ErrorEvent) => {}; - const addBreadcrumb = (breadcrumb: Breadcrumb) => {}; - const captureSession = (sessionData: SessionData) => {}; - - return { - captureError, - captureErrorEvent, - addBreadcrumb, - captureSession, - }; -} From 241598b6c499a87ba3a190fabd0cb19ada91f612 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 12 Dec 2024 09:26:52 -0800 Subject: [PATCH 4/5] Change to destination --- .../src/collectors/rrweb/ContinuousReplay.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/telemetry/browser-telemetry/src/collectors/rrweb/ContinuousReplay.ts b/packages/telemetry/browser-telemetry/src/collectors/rrweb/ContinuousReplay.ts index 4a34a42b86..419a2781fd 100644 --- a/packages/telemetry/browser-telemetry/src/collectors/rrweb/ContinuousReplay.ts +++ b/packages/telemetry/browser-telemetry/src/collectors/rrweb/ContinuousReplay.ts @@ -6,7 +6,7 @@ import { Collector } from '../../api/Collector'; import { ContinuousCapture } from './SessionReplayOptions'; export default class ContinuousReplay implements Collector { - private _telemetry?: Recorder; + private _destination?: Recorder; // TODO: Use a better buffer. (SDK-972) private _buffer: eventWithTime[] = []; private _stopper?: () => void; @@ -39,13 +39,13 @@ export default class ContinuousReplay implements Collector { } register(recorder: Recorder, sessionId: string): void { - this._telemetry = recorder; + this._destination = recorder; this._sessionId = sessionId; } unregister(): void { this._stopper?.(); - this._telemetry = undefined; + this._destination = undefined; document.removeEventListener('visibilitychange', this._visibilityHandler); clearInterval(this._timerHandle); } @@ -62,8 +62,8 @@ export default class ContinuousReplay implements Collector { private _recordCapture(): void { // Telemetry and sessionId should always be set at the same time, but check both // for correctness. - if (this._telemetry && this._sessionId) { - this._telemetry.captureSession({ + if (this._destination && this._sessionId) { + this._destination.captureSession({ events: [...this._buffer], index: this._index, }); From 1247369ffd5eb60735bcd58e2d8b6d938c9e9843 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 12 Dec 2024 10:11:33 -0800 Subject: [PATCH 5/5] PR feedback. --- ...onBuffer.test.ts => RollingBuffer.test.ts} | 24 +++++++++++++++++++ .../src/collectors/rrweb/RollingBuffer.ts | 20 +++++++++------- 2 files changed, 36 insertions(+), 8 deletions(-) rename packages/telemetry/browser-telemetry/__tests__/collectors/rrweb/{SessionBuffer.test.ts => RollingBuffer.test.ts} (59%) diff --git a/packages/telemetry/browser-telemetry/__tests__/collectors/rrweb/SessionBuffer.test.ts b/packages/telemetry/browser-telemetry/__tests__/collectors/rrweb/RollingBuffer.test.ts similarity index 59% rename from packages/telemetry/browser-telemetry/__tests__/collectors/rrweb/SessionBuffer.test.ts rename to packages/telemetry/browser-telemetry/__tests__/collectors/rrweb/RollingBuffer.test.ts index 2bdf2cf069..772f7ddbd3 100644 --- a/packages/telemetry/browser-telemetry/__tests__/collectors/rrweb/SessionBuffer.test.ts +++ b/packages/telemetry/browser-telemetry/__tests__/collectors/rrweb/RollingBuffer.test.ts @@ -27,3 +27,27 @@ it('when the buffer is exceeded it will wrap around', () => { expect(buffer.toArray()).toEqual(expectedItems); }); + +it('can reset the buffer', () => { + const bufferSize = 5; + const numberBuffers = 4; + const buffer = new RollingBuffer(bufferSize, numberBuffers); + const demoItems = Array.from(new Array(10), (_, i) => i); + + demoItems.forEach(buffer.push.bind(buffer)); + buffer.reset(); + + expect(buffer.toArray()).toEqual([]); +}); + +it('returns correct items when buffer is partially filled', () => { + const bufferSize = 5; + const numberBuffers = 4; + const buffer = new RollingBuffer(bufferSize, numberBuffers); + const itemsToAdd = 7; // Less than total capacity + const demoItems = Array.from(new Array(itemsToAdd), (_, i) => i); + + demoItems.forEach(buffer.push.bind(buffer)); + + expect(buffer.toArray()).toEqual(demoItems); +}); diff --git a/packages/telemetry/browser-telemetry/src/collectors/rrweb/RollingBuffer.ts b/packages/telemetry/browser-telemetry/src/collectors/rrweb/RollingBuffer.ts index df2ecd2ddb..073bbd08da 100644 --- a/packages/telemetry/browser-telemetry/src/collectors/rrweb/RollingBuffer.ts +++ b/packages/telemetry/browser-telemetry/src/collectors/rrweb/RollingBuffer.ts @@ -43,16 +43,20 @@ export default class RollingBuffer { toArray(): any[] { const asArray: any[] = []; - const size = this._buffers.reduce((acc: number, item: EventBuffer) => { - if (item.isPopulated()) { - return acc + 1; - } - return acc; - }, 0); - for (let index = this._headPointer; index < this._headPointer + size; index += 1) { + // Loop through the buffers, apprending their contents to asArray, until we find an empty one. + for ( + let index = this._headPointer; + index < this._headPointer + this._buffers.length; + index += 1 + ) { const realIndex = index % this._buffers.length; - asArray.push(...this._buffers[realIndex].content); + const item = this._buffers[realIndex]; + + if (!item.isPopulated) { + break; + } + asArray.push(...item.content); } return asArray;