Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add underlying session replay support. (Not enabled) #708

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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);
});

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);
});
5 changes: 5 additions & 0 deletions packages/telemetry/browser-telemetry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"devDependencies": {
"@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",
Expand All @@ -67,9 +68,13 @@
"launchdarkly-js-test-helpers": "^2.2.0",
"prettier": "^3.0.0",
"rimraf": "^5.0.5",
"rrweb": "2.0.0-alpha.4",
"ts-jest": "^29.1.1",
"tsup": "^8.3.5",
"typedoc": "0.25.0",
"typescript": "^5.5.3"
},
"peerDependencies": {
"rrweb": "2.0.0-alpha.4"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,18 @@ 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';
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';
Expand Down Expand Up @@ -76,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);

Expand Down Expand Up @@ -115,16 +123,59 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
this._collectors.push(new KeypressCollector());
}

this._collectors.forEach((collector) =>
collector.register(this as BrowserTelemetry, this._sessionId),
);
this._recorder = this._makeRecorder();

this._collectors.forEach((collector) => collector.register(this._recorder, this._sessionId));

const impl = this;
const inspectors: LDInspection[] = [];
makeInspectors(_options, inspectors, impl);
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) => {
Expand All @@ -148,7 +199,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
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)
// TODO: Log when pending events must be dropped. (SDK-915, SDK-973)
this._pendingEvents.shift();
}
}
Expand Down Expand Up @@ -181,7 +232,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
this.captureError(errorEvent.error);
}

captureSession(sessionEvent: EventData): void {
private _captureSession(sessionEvent: SessionData): void {
this._capture(SESSION_CAPTURE_KEY, { ...sessionEvent, breadcrumbs: [...this._breadcrumbs] });
}

Expand All @@ -204,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: {
Expand All @@ -216,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);
});
}

/**
Expand All @@ -239,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);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
3 changes: 2 additions & 1 deletion packages/telemetry/browser-telemetry/src/api/EventData.ts
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 11 additions & 0 deletions packages/telemetry/browser-telemetry/src/api/Recorder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Breadcrumb } from './Breadcrumb';
import { SessionData } from './SessionData';

/**
* Interface for capturing telemetry data.
Expand All @@ -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;
}
6 changes: 6 additions & 0 deletions packages/telemetry/browser-telemetry/src/api/SessionData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { eventWithTime } from '@rrweb/types';

export interface SessionData {
events: eventWithTime[];
index: number;
}
14 changes: 14 additions & 0 deletions packages/telemetry/browser-telemetry/src/api/SessionMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { LDContext, LDEvaluationDetail } from '@launchdarkly/js-client-sdk';

/**
* 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;
handleFlagDetailChanged?(flagKey: string, detail: LDEvaluationDetail): void;
handleErrorEvent(name: string, message: string): void;
}
2 changes: 2 additions & 0 deletions packages/telemetry/browser-telemetry/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export * from './Options';
export * from './Recorder';
export * from './stack';
export * from './client';
export * from './EventData';
export * from './SessionData';
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export default function decorateXhr(callback: (breadcrumb: HttpBreadcrumb) => vo
});
} catch {
// Intentional ignore.
// TODO: If we add debug logging, then this should be logged.
// TODO: If we add debug logging, then this should be logged. (SDK-973)
}
}

Expand Down Expand Up @@ -118,6 +118,6 @@ export default function decorateXhr(callback: (breadcrumb: HttpBreadcrumb) => vo
});
} catch {
// Intentional ignore.
// TODO: If we add debug logging, then this should be logged.
// TODO: If we add debug logging, then this should be logged. (SDK-973)
}
}
Loading
Loading