Skip to content

Commit 5e26703

Browse files
Merge pull request #33323 from storybookjs/valentin/fix-telemetry-last-event
Telemetry: Fix race condition in telemetry cache causing malformed JSON
2 parents 69ddce7 + bb01d69 commit 5e26703

File tree

4 files changed

+181
-70
lines changed

4 files changed

+181
-70
lines changed

code/core/src/core-server/server-channel/preview-initialized-channel.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import type { Channel } from 'storybook/internal/channels';
22
import { PREVIEW_INITIALIZED } from 'storybook/internal/core-events';
33
import { type InitPayload, telemetry } from 'storybook/internal/telemetry';
4+
import { type CacheEntry, getLastEvents } from 'storybook/internal/telemetry';
5+
import { getSessionId } from 'storybook/internal/telemetry';
46
import type { CoreConfig, Options } from 'storybook/internal/types';
57

6-
import { type CacheEntry, getLastEvents } from '../../telemetry/event-cache';
7-
import { getSessionId } from '../../telemetry/session-id';
8-
98
export const makePayload = (
109
userAgent: string,
1110
lastInit: CacheEntry | undefined,

code/core/src/telemetry/event-cache.test.ts

Lines changed: 158 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
33

44
import { cache } from 'storybook/internal/common';
55

6-
import { get, getLastEvents, getPrecedingUpgrade, set } from './event-cache';
6+
import type { CacheEntry } from './event-cache';
7+
import { getLastEvents, getPrecedingUpgrade, set } from './event-cache';
8+
import type { TelemetryEvent } from './types';
79

810
vi.mock('storybook/internal/common', { spy: true });
911

@@ -12,54 +14,94 @@ expect.addSnapshotSerializer({
1214
test: (val) => typeof val !== 'string',
1315
});
1416

17+
// Helper to create valid TelemetryEvent objects
18+
const createTelemetryEvent = (
19+
eventType: TelemetryEvent['eventType'],
20+
eventId: string,
21+
overrides?: Partial<TelemetryEvent>
22+
): TelemetryEvent => ({
23+
eventType,
24+
eventId,
25+
sessionId: 'test-session',
26+
context: {},
27+
payload: {},
28+
...overrides,
29+
});
30+
1531
describe('event-cache', () => {
16-
const init = { body: { eventType: 'init', eventId: 'init' }, timestamp: 1 };
17-
const upgrade = { body: { eventType: 'upgrade', eventId: 'upgrade' }, timestamp: 2 };
18-
const dev = { body: { eventType: 'dev', eventId: 'dev' }, timestamp: 3 };
19-
const build = { body: { eventType: 'build', eventId: 'build' }, timestamp: 3 };
20-
const error = { body: { eventType: 'build', eventId: 'error' }, timestamp: 4 };
21-
const versionUpdate = {
22-
body: { eventType: 'version-update', eventId: 'version-update' },
32+
const init: CacheEntry = {
33+
body: createTelemetryEvent('init', 'init'),
34+
timestamp: 1,
35+
};
36+
const upgrade: CacheEntry = {
37+
body: createTelemetryEvent('upgrade', 'upgrade'),
38+
timestamp: 2,
39+
};
40+
const dev: CacheEntry = {
41+
body: createTelemetryEvent('dev', 'dev'),
42+
timestamp: 3,
43+
};
44+
const build: CacheEntry = {
45+
body: createTelemetryEvent('build', 'build'),
46+
timestamp: 3,
47+
};
48+
const error: CacheEntry = {
49+
body: createTelemetryEvent('build', 'error'),
50+
timestamp: 4,
51+
};
52+
const versionUpdate: CacheEntry = {
53+
body: createTelemetryEvent('version-update', 'version-update'),
2354
timestamp: 5,
2455
};
2556

2657
describe('data handling', () => {
2758
it('errors', async () => {
2859
const preceding = await getPrecedingUpgrade({
29-
init: { timestamp: 1, body: { ...init.body, error: {} } },
60+
init: {
61+
timestamp: 1,
62+
body: { ...init.body, error: {} } as TelemetryEvent & { error: unknown },
63+
},
3064
});
3165
expect(preceding).toMatchInlineSnapshot(`
3266
{
3367
"timestamp": 1,
3468
"eventType": "init",
35-
"eventId": "init"
69+
"eventId": "init",
70+
"sessionId": "test-session"
3671
}
3772
`);
3873
});
3974

4075
it('session IDs', async () => {
4176
const preceding = await getPrecedingUpgrade({
42-
init: { timestamp: 1, body: { ...init.body, sessionId: 100 } },
77+
init: {
78+
timestamp: 1,
79+
body: { ...init.body, sessionId: '100' },
80+
},
4381
});
4482
expect(preceding).toMatchInlineSnapshot(`
4583
{
4684
"timestamp": 1,
4785
"eventType": "init",
4886
"eventId": "init",
49-
"sessionId": 100
87+
"sessionId": "100"
5088
}
5189
`);
5290
});
5391

5492
it('extra fields', async () => {
5593
const preceding = await getPrecedingUpgrade({
56-
init: { timestamp: 1, body: { ...init.body, foobar: 'baz' } },
94+
init: {
95+
timestamp: 1,
96+
body: { ...init.body, foobar: 'baz' } as TelemetryEvent & { foobar: string },
97+
},
5798
});
5899
expect(preceding).toMatchInlineSnapshot(`
59100
{
60101
"timestamp": 1,
61102
"eventType": "init",
62-
"eventId": "init"
103+
"eventId": "init",
104+
"sessionId": "test-session"
63105
}
64106
`);
65107
});
@@ -77,7 +119,8 @@ describe('event-cache', () => {
77119
{
78120
"timestamp": 1,
79121
"eventType": "init",
80-
"eventId": "init"
122+
"eventId": "init",
123+
"sessionId": "test-session"
81124
}
82125
`);
83126
});
@@ -88,7 +131,8 @@ describe('event-cache', () => {
88131
{
89132
"timestamp": 2,
90133
"eventType": "upgrade",
91-
"eventId": "upgrade"
134+
"eventId": "upgrade",
135+
"sessionId": "test-session"
92136
}
93137
`);
94138
});
@@ -99,7 +143,8 @@ describe('event-cache', () => {
99143
{
100144
"timestamp": 2,
101145
"eventType": "upgrade",
102-
"eventId": "upgrade"
146+
"eventId": "upgrade",
147+
"sessionId": "test-session"
103148
}
104149
`);
105150
});
@@ -127,31 +172,33 @@ describe('event-cache', () => {
127172
});
128173

129174
it('both init and upgrade with intervening dev', async () => {
130-
const secondUpgrade = {
131-
body: { eventType: 'upgrade', eventId: 'secondUpgrade' },
175+
const secondUpgrade: CacheEntry = {
176+
body: createTelemetryEvent('upgrade', 'secondUpgrade'),
132177
timestamp: 4,
133178
};
134179
const preceding = await getPrecedingUpgrade({ init, dev, upgrade: secondUpgrade });
135180
expect(preceding).toMatchInlineSnapshot(`
136181
{
137182
"timestamp": 4,
138183
"eventType": "upgrade",
139-
"eventId": "secondUpgrade"
184+
"eventId": "secondUpgrade",
185+
"sessionId": "test-session"
140186
}
141187
`);
142188
});
143189

144190
it('both init and upgrade with non-intervening dev', async () => {
145-
const earlyDev = {
146-
body: { eventType: 'dev', eventId: 'earlyDev' },
191+
const earlyDev: CacheEntry = {
192+
body: createTelemetryEvent('dev', 'earlyDev'),
147193
timestamp: -1,
148194
};
149195
const preceding = await getPrecedingUpgrade({ dev: earlyDev, init, upgrade });
150196
expect(preceding).toMatchInlineSnapshot(`
151197
{
152198
"timestamp": 2,
153199
"eventType": "upgrade",
154-
"eventId": "upgrade"
200+
"eventId": "upgrade",
201+
"sessionId": "test-session"
155202
}
156203
`);
157204
});
@@ -174,7 +221,8 @@ describe('event-cache', () => {
174221
{
175222
"timestamp": 2,
176223
"eventType": "upgrade",
177-
"eventId": "upgrade"
224+
"eventId": "upgrade",
225+
"sessionId": "test-session"
178226
}
179227
`);
180228
});
@@ -192,46 +240,28 @@ describe('event-cache', () => {
192240

193241
it('getLastEvents waits for pending set operations to complete', async () => {
194242
const initialData = {
195-
init: { timestamp: 1, body: { eventType: 'init', eventId: 'init-1' } },
243+
init: { timestamp: 1, body: createTelemetryEvent('init', 'init-1') },
196244
};
197245
const updatedData = {
198-
init: { timestamp: 1, body: { eventType: 'init', eventId: 'init-1' } },
199-
upgrade: { timestamp: 2, body: { eventType: 'upgrade', eventId: 'upgrade-1' } },
246+
init: { timestamp: 1, body: createTelemetryEvent('init', 'init-1') },
247+
upgrade: { timestamp: 2, body: createTelemetryEvent('upgrade', 'upgrade-1') },
200248
};
201249

202-
// Use a simple delay to simulate async operations
203-
let setGetResolved = false;
204-
let setSetResolved = false;
250+
// Mock cache.get to return initial data first, then updated data
251+
cacheGetMock
252+
.mockResolvedValueOnce(initialData) // First call in setHelper
253+
.mockResolvedValueOnce(updatedData); // Second call in getLastEvents
205254

206-
cacheGetMock.mockImplementationOnce(async () => {
207-
while (!setGetResolved) {
208-
await new Promise((resolve) => setTimeout(resolve, 10));
209-
}
210-
return initialData;
211-
});
212-
213-
cacheSetMock.mockImplementationOnce(async () => {
214-
while (!setSetResolved) {
215-
await new Promise((resolve) => setTimeout(resolve, 10));
216-
}
217-
});
255+
// Mock cache.set to resolve immediately
256+
cacheSetMock.mockResolvedValue(undefined);
218257

219-
// Mock cache.get to return updated data after set completes
220-
cacheGetMock.mockResolvedValueOnce(updatedData);
221-
222-
// Start a set operation (this will be pending)
223-
const setPromiseResult = set('upgrade', { eventType: 'upgrade', eventId: 'upgrade-1' });
258+
// Start a set operation (this will be queued and processed)
259+
const setPromiseResult = set('upgrade', createTelemetryEvent('upgrade', 'upgrade-1'));
224260

225261
// Immediately call getLastEvents() - it should wait for set() to complete
226262
const getPromise = getLastEvents();
227263

228-
// Verify that getLastEvents hasn't resolved yet (it's waiting)
229-
await new Promise((resolve) => setTimeout(resolve, 50));
230-
231-
// Resolve the set operations
232-
setGetResolved = true;
233-
await new Promise((resolve) => setTimeout(resolve, 50));
234-
setSetResolved = true;
264+
// Wait for set operation to complete
235265
await setPromiseResult;
236266

237267
// Now getLastEvents should complete and return the updated data
@@ -242,5 +272,79 @@ describe('event-cache', () => {
242272
expect(cacheGetMock).toHaveBeenCalledTimes(2); // Once in setHelper, once in getLastEvents
243273
expect(cacheSetMock).toHaveBeenCalledTimes(1);
244274
});
275+
276+
it('queues multiple set operations sequentially', async () => {
277+
const initialData = {};
278+
const afterFirst = {
279+
init: { timestamp: 1, body: createTelemetryEvent('init', 'init-1') },
280+
};
281+
const afterSecond = {
282+
init: { timestamp: 1, body: createTelemetryEvent('init', 'init-1') },
283+
upgrade: { timestamp: 2, body: createTelemetryEvent('upgrade', 'upgrade-1') },
284+
};
285+
const afterThird = {
286+
init: { timestamp: 1, body: createTelemetryEvent('init', 'init-1') },
287+
upgrade: { timestamp: 2, body: createTelemetryEvent('upgrade', 'upgrade-1') },
288+
dev: { timestamp: 3, body: createTelemetryEvent('dev', 'dev-1') },
289+
};
290+
291+
// Mock cache.get to return data in sequence
292+
cacheGetMock
293+
.mockResolvedValueOnce(initialData) // First set: get initial
294+
.mockResolvedValueOnce(afterFirst) // Second set: get after first
295+
.mockResolvedValueOnce(afterSecond) // Third set: get after second
296+
.mockResolvedValueOnce(afterThird); // getLastEvents: get after third
297+
298+
// Mock cache.set to resolve immediately
299+
cacheSetMock.mockResolvedValue(undefined);
300+
301+
// Queue multiple set operations
302+
const set1 = set('init', createTelemetryEvent('init', 'init-1'));
303+
const set2 = set('upgrade', createTelemetryEvent('upgrade', 'upgrade-1'));
304+
const set3 = set('dev', createTelemetryEvent('dev', 'dev-1'));
305+
306+
// Wait for all operations to complete
307+
await Promise.all([set1, set2, set3]);
308+
309+
// Now getLastEvents should return the final state
310+
const result = await getLastEvents();
311+
312+
// Verify all operations were processed sequentially
313+
expect(result).toEqual(afterThird);
314+
expect(cacheGetMock).toHaveBeenCalledTimes(4); // 3 sets + 1 getLastEvents
315+
expect(cacheSetMock).toHaveBeenCalledTimes(3); // One for each set
316+
});
317+
318+
it('handles errors in queued operations', async () => {
319+
const initialData = {
320+
init: { timestamp: 1, body: createTelemetryEvent('init', 'init-1') },
321+
};
322+
const afterDev = {
323+
init: { timestamp: 1, body: createTelemetryEvent('init', 'init-1') },
324+
dev: { timestamp: 3, body: createTelemetryEvent('dev', 'dev-1') },
325+
};
326+
327+
// First operation will fail
328+
cacheGetMock.mockResolvedValueOnce(initialData);
329+
cacheSetMock.mockRejectedValueOnce(new Error('Cache write failed'));
330+
331+
// Queue an operation that will fail
332+
const failedOperation = set('upgrade', createTelemetryEvent('upgrade', 'upgrade-1'));
333+
await expect(failedOperation).rejects.toThrow('Cache write failed');
334+
335+
// Wait a bit to ensure queue processing completes
336+
await new Promise((resolve) => setTimeout(resolve, 10));
337+
338+
// Verify subsequent operations can still be queued and succeed
339+
cacheGetMock.mockResolvedValueOnce(initialData);
340+
cacheSetMock.mockResolvedValueOnce(undefined);
341+
cacheGetMock.mockResolvedValueOnce(afterDev);
342+
343+
await expect(set('dev', createTelemetryEvent('dev', 'dev-1'))).resolves.toBeUndefined();
344+
345+
// Verify the successful operation was processed
346+
const result = await getLastEvents();
347+
expect(result).toEqual(afterDev);
348+
});
245349
});
246350
});

0 commit comments

Comments
 (0)