Skip to content

Commit

Permalink
Merge branch 'main' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
professorice authored Jan 22, 2025
2 parents 8b0ee06 + 833f4ce commit 3d4fb6d
Show file tree
Hide file tree
Showing 13 changed files with 468 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ it('limits pending events to maxPendingEvents', () => {

telemetry.register(mockClient);

// Should only see the last 2 errors tracked
expect(mockClient.track).toHaveBeenCalledTimes(2);
// Should only see the the session init event and last 2 errors tracked
expect(mockClient.track).toHaveBeenCalledTimes(3);
expect(mockClient.track).toHaveBeenCalledWith(
'$ld:telemetry:error',
expect.objectContaining({
Expand Down Expand Up @@ -522,3 +522,15 @@ it('uses the client logger when no logger is provided', () => {
'LaunchDarkly - Browser Telemetry: Error applying breadcrumb filters: Error: Filter error',
);
});

it('sends session init event when client is registered', () => {
const telemetry = new BrowserTelemetryImpl(defaultOptions);
telemetry.register(mockClient);

expect(mockClient.track).toHaveBeenCalledWith(
'$ld:telemetry:session:init',
expect.objectContaining({
sessionId: expect.any(String),
}),
);
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Breadcrumb } from '../src/api/Breadcrumb';
import ErrorCollector from '../src/collectors/error';
import parse, { defaultOptions } from '../src/options';

Expand All @@ -15,14 +16,15 @@ it('handles an empty configuration', () => {
});

it('can set all options at once', () => {
const filter = (breadcrumb: Breadcrumb) => breadcrumb;
const outOptions = parse({
maxPendingEvents: 1,
breadcrumbs: {
maxBreadcrumbs: 1,
click: false,
evaluations: false,
flagChange: false,
filters: [(breadcrumb) => breadcrumb],
filters: [filter],
},
collectors: [new ErrorCollector(), new ErrorCollector()],
});
Expand All @@ -39,7 +41,7 @@ it('can set all options at once', () => {
instrumentFetch: true,
instrumentXhr: true,
},
filters: expect.any(Array),
filters: expect.arrayContaining([filter]),
},
stack: {
source: {
Expand All @@ -50,6 +52,7 @@ it('can set all options at once', () => {
},
collectors: [new ErrorCollector(), new ErrorCollector()],
});
expect(mockLogger.warn).not.toHaveBeenCalled();
});

it('warns when maxPendingEvents is not a number', () => {
Expand Down Expand Up @@ -435,6 +438,6 @@ it('warns when filters is not an array', () => {
);
expect(outOptions.breadcrumbs.filters).toEqual([]);
expect(mockLogger.warn).toHaveBeenCalledWith(
'LaunchDarkly - Browser Telemetry: Config option "breadcrumbs.filters" should be of type array, got string, using default value',
'LaunchDarkly - Browser Telemetry: Config option "breadcrumbs.filters" should be of type BreadcrumbFilter[], got string, using default value',
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { fallbackLogger } from '../../src/logging';
import { getTelemetryInstance, initTelemetry, resetTelemetryInstance } from '../../src/singleton';

beforeEach(() => {
resetTelemetryInstance();
jest.resetAllMocks();
});

it('warns and keeps existing instance when initialized multiple times', () => {
const mockLogger = {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
};

initTelemetry({ logger: mockLogger });
const instanceA = getTelemetryInstance();
initTelemetry({ logger: mockLogger });
const instanceB = getTelemetryInstance();

expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringMatching(/Telemetry has already been initialized/),
);

expect(instanceA).toBe(instanceB);
});

it('warns when getting telemetry instance before initialization', () => {
const spy = jest.spyOn(fallbackLogger, 'warn');

getTelemetryInstance();

expect(spy).toHaveBeenCalledWith(expect.stringMatching(/Telemetry has not been initialized/));
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Breadcrumb, LDClientTracking } from '../../src/api';
import { BrowserTelemetry } from '../../src/api/BrowserTelemetry';
import { BrowserTelemetryInspector } from '../../src/api/client/BrowserTelemetryInspector';
import { getTelemetryInstance } from '../../src/singleton/singletonInstance';
import {
addBreadcrumb,
captureError,
captureErrorEvent,
close,
inspectors,
register,
} from '../../src/singleton/singletonMethods';

jest.mock('../../src/singleton/singletonInstance');

const mockTelemetry: jest.Mocked<BrowserTelemetry> = {
inspectors: jest.fn(),
captureError: jest.fn(),
captureErrorEvent: jest.fn(),
addBreadcrumb: jest.fn(),
register: jest.fn(),
close: jest.fn(),
};

const mockGetTelemetryInstance = getTelemetryInstance as jest.Mock;

beforeEach(() => {
jest.resetAllMocks();
});

it('returns empty array when telemetry is not initialized for inspectors', () => {
mockGetTelemetryInstance.mockReturnValue(undefined);
expect(() => inspectors()).not.toThrow();
expect(inspectors()).toEqual([]);
});

it('returns inspectors when telemetry is initialized', () => {
const mockInspectors: BrowserTelemetryInspector[] = [
{ name: 'test-inspector', type: 'flag-used', synchronous: true, method: () => {} },
];
mockGetTelemetryInstance.mockReturnValue(mockTelemetry);
mockTelemetry.inspectors.mockReturnValue(mockInspectors);

expect(inspectors()).toBe(mockInspectors);
});

it('does not crash when calling captureError with no telemetry instance', () => {
mockGetTelemetryInstance.mockReturnValue(undefined);
const error = new Error('test error');

expect(() => captureError(error)).not.toThrow();

expect(mockTelemetry.captureError).not.toHaveBeenCalled();
});

it('captures errors when telemetry is initialized', () => {
mockGetTelemetryInstance.mockReturnValue(mockTelemetry);
const error = new Error('test error');

captureError(error);

expect(mockTelemetry.captureError).toHaveBeenCalledWith(error);
});

it('it does not crash when calling captureErrorEvent with no telemetry instance', () => {
mockGetTelemetryInstance.mockReturnValue(undefined);
const errorEvent = new ErrorEvent('error', { error: new Error('test error') });

expect(() => captureErrorEvent(errorEvent)).not.toThrow();

expect(mockTelemetry.captureErrorEvent).not.toHaveBeenCalled();
});

it('captures error event when telemetry is initialized', () => {
mockGetTelemetryInstance.mockReturnValue(mockTelemetry);
const errorEvent = new ErrorEvent('error', { error: new Error('test error') });

captureErrorEvent(errorEvent);

expect(mockTelemetry.captureErrorEvent).toHaveBeenCalledWith(errorEvent);
});

it('does not crash when calling addBreadcrumb with no telemetry instance', () => {
mockGetTelemetryInstance.mockReturnValue(undefined);
const breadcrumb: Breadcrumb = {
type: 'custom',
data: { test: 'data' },
timestamp: Date.now(),
class: 'custom',
level: 'info',
};

expect(() => addBreadcrumb(breadcrumb)).not.toThrow();

expect(mockTelemetry.addBreadcrumb).not.toHaveBeenCalled();
});

it('adds breadcrumb when telemetry is initialized', () => {
mockGetTelemetryInstance.mockReturnValue(mockTelemetry);
const breadcrumb: Breadcrumb = {
type: 'custom',
data: { test: 'data' },
timestamp: Date.now(),
class: 'custom',
level: 'info',
};

addBreadcrumb(breadcrumb);

expect(mockTelemetry.addBreadcrumb).toHaveBeenCalledWith(breadcrumb);
});

it('does not crash when calling register with no telemetry instance', () => {
mockGetTelemetryInstance.mockReturnValue(undefined);
const mockClient: jest.Mocked<LDClientTracking> = {
track: jest.fn(),
};

expect(() => register(mockClient)).not.toThrow();

expect(mockTelemetry.register).not.toHaveBeenCalled();
});

it('registers client when telemetry is initialized', () => {
mockGetTelemetryInstance.mockReturnValue(mockTelemetry);
const mockClient: jest.Mocked<LDClientTracking> = {
track: jest.fn(),
};

register(mockClient);

expect(mockTelemetry.register).toHaveBeenCalledWith(mockClient);
});

it('does not crash when calling close with no telemetry instance', () => {
mockGetTelemetryInstance.mockReturnValue(undefined);

expect(() => close()).not.toThrow();

expect(mockTelemetry.close).not.toHaveBeenCalled();
});

it('closes when telemetry is initialized', () => {
mockGetTelemetryInstance.mockReturnValue(mockTelemetry);

close();

expect(mockTelemetry.close).toHaveBeenCalled();
});
18 changes: 9 additions & 9 deletions packages/telemetry/browser-telemetry/src/BrowserTelemetryImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
* 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 type { LDContext, LDEvaluationDetail } from '@launchdarkly/js-client-sdk';

import { BreadcrumbFilter, LDClientLogging, LDClientTracking, MinLogger } from './api';
import { Breadcrumb, FeatureManagementBreadcrumb } from './api/Breadcrumb';
import { BrowserTelemetry } from './api/BrowserTelemetry';
import { BrowserTelemetryInspector } from './api/client/BrowserTelemetryInspector';
import { Collector } from './api/Collector';
import { ErrorData } from './api/ErrorData';
import { EventData } from './api/EventData';
Expand All @@ -28,7 +29,7 @@ import { getTraceKit } from './vendor/TraceKit';

const CUSTOM_KEY_PREFIX = '$ld:telemetry';
const ERROR_KEY = `${CUSTOM_KEY_PREFIX}:error`;
const SESSION_CAPTURE_KEY = `${CUSTOM_KEY_PREFIX}:sessionCapture`;
const SESSION_INIT_KEY = `${CUSTOM_KEY_PREFIX}:session:init`;
const GENERIC_EXCEPTION = 'generic';
const NULL_EXCEPTION_MESSAGE = 'exception was null or undefined';
const MISSING_MESSAGE = 'exception had no message';
Expand Down Expand Up @@ -94,7 +95,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {

private _breadcrumbs: Breadcrumb[] = [];

private _inspectorInstances: LDInspection[] = [];
private _inspectorInstances: BrowserTelemetryInspector[] = [];
private _collectors: Collector[] = [];
private _sessionId: string = randomUuidV4();

Expand Down Expand Up @@ -149,7 +150,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
);

const impl = this;
const inspectors: LDInspection[] = [];
const inspectors: BrowserTelemetryInspector[] = [];
makeInspectors(_options, inspectors, impl);
this._inspectorInstances.push(...inspectors);

Expand All @@ -163,6 +164,9 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
// When the client is registered, we need to set the logger again, because we may be able to use the client's
// logger.
this._setLogger();

this._client.track(SESSION_INIT_KEY, { sessionId: this._sessionId });

this._pendingEvents.forEach((event) => {
this._client?.track(event.type, event.data);
});
Expand All @@ -181,7 +185,7 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
}
}

inspectors(): LDInspection[] {
inspectors(): BrowserTelemetryInspector[] {
return this._inspectorInstances;
}

Expand Down Expand Up @@ -237,10 +241,6 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
this.captureError(errorEvent.error);
}

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

private _applyBreadcrumbFilters(
breadcrumb: Breadcrumb,
filters: BreadcrumbFilter[],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { LDInspection } from '@launchdarkly/js-client-sdk';

import { Breadcrumb } from './Breadcrumb';
import { BrowserTelemetryInspector } from './client/BrowserTelemetryInspector';
import { LDClientTracking } from './client/LDClientTracking';

/**
Expand All @@ -15,9 +14,9 @@ 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.
* @returns An array of {@link BrowserTelemetryInspector} objects.
*/
inspectors(): LDInspection[];
inspectors(): BrowserTelemetryInspector[];

/**
* Captures an Error object for telemetry purposes.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* A less constrained version of the LDInspection interface in order to allow for greater compatibility between
* SDK versions.
*
* This interface is not intended for use by application developers and is instead intended as a compatibility bridge
* to support multiple SDK versions.
*/
export interface BrowserTelemetryInspector {
/**
* The telemetry package only requires flag-detail-changed inspectors and flag-used inspectors.
*/
type: 'flag-used' | 'flag-detail-changed';

/**
* The name of the inspector, used for debugging purposes.
*/
name: string;
/**
* Whether the inspector is synchronous.
*/
synchronous: boolean;
/**
* The method to call when the inspector is triggered.
*
* The typing here is intentionally loose to allow for greater compatibility between SDK versions.
* This function should ONLY be called by an SDK instance and not by an application developer.
*/
method: (...args: any[]) => void;
}
Loading

0 comments on commit 3d4fb6d

Please sign in to comment.