Skip to content

Commit 68a3b87

Browse files
feat: Add singleton support for browser-telemetry. (#739)
This PR adds a singleton interface to the EM SDK to simplify common usage. You can still create individual browser telemetry instances, but there is a default instance with module level functions for each operation. Minimal example. ``` import * as LDTelemetry from '@launchdarkly/browser-telemetry` LDTelemetry.initTelemetry(); LDTelemetry.register(ldClient); // For manual error capture. LDTelemetry.captureError(someError); ``` --------- Co-authored-by: Stacy Harrison <[email protected]>
1 parent 320c07d commit 68a3b87

File tree

6 files changed

+399
-1
lines changed

6 files changed

+399
-1
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { fallbackLogger } from '../../src/logging';
2+
import { getTelemetryInstance, initTelemetry, resetTelemetryInstance } from '../../src/singleton';
3+
4+
beforeEach(() => {
5+
resetTelemetryInstance();
6+
jest.resetAllMocks();
7+
});
8+
9+
it('warns and keeps existing instance when initialized multiple times', () => {
10+
const mockLogger = {
11+
error: jest.fn(),
12+
warn: jest.fn(),
13+
info: jest.fn(),
14+
debug: jest.fn(),
15+
};
16+
17+
initTelemetry({ logger: mockLogger });
18+
const instanceA = getTelemetryInstance();
19+
initTelemetry({ logger: mockLogger });
20+
const instanceB = getTelemetryInstance();
21+
22+
expect(mockLogger.warn).toHaveBeenCalledWith(
23+
expect.stringMatching(/Telemetry has already been initialized/),
24+
);
25+
26+
expect(instanceA).toBe(instanceB);
27+
});
28+
29+
it('warns when getting telemetry instance before initialization', () => {
30+
const spy = jest.spyOn(fallbackLogger, 'warn');
31+
32+
getTelemetryInstance();
33+
34+
expect(spy).toHaveBeenCalledWith(expect.stringMatching(/Telemetry has not been initialized/));
35+
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { LDInspection } from '@launchdarkly/js-client-sdk';
2+
3+
import { Breadcrumb, LDClientTracking } from '../../src/api';
4+
import { BrowserTelemetry } from '../../src/api/BrowserTelemetry';
5+
import { getTelemetryInstance } from '../../src/singleton/singletonInstance';
6+
import {
7+
addBreadcrumb,
8+
captureError,
9+
captureErrorEvent,
10+
close,
11+
inspectors,
12+
register,
13+
} from '../../src/singleton/singletonMethods';
14+
15+
jest.mock('../../src/singleton/singletonInstance');
16+
17+
const mockTelemetry: jest.Mocked<BrowserTelemetry> = {
18+
inspectors: jest.fn(),
19+
captureError: jest.fn(),
20+
captureErrorEvent: jest.fn(),
21+
addBreadcrumb: jest.fn(),
22+
register: jest.fn(),
23+
close: jest.fn(),
24+
};
25+
26+
const mockGetTelemetryInstance = getTelemetryInstance as jest.Mock;
27+
28+
beforeEach(() => {
29+
jest.resetAllMocks();
30+
});
31+
32+
it('returns empty array when telemetry is not initialized for inspectors', () => {
33+
mockGetTelemetryInstance.mockReturnValue(undefined);
34+
expect(() => inspectors()).not.toThrow();
35+
expect(inspectors()).toEqual([]);
36+
});
37+
38+
it('returns inspectors when telemetry is initialized', () => {
39+
const mockInspectors: LDInspection[] = [
40+
{ name: 'test-inspector', type: 'flag-used', method: () => {} },
41+
];
42+
mockGetTelemetryInstance.mockReturnValue(mockTelemetry);
43+
mockTelemetry.inspectors.mockReturnValue(mockInspectors);
44+
45+
expect(inspectors()).toBe(mockInspectors);
46+
});
47+
48+
it('does not crash when calling captureError with no telemetry instance', () => {
49+
mockGetTelemetryInstance.mockReturnValue(undefined);
50+
const error = new Error('test error');
51+
52+
expect(() => captureError(error)).not.toThrow();
53+
54+
expect(mockTelemetry.captureError).not.toHaveBeenCalled();
55+
});
56+
57+
it('captures errors when telemetry is initialized', () => {
58+
mockGetTelemetryInstance.mockReturnValue(mockTelemetry);
59+
const error = new Error('test error');
60+
61+
captureError(error);
62+
63+
expect(mockTelemetry.captureError).toHaveBeenCalledWith(error);
64+
});
65+
66+
it('it does not crash when calling captureErrorEvent with no telemetry instance', () => {
67+
mockGetTelemetryInstance.mockReturnValue(undefined);
68+
const errorEvent = new ErrorEvent('error', { error: new Error('test error') });
69+
70+
expect(() => captureErrorEvent(errorEvent)).not.toThrow();
71+
72+
expect(mockTelemetry.captureErrorEvent).not.toHaveBeenCalled();
73+
});
74+
75+
it('captures error event when telemetry is initialized', () => {
76+
mockGetTelemetryInstance.mockReturnValue(mockTelemetry);
77+
const errorEvent = new ErrorEvent('error', { error: new Error('test error') });
78+
79+
captureErrorEvent(errorEvent);
80+
81+
expect(mockTelemetry.captureErrorEvent).toHaveBeenCalledWith(errorEvent);
82+
});
83+
84+
it('does not crash when calling addBreadcrumb with no telemetry instance', () => {
85+
mockGetTelemetryInstance.mockReturnValue(undefined);
86+
const breadcrumb: Breadcrumb = {
87+
type: 'custom',
88+
data: { test: 'data' },
89+
timestamp: Date.now(),
90+
class: 'custom',
91+
level: 'info',
92+
};
93+
94+
expect(() => addBreadcrumb(breadcrumb)).not.toThrow();
95+
96+
expect(mockTelemetry.addBreadcrumb).not.toHaveBeenCalled();
97+
});
98+
99+
it('adds breadcrumb when telemetry is initialized', () => {
100+
mockGetTelemetryInstance.mockReturnValue(mockTelemetry);
101+
const breadcrumb: Breadcrumb = {
102+
type: 'custom',
103+
data: { test: 'data' },
104+
timestamp: Date.now(),
105+
class: 'custom',
106+
level: 'info',
107+
};
108+
109+
addBreadcrumb(breadcrumb);
110+
111+
expect(mockTelemetry.addBreadcrumb).toHaveBeenCalledWith(breadcrumb);
112+
});
113+
114+
it('does not crash when calling register with no telemetry instance', () => {
115+
mockGetTelemetryInstance.mockReturnValue(undefined);
116+
const mockClient: jest.Mocked<LDClientTracking> = {
117+
track: jest.fn(),
118+
};
119+
120+
expect(() => register(mockClient)).not.toThrow();
121+
122+
expect(mockTelemetry.register).not.toHaveBeenCalled();
123+
});
124+
125+
it('registers client when telemetry is initialized', () => {
126+
mockGetTelemetryInstance.mockReturnValue(mockTelemetry);
127+
const mockClient: jest.Mocked<LDClientTracking> = {
128+
track: jest.fn(),
129+
};
130+
131+
register(mockClient);
132+
133+
expect(mockTelemetry.register).toHaveBeenCalledWith(mockClient);
134+
});
135+
136+
it('does not crash when calling close with no telemetry instance', () => {
137+
mockGetTelemetryInstance.mockReturnValue(undefined);
138+
139+
expect(() => close()).not.toThrow();
140+
141+
expect(mockTelemetry.close).not.toHaveBeenCalled();
142+
});
143+
144+
it('closes when telemetry is initialized', () => {
145+
mockGetTelemetryInstance.mockReturnValue(mockTelemetry);
146+
147+
close();
148+
149+
expect(mockTelemetry.close).toHaveBeenCalled();
150+
});

packages/telemetry/browser-telemetry/src/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,20 @@ import parse from './options';
66

77
export * from './api';
88

9-
export function initializeTelemetry(options?: Options): BrowserTelemetry {
9+
export * from './singleton';
10+
11+
/**
12+
* Initialize a new telemetry instance.
13+
*
14+
* This instance is not global. Generally developers should use {@link initializeTelemetry} instead.
15+
*
16+
* If for some reason multiple telemetry instances are needed, this method can be used to create a new instance.
17+
* Instances are not aware of each other and may send duplicate data from automatically captured events.
18+
*
19+
* @param options The options to use for the telemetry instance.
20+
* @returns A telemetry instance.
21+
*/
22+
export function initializeTelemetryInstance(options?: Options): BrowserTelemetry {
1023
const parsedOptions = parse(options || {}, safeMinLogger(options?.logger));
1124
return new BrowserTelemetryImpl(parsedOptions);
1225
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './singletonInstance';
2+
export * from './singletonMethods';
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Options } from '../api';
2+
import { BrowserTelemetry } from '../api/BrowserTelemetry';
3+
import BrowserTelemetryImpl from '../BrowserTelemetryImpl';
4+
import { fallbackLogger, prefixLog, safeMinLogger } from '../logging';
5+
import parse from '../options';
6+
7+
let telemetryInstance: BrowserTelemetry | undefined;
8+
let warnedClientNotInitialized: boolean = false;
9+
10+
/**
11+
* Initialize the LaunchDarkly telemetry client
12+
*
13+
* This method should be called one time as early as possible in the application lifecycle.
14+
*
15+
* @example
16+
* ```
17+
* import { initTelemetry } from '@launchdarkly/browser-telemetry';
18+
*
19+
* initTelemetry();
20+
* ```
21+
*
22+
* After initialization the telemetry client must be registered with the LaunchDarkly SDK client.
23+
*
24+
* @example
25+
* ```
26+
* import { initTelemetry, register } from '@launchdarkly/browser-telemetry';
27+
*
28+
* initTelemetry();
29+
*
30+
* // Create your LaunchDarkly client following the LaunchDarkly SDK documentation.
31+
*
32+
* register(ldClient);
33+
* ```
34+
*
35+
* If using the 3.x version of the LaunchDarkly SDK, then you must also add inspectors when initializing your LaunchDarkly client.
36+
* This allows for integration with feature flag data.
37+
*
38+
* @example
39+
* ```
40+
* import { initTelemetry, register, inspectors } from '@launchdarkly/browser-telemetry';
41+
* import { init } from 'launchdarkly-js-client-sdk';
42+
*
43+
* initTelemetry();
44+
*
45+
* const ldClient = init('YOUR_CLIENT_SIDE_ID', {
46+
* inspectors: inspectors()
47+
* });
48+
*
49+
* register(ldClient);
50+
* ```
51+
*
52+
* @param options The options to use for the telemetry instance. Refer to {@link Options} for more information.
53+
*/
54+
export function initTelemetry(options?: Options) {
55+
const logger = safeMinLogger(options?.logger);
56+
57+
if (telemetryInstance) {
58+
logger.warn(prefixLog('Telemetry has already been initialized. Ignoring new options.'));
59+
return;
60+
}
61+
62+
const parsedOptions = parse(options || {}, logger);
63+
telemetryInstance = new BrowserTelemetryImpl(parsedOptions);
64+
}
65+
66+
/**
67+
* Get the telemetry instance.
68+
*
69+
* In typical operation this method doesn't need to be called. Instead the functions exported by this package directly
70+
* use the telemetry instance.
71+
*
72+
* This function can be used when the telemetry instance needs to be injected into code instead of accessed globally.
73+
*
74+
* @returns The telemetry instance, or undefined if it has not been initialized.
75+
*/
76+
export function getTelemetryInstance(): BrowserTelemetry | undefined {
77+
if (!telemetryInstance) {
78+
if (warnedClientNotInitialized) {
79+
return undefined;
80+
}
81+
82+
fallbackLogger.warn(prefixLog('Telemetry has not been initialized'));
83+
warnedClientNotInitialized = true;
84+
return undefined;
85+
}
86+
87+
return telemetryInstance;
88+
}
89+
90+
/**
91+
* Reset the telemetry instance to its initial state.
92+
*
93+
* This method is intended to be used in tests.
94+
*
95+
* @internal
96+
*/
97+
export function resetTelemetryInstance() {
98+
telemetryInstance = undefined;
99+
warnedClientNotInitialized = false;
100+
}

0 commit comments

Comments
 (0)