Skip to content

Commit d2ac2e2

Browse files
authored
fix: Fix race condition with client registration. (#750)
During initialization the js-client-sdk does some context processing. This is an async operation which will add keys to anonymous contexts. Being as this doesn't happen synchronously within the initialization call there is a possibility that you register the telemetry SDK before that initialization is complete. This PR updates the registration to attempt to wait for initialization. This is a larger window than we need to wait, but ensures that process will be complete.
1 parent 18e1aae commit d2ac2e2

File tree

4 files changed

+134
-8
lines changed

4 files changed

+134
-8
lines changed

packages/telemetry/browser-telemetry/__tests__/BrowserTelemetryImpl.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,3 +657,79 @@ it('only logs error filter error once', () => {
657657

658658
expect(mockLogger.warn).toHaveBeenCalledTimes(1);
659659
});
660+
661+
it('waits for client initialization before sending events', async () => {
662+
const telemetry = new BrowserTelemetryImpl(defaultOptions);
663+
const error = new Error('Test error');
664+
665+
let resolver;
666+
667+
const initPromise = new Promise((resolve) => {
668+
resolver = resolve;
669+
});
670+
671+
const mockInitClient = {
672+
track: jest.fn(),
673+
waitForInitialization: jest.fn().mockImplementation(() => initPromise),
674+
};
675+
676+
telemetry.captureError(error);
677+
telemetry.register(mockInitClient);
678+
679+
expect(mockInitClient.track).not.toHaveBeenCalled();
680+
681+
resolver!();
682+
683+
await initPromise;
684+
685+
expect(mockInitClient.track).toHaveBeenCalledWith(
686+
'$ld:telemetry:session:init',
687+
expect.objectContaining({
688+
sessionId: expect.any(String),
689+
}),
690+
);
691+
692+
expect(mockInitClient.track).toHaveBeenCalledWith(
693+
'$ld:telemetry:error',
694+
expect.objectContaining({
695+
type: 'Error',
696+
message: 'Test error',
697+
stack: { frames: expect.any(Array) },
698+
breadcrumbs: [],
699+
sessionId: expect.any(String),
700+
}),
701+
);
702+
});
703+
704+
it('handles client initialization failure gracefully', async () => {
705+
const telemetry = new BrowserTelemetryImpl(defaultOptions);
706+
const error = new Error('Test error');
707+
const mockInitClient = {
708+
track: jest.fn(),
709+
waitForInitialization: jest.fn().mockRejectedValue(new Error('Init failed')),
710+
};
711+
712+
telemetry.captureError(error);
713+
telemetry.register(mockInitClient);
714+
715+
await expect(mockInitClient.waitForInitialization()).rejects.toThrow('Init failed');
716+
717+
// Should still send events even if initialization fails
718+
expect(mockInitClient.track).toHaveBeenCalledWith(
719+
'$ld:telemetry:session:init',
720+
expect.objectContaining({
721+
sessionId: expect.any(String),
722+
}),
723+
);
724+
725+
expect(mockInitClient.track).toHaveBeenCalledWith(
726+
'$ld:telemetry:error',
727+
expect.objectContaining({
728+
type: 'Error',
729+
message: 'Test error',
730+
stack: { frames: expect.any(Array) },
731+
breadcrumbs: [],
732+
sessionId: expect.any(String),
733+
}),
734+
);
735+
});

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

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66
import type { LDContext, LDEvaluationDetail } from '@launchdarkly/js-client-sdk';
77

8-
import { LDClientLogging, LDClientTracking, MinLogger } from './api';
8+
import { LDClientInitialization, LDClientLogging, LDClientTracking, MinLogger } from './api';
99
import { Breadcrumb, FeatureManagementBreadcrumb } from './api/Breadcrumb';
1010
import { BrowserTelemetry } from './api/BrowserTelemetry';
1111
import { BrowserTelemetryInspector } from './api/client/BrowserTelemetryInspector';
@@ -34,6 +34,12 @@ const GENERIC_EXCEPTION = 'generic';
3434
const NULL_EXCEPTION_MESSAGE = 'exception was null or undefined';
3535
const MISSING_MESSAGE = 'exception had no message';
3636

37+
// Timeout for client initialization. The telemetry SDK doesn't require that the client be initialized, but it does
38+
// require that the context processing that happens during initialization complete. This is some subset of the total
39+
// initialization time, but we don't care if initialization actually completes within the, just that the context
40+
// is available for event sending.
41+
const INITIALIZATION_TIMEOUT = 5;
42+
3743
/**
3844
* Given a flag value ensure it is safe for analytics.
3945
*
@@ -83,6 +89,10 @@ function isLDClientLogging(client: unknown): client is LDClientLogging {
8389
return (client as any).logger !== undefined;
8490
}
8591

92+
function isLDClientInitialization(client: unknown): client is LDClientInitialization {
93+
return (client as any).waitForInitialization !== undefined;
94+
}
95+
8696
export default class BrowserTelemetryImpl implements BrowserTelemetry {
8797
private _maxPendingEvents: number;
8898
private _maxBreadcrumbs: number;
@@ -98,6 +108,10 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
98108

99109
private _logger: MinLogger;
100110

111+
private _registrationComplete: boolean = false;
112+
113+
// Used to ensure we only log the event dropped message once.
114+
private _clientRegistered: boolean = false;
101115
// Used to ensure we only log the event dropped message once.
102116
private _eventsDropped: boolean = false;
103117
// Used to ensure we only log the breadcrumb filter error once.
@@ -159,17 +173,45 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
159173
}
160174

161175
register(client: LDClientTracking): void {
176+
if (this._client !== undefined) {
177+
return;
178+
}
179+
162180
this._client = client;
181+
163182
// When the client is registered, we need to set the logger again, because we may be able to use the client's
164183
// logger.
165184
this._setLogger();
166185

167-
this._client.track(SESSION_INIT_KEY, { sessionId: this._sessionId });
186+
const completeRegistration = () => {
187+
this._client?.track(SESSION_INIT_KEY, { sessionId: this._sessionId });
168188

169-
this._pendingEvents.forEach((event) => {
170-
this._client?.track(event.type, event.data);
171-
});
172-
this._pendingEvents = [];
189+
this._pendingEvents.forEach((event) => {
190+
this._client?.track(event.type, event.data);
191+
});
192+
this._pendingEvents = [];
193+
this._registrationComplete = true;
194+
};
195+
196+
if (isLDClientInitialization(client)) {
197+
// We don't actually need the client initialization to complete, but we do need the context processing that
198+
// happens during initialization to complete. This time will be some time greater than that, but we don't
199+
// care if initialization actually completes within the timeout.
200+
201+
// An immediately invoked async function is used to ensure that the registration method can be called synchronously.
202+
// Making the `register` method async would increase the complexity for application developers.
203+
(async () => {
204+
try {
205+
await client.waitForInitialization(INITIALIZATION_TIMEOUT);
206+
} catch {
207+
// We don't care if the initialization fails.
208+
}
209+
completeRegistration();
210+
})();
211+
} else {
212+
// TODO(EMSR-36): Figure out how to handle the 4.x implementation.
213+
completeRegistration();
214+
}
173215
}
174216

175217
private _setLogger() {
@@ -207,7 +249,9 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
207249
return;
208250
}
209251

210-
if (this._client === undefined) {
252+
if (this._registrationComplete) {
253+
this._client?.track(type, filteredEvent);
254+
} else {
211255
this._pendingEvents.push({ type, data: filteredEvent });
212256
if (this._pendingEvents.length > this._maxPendingEvents) {
213257
if (!this._eventsDropped) {
@@ -221,7 +265,6 @@ export default class BrowserTelemetryImpl implements BrowserTelemetry {
221265
this._pendingEvents.shift();
222266
}
223267
}
224-
this._client?.track(type, filteredEvent);
225268
}
226269

227270
captureError(exception: Error): void {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Minimal client interface which allows waiting for initialization.
3+
*/
4+
export interface LDClientInitialization {
5+
waitForInitialization(timeout?: number): Promise<void>;
6+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './LDClientTracking';
22
export * from './LDClientLogging';
33
export * from './BrowserTelemetryInspector';
4+
export * from './LDClientInitialization';

0 commit comments

Comments
 (0)