Skip to content

Commit 7906a0d

Browse files
authored
Merge pull request #156 from ninetailed-inc/NT-2429-implement-extended-view-event-tracking-in-experience-js-sdk
feat(tracking): Add view duration tracking
2 parents 8b3130d + 8ceb86b commit 7906a0d

13 files changed

Lines changed: 618 additions & 92 deletions

File tree

packages/plugins/analytics/src/lib/ElementPayload.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ const BaseElementInteractionPayloadSchema = BaseElementPayloadSchema.extend({
4848
export const ElementSeenPayloadSchema =
4949
BaseElementInteractionPayloadSchema.extend({
5050
seenFor: z.number().optional().default(0),
51+
viewDurationMs: z.number().optional(),
52+
viewId: z.string().optional(),
5153
});
5254

5355
export type ElementSeenPayload = Omit<
@@ -65,7 +67,7 @@ export type ElementClickedPayload = Omit<
6567
export const ElementHoveredPayloadSchema =
6668
BaseElementInteractionPayloadSchema.extend({
6769
hoverDurationMs: z.number(),
68-
componentHoverId: z.string(),
70+
hoverId: z.string(),
6971
});
7072

7173
export type ElementHoveredPayload = Omit<

packages/plugins/insights/src/lib/NinetailedInsightsPlugin.spec.ts

Lines changed: 174 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ describe('NinetailedInsightsPlugin', () => {
5353
});
5454
intersect(element, true);
5555
}
56-
jest.runAllTimers();
56+
jest.advanceTimersByTime(2_100);
5757
jest.useRealTimers();
5858
await waitFor(() => {
5959
expect(insightsApiClientSendEventBatchesMock).toHaveBeenCalledTimes(1);
@@ -93,7 +93,7 @@ describe('NinetailedInsightsPlugin', () => {
9393
});
9494
intersect(element, true);
9595
}
96-
jest.runAllTimers();
96+
jest.advanceTimersByTime(2_100);
9797
jest.useRealTimers();
9898
await waitFor(() => {
9999
expect(insightsApiClientSendEventBatchesMock).not.toHaveBeenCalled();
@@ -115,7 +115,7 @@ describe('NinetailedInsightsPlugin', () => {
115115
variantIndex: 0,
116116
});
117117
intersect(element, true);
118-
jest.runAllTimers();
118+
jest.advanceTimersByTime(2_100);
119119
jest.useRealTimers();
120120
expect(insightsApiClientSendEventBatchesMock).toHaveBeenCalledTimes(0);
121121
await ninetailed.identify('test-2');
@@ -185,12 +185,59 @@ describe('NinetailedInsightsPlugin', () => {
185185
});
186186
}
187187
intersect(element, true);
188-
jest.runAllTimers();
188+
189+
// Run a bounded number of heartbeats to avoid an unbounded timer loop.
190+
for (let i = 0; i < 25; i++) {
191+
jest.runOnlyPendingTimers();
192+
}
193+
intersect(element, false);
194+
jest.runOnlyPendingTimers();
189195
jest.useRealTimers();
190196
await waitFor(() => {
191197
expect(insightsApiClientSendEventBatchesMock).toHaveBeenCalledTimes(1);
192198
});
193199
});
200+
it('should dedupe onHasSeenElement by viewId even when payloads differ', async () => {
201+
const insightsPlugin = new NinetailedInsightsPlugin();
202+
const dispatchMock = jest.fn();
203+
await insightsPlugin.initialize({
204+
instance: { dispatch: dispatchMock } as unknown as never,
205+
});
206+
207+
const basePayload = {
208+
meta: { rid: 'rid-1', ts: new Date().toISOString() },
209+
_: { originalAction: 'has_seen_element' },
210+
element: document.createElement('div'),
211+
variant: { id: 'variant-id-1' },
212+
variantIndex: 0,
213+
seenFor: 2000,
214+
viewId: 'view-id-1',
215+
viewDurationMs: 2000,
216+
};
217+
218+
insightsPlugin.onHasSeenElement({
219+
payload: basePayload,
220+
instance: {} as never,
221+
abort: jest.fn(),
222+
});
223+
224+
insightsPlugin.onHasSeenElement({
225+
payload: {
226+
...basePayload,
227+
variant: { id: 'variant-id-2' },
228+
variantIndex: 1,
229+
},
230+
instance: {} as never,
231+
abort: jest.fn(),
232+
});
233+
234+
expect(dispatchMock).toHaveBeenCalledTimes(1);
235+
expect(dispatchMock).toHaveBeenCalledWith(
236+
expect.objectContaining({
237+
viewId: 'view-id-1',
238+
})
239+
);
240+
});
194241
it('should not track the same element with the same payload', async () => {
195242
const insightsPlugin = new NinetailedInsightsPlugin();
196243
const ninetailed = setupNinetailedInstance([insightsPlugin]);
@@ -209,7 +256,7 @@ describe('NinetailedInsightsPlugin', () => {
209256
});
210257
}
211258
intersect(element, true);
212-
jest.runAllTimers();
259+
jest.advanceTimersByTime(2_100);
213260
jest.useRealTimers();
214261
await waitFor(() => {
215262
expect(insightsApiClientSendEventBatchesMock).toHaveBeenCalledTimes(0);
@@ -236,13 +283,127 @@ describe('NinetailedInsightsPlugin', () => {
236283
// Intersect twice to simulate multiple views
237284
intersect(element, true);
238285
intersect(element, true);
239-
jest.runAllTimers();
286+
jest.advanceTimersByTime(2_100);
240287
jest.useRealTimers();
241288
await waitFor(() => {
242289
// Should not flush because only one unique event
243290
expect(insightsApiClientSendEventBatchesMock).toHaveBeenCalledTimes(0);
244291
});
245292
});
293+
it('should generate a new viewId and reset viewDurationMs across re-entries', async () => {
294+
const insightsPlugin = new NinetailedInsightsPlugin();
295+
const ninetailed = setupNinetailedInstance([insightsPlugin]);
296+
insightsPlugin.setCredentials({
297+
clientId: 'test',
298+
environment: 'development',
299+
});
300+
await ninetailed.identify('test');
301+
jest.useFakeTimers();
302+
const element = document.body.appendChild(document.createElement('div'));
303+
ninetailed.observeElement({
304+
element,
305+
variant: { id: 'variant-id-1' },
306+
variantIndex: 0,
307+
});
308+
309+
intersect(element, true);
310+
jest.advanceTimersByTime(2_100);
311+
intersect(element, false);
312+
jest.advanceTimersByTime(250);
313+
intersect(element, true);
314+
jest.advanceTimersByTime(2_100);
315+
intersect(element, false);
316+
jest.useRealTimers();
317+
318+
await waitFor(() => {
319+
const pluginEvents = (
320+
insightsPlugin as unknown as { events: Record<string, unknown>[] }
321+
).events;
322+
const componentEvents = pluginEvents.filter(
323+
(event: { type?: string }) => event.type === 'component'
324+
);
325+
326+
expect(componentEvents.length).toBeGreaterThan(1);
327+
});
328+
329+
const pluginEvents = (
330+
insightsPlugin as unknown as { events: Record<string, unknown>[] }
331+
).events;
332+
const componentEvents = pluginEvents.filter(
333+
(event: { type?: string }) => event.type === 'component'
334+
);
335+
const viewIds = new Set(
336+
componentEvents.map((event) => event['viewId'] as string)
337+
);
338+
const durations = componentEvents.map(
339+
(event) => event['viewDurationMs'] as number
340+
);
341+
342+
expect(viewIds.size).toBe(2);
343+
expect(Math.max(...durations)).toBeLessThan(4_000);
344+
});
345+
it('should flush component view heartbeats with sendBeacon on page hide', async () => {
346+
const insightsPlugin = new NinetailedInsightsPlugin();
347+
const ninetailed = setupNinetailedInstance([insightsPlugin]);
348+
insightsPlugin.setCredentials({
349+
clientId: 'test',
350+
environment: 'development',
351+
});
352+
await ninetailed.identify('test');
353+
jest.useFakeTimers();
354+
const element = document.body.appendChild(document.createElement('div'));
355+
ninetailed.observeElement({
356+
element,
357+
variant: { id: 'variant-id-1' },
358+
variantIndex: 0,
359+
});
360+
361+
intersect(element, true);
362+
jest.advanceTimersByTime(2_100);
363+
Object.defineProperty(document, 'visibilityState', {
364+
configurable: true,
365+
value: 'hidden',
366+
});
367+
document.dispatchEvent(new Event('visibilitychange'));
368+
jest.useRealTimers();
369+
370+
await waitFor(() => {
371+
const beaconCalls = insightsApiClientSendEventBatchesMock.mock.calls
372+
.filter(([, options]) => options?.useBeacon === true)
373+
.filter(
374+
([batches]) =>
375+
(batches as { events: Record<string, unknown>[] }[]).length > 0
376+
);
377+
378+
expect(beaconCalls.length).toBeGreaterThan(0);
379+
});
380+
381+
const beaconCalls = insightsApiClientSendEventBatchesMock.mock.calls.filter(
382+
([, options]) => options?.useBeacon === true
383+
);
384+
const lastBeaconCall = beaconCalls[beaconCalls.length - 1];
385+
386+
expect(lastBeaconCall[1]).toEqual(
387+
expect.objectContaining({
388+
useBeacon: true,
389+
})
390+
);
391+
expect(
392+
(lastBeaconCall[0][0] as { events: Record<string, unknown>[] }).events
393+
).toEqual(
394+
expect.arrayContaining([
395+
expect.objectContaining({
396+
type: 'component',
397+
viewId: expect.any(String),
398+
viewDurationMs: expect.any(Number),
399+
}),
400+
])
401+
);
402+
Object.defineProperty(document, 'visibilityState', {
403+
configurable: true,
404+
value: 'visible',
405+
});
406+
});
246407
it('should send component click events once the queue can be flushed', async () => {
247408
const insightsPlugin = new NinetailedInsightsPlugin();
248409
const ninetailed = setupNinetailedInstance([insightsPlugin]);
@@ -360,13 +521,13 @@ describe('NinetailedInsightsPlugin', () => {
360521
componentId: expect.any(String),
361522
variantIndex: expect.any(Number),
362523
hoverDurationMs: expect.any(Number),
363-
componentHoverId: expect.any(String),
524+
hoverId: expect.any(String),
364525
})
365526
);
366527
});
367528
});
368529

369-
it('should generate a unique componentHoverId for each hover interaction', async () => {
530+
it('should generate a unique hoverId for each hover interaction', async () => {
370531
const element = document.body.appendChild(document.createElement('div'));
371532
ninetailed.observeElement(
372533
{
@@ -402,12 +563,10 @@ describe('NinetailedInsightsPlugin', () => {
402563
);
403564

404565
expect(hoverEvents.length).toBeGreaterThan(2);
405-
const uniqueComponentHoverIds = new Set(
406-
hoverEvents.map(
407-
(hoverEvent) => hoverEvent['componentHoverId'] as string
408-
)
566+
const uniqueHoverIds = new Set(
567+
hoverEvents.map((hoverEvent) => hoverEvent['hoverId'] as string)
409568
);
410-
expect(uniqueComponentHoverIds.size).toBe(2);
569+
expect(uniqueHoverIds.size).toBe(2);
411570
});
412571

413572
it('should map component hover events to the correct component metadata when multiple elements are observed', async () => {
@@ -467,7 +626,7 @@ describe('NinetailedInsightsPlugin', () => {
467626
type: 'component_hover',
468627
componentId: expect.any(String),
469628
hoverDurationMs: expect.any(Number),
470-
componentHoverId: expect.any(String),
629+
hoverId: expect.any(String),
471630
};
472631
expect(hoverEvents).toEqual(
473632
expect.arrayContaining([
@@ -568,7 +727,7 @@ describe('NinetailedInsightsPlugin', () => {
568727
expect.objectContaining({
569728
type: 'component_hover',
570729
hoverDurationMs: expect.any(Number),
571-
componentHoverId: expect.any(String),
730+
hoverId: expect.any(String),
572731
}),
573732
])
574733
);

0 commit comments

Comments
 (0)