diff --git a/src/format/format-event-data.test.ts b/src/format/format-event-data.test.ts index 3d82466..4ffbd97 100644 --- a/src/format/format-event-data.test.ts +++ b/src/format/format-event-data.test.ts @@ -1,5 +1,5 @@ import { it, describe, expect } from 'vitest'; -import { formatEventData } from './format-event-data.js'; +import { formatEventData, NOT_SET_MESSAGE } from './format-event-data.js'; import type { CLSAttribution, FCPAttribution, @@ -9,7 +9,7 @@ import type { } from 'web-vitals'; describe('formatEventData', () => { - it('should format CLS data correctly', () => { + it('should format CLS data', () => { const attribution = { largestShiftTime: 1000, loadState: 'complete', @@ -25,7 +25,23 @@ describe('formatEventData', () => { }); }); - it('should format FCP data correctly', () => { + it('should handle CLS attribution with no largestShiftTarget', () => { + const attribution = { + largestShiftTime: 1000, + loadState: 'complete', + largestShiftTarget: undefined, + } satisfies CLSAttribution; + + const result = formatEventData('CLS', attribution); + + expect(result).toEqual({ + debug_time: attribution.largestShiftTime, + debug_load_state: attribution.loadState, + debug_target: NOT_SET_MESSAGE, + }); + }); + + it('should format FCP data', () => { const attribution = { timeToFirstByte: 7.199999999254942, firstByteToFCP: 456.9000000022352, @@ -42,29 +58,26 @@ describe('formatEventData', () => { }); }); - it('should format LCP data correctly', () => { + it('should handle FCP attribution if loadState is not set', () => { const attribution = { - url: 'https://localhost/', - timeToFirstByte: 100, - resourceLoadDelay: 50, - resourceLoadDuration: 120, - elementRenderDelay: 10, - element: 'body>div#main', - } satisfies LCPAttribution; + timeToFirstByte: 7.199999999254942, + firstByteToFCP: 456.9000000022352, + // @ts-expect-error - deviate from the type + loadState: '', + } satisfies FCPAttribution; - const result = formatEventData('LCP', attribution); + // @ts-expect-error - deviate from the type + const result = formatEventData('FCP', attribution); expect(result).toEqual({ - debug_url: attribution.url, debug_time_to_first_byte: attribution.timeToFirstByte, - debug_resource_load_delay: attribution.resourceLoadDelay, - debug_resource_load_duration: attribution.resourceLoadDuration, - debug_element_render_delay: attribution.elementRenderDelay, - debug_target: attribution.element, + debug_first_byte_to_fcp: attribution.firstByteToFCP, + debug_load_state: attribution.loadState, + debug_target: NOT_SET_MESSAGE, }); }); - it('should format INP data correctly', () => { + it('should format INP data', () => { const attribution = { interactionTarget: 'html>body', interactionTargetElement: undefined, @@ -92,7 +105,79 @@ describe('formatEventData', () => { }); }); - it('should format TTFB data correctly', () => { + it('should handle INP data if interactionTarget is not set', () => { + const attribution = { + interactionTarget: '', + interactionTargetElement: undefined, + interactionType: 'keyboard', + interactionTime: 235.5, + nextPaintTime: 435.5, + processedEventEntries: [], + longAnimationFrameEntries: [], + inputDelay: 0.5, + processingDuration: 93, + presentationDelay: 106.5, + loadState: 'dom-interactive', + } satisfies INPAttribution; + + const result = formatEventData('INP', attribution); + + expect(result).toEqual({ + debug_event: attribution.interactionType, + debug_time: Math.round(attribution.interactionTime), + debug_load_state: attribution.loadState, + debug_target: NOT_SET_MESSAGE, + debug_interaction_delay: Math.round(attribution.inputDelay), + debug_processing_duration: Math.round(attribution.processingDuration), + debug_presentation_delay: Math.round(attribution.presentationDelay), + }); + }); + + it('should format LCP data', () => { + const attribution = { + url: 'https://localhost/', + timeToFirstByte: 100, + resourceLoadDelay: 50, + resourceLoadDuration: 120, + elementRenderDelay: 10, + element: 'body>div#main', + } satisfies LCPAttribution; + + const result = formatEventData('LCP', attribution); + + expect(result).toEqual({ + debug_url: attribution.url, + debug_time_to_first_byte: attribution.timeToFirstByte, + debug_resource_load_delay: attribution.resourceLoadDelay, + debug_resource_load_duration: attribution.resourceLoadDuration, + debug_element_render_delay: attribution.elementRenderDelay, + debug_target: attribution.element, + }); + }); + + it('should handle LCP data if the element is not set', () => { + const attribution = { + url: 'https://localhost/', + timeToFirstByte: 100, + resourceLoadDelay: 50, + resourceLoadDuration: 120, + elementRenderDelay: 10, + element: '', + } satisfies LCPAttribution; + + const result = formatEventData('LCP', attribution); + + expect(result).toEqual({ + debug_url: attribution.url, + debug_time_to_first_byte: attribution.timeToFirstByte, + debug_resource_load_delay: attribution.resourceLoadDelay, + debug_resource_load_duration: attribution.resourceLoadDuration, + debug_element_render_delay: attribution.elementRenderDelay, + debug_target: NOT_SET_MESSAGE, + }); + }); + + it('should format TTFB data', () => { const attribution = { waitingDuration: 0, cacheDuration: 0, @@ -112,9 +197,26 @@ describe('formatEventData', () => { }); }); + it('should ignore the deprecated FID event', () => { + const attribution = { + waitingDuration: 0, + cacheDuration: 0, + dnsDuration: 0, + connectionDuration: 2015, + requestDuration: 47, + } as TTFBAttribution; + + // @ts-expect-error - FID is deprecated + const result = formatEventData('FID', attribution); + + expect(result).toEqual({ + debug_target: NOT_SET_MESSAGE, + }); + }); + it('should return default/empty params if no attribution data is provided', () => { - // @ts-expect-error - unknown is not valid - const result = formatEventData('unknown', null); + // @ts-expect-error - null is not valid for arg2 + const result = formatEventData('TTFB', null); expect(result).toEqual({ debug_target: '(not set)', diff --git a/src/format/format-event-data.ts b/src/format/format-event-data.ts index f8f7c47..5f8ea41 100644 --- a/src/format/format-event-data.ts +++ b/src/format/format-event-data.ts @@ -18,78 +18,69 @@ export type WebVitalsAttribution = | LCPAttribution | TTFBAttribution; +export const NOT_SET_MESSAGE = '(not set)'; + +const formatCLS = (attribution: CLSAttribution) => ({ + debug_time: attribution.largestShiftTime, + debug_load_state: attribution.loadState, + debug_target: attribution.largestShiftTarget || NOT_SET_MESSAGE, +}); + +const formatFCP = (attribution: FCPAttribution) => ({ + debug_time_to_first_byte: attribution.timeToFirstByte, + debug_first_byte_to_fcp: attribution.firstByteToFCP, + debug_load_state: attribution.loadState, + debug_target: attribution.loadState || NOT_SET_MESSAGE, +}); + +const formatINP = (attribution: DAPINPAttribution) => ({ + debug_event: attribution.interactionType, + debug_time: Math.round(attribution.interactionTime), + debug_load_state: attribution.loadState, + debug_target: attribution.interactionTarget || NOT_SET_MESSAGE, + debug_interaction_delay: Math.round(attribution.inputDelay), + debug_processing_duration: Math.round(attribution.processingDuration), + debug_presentation_delay: Math.round(attribution.presentationDelay), + ...formatLongAnimationFrameData(attribution), +}); + +const formatLCP = (attribution: LCPAttribution) => ({ + debug_url: attribution.url, + debug_time_to_first_byte: attribution.timeToFirstByte, + debug_resource_load_delay: attribution.resourceLoadDelay, + debug_resource_load_duration: attribution.resourceLoadDuration, + debug_element_render_delay: attribution.elementRenderDelay, + debug_target: attribution.element || NOT_SET_MESSAGE, +}); + +const formatTTFB = (attribution: TTFBAttribution) => ({ + debug_waiting_duration: attribution.waitingDuration, + debug_dns_duration: attribution.dnsDuration, + debug_connection_duration: attribution.connectionDuration, + debug_cache_duration: attribution.cacheDuration, + debug_request_duration: attribution.requestDuration, +}); + export const formatEventData = ( name: WebVitalsName, attribution: WebVitalsAttribution, ) => { - // In some cases there won't be any entries (e.g. if CLS is 0, - // or for LCP after a bfcache restore), so we have to check first. - if (attribution) { - if (name === 'CLS') { - return { - debug_time: (attribution as CLSAttribution).largestShiftTime, - debug_load_state: (attribution as CLSAttribution).loadState, - debug_target: - (attribution as CLSAttribution).largestShiftTarget || '(not set)', - }; - } - if (name === 'FCP') { - return { - debug_time_to_first_byte: (attribution as FCPAttribution) - .timeToFirstByte, - debug_first_byte_to_fcp: (attribution as FCPAttribution).firstByteToFCP, - debug_load_state: (attribution as FCPAttribution).loadState, - debug_target: (attribution as FCPAttribution).loadState || '(not set)', - }; - } - if (name === 'INP') { - return { - debug_event: (attribution as INPAttribution).interactionType, - debug_time: Math.round((attribution as INPAttribution).interactionTime), - debug_load_state: (attribution as INPAttribution).loadState, - debug_target: - (attribution as INPAttribution).interactionTarget || '(not set)', - debug_interaction_delay: Math.round( - (attribution as INPAttribution).inputDelay, - ), - debug_processing_duration: Math.round( - (attribution as INPAttribution).processingDuration, - ), - debug_presentation_delay: Math.round( - (attribution as INPAttribution).presentationDelay, - ), - ...formatLongAnimationFrameData(attribution as DAPINPAttribution), - }; - } - if (name === 'LCP') { - return { - debug_url: (attribution as LCPAttribution).url, - debug_time_to_first_byte: (attribution as LCPAttribution) - .timeToFirstByte, - debug_resource_load_delay: (attribution as LCPAttribution) - .resourceLoadDelay, - debug_resource_load_duration: (attribution as LCPAttribution) - .resourceLoadDuration, - debug_element_render_delay: (attribution as LCPAttribution) - .elementRenderDelay, - debug_target: (attribution as LCPAttribution).element || '(not set)', - }; - } - if (name === 'TTFB') { - return { - debug_waiting_duration: (attribution as TTFBAttribution) - .waitingDuration, - debug_dns_duration: (attribution as TTFBAttribution).dnsDuration, - debug_connection_duration: (attribution as TTFBAttribution) - .connectionDuration, - debug_cache_duration: (attribution as TTFBAttribution).cacheDuration, - debug_request_duration: (attribution as TTFBAttribution) - .requestDuration, - }; - } + if (!attribution) { + return { debug_target: NOT_SET_MESSAGE }; + } + + switch (name) { + case 'CLS': + return formatCLS(attribution as CLSAttribution); + case 'FCP': + return formatFCP(attribution as FCPAttribution); + case 'INP': + return formatINP(attribution as DAPINPAttribution); + case 'LCP': + return formatLCP(attribution as LCPAttribution); + case 'TTFB': + return formatTTFB(attribution as TTFBAttribution); + default: + return { debug_target: NOT_SET_MESSAGE }; } - // Return default/empty params in case there is no attribution. - return { - debug_target: '(not set)', - }; }; diff --git a/src/format/format-long-animation-frame-data.test.ts b/src/format/format-long-animation-frame-data.test.ts index 4144e98..32b0251 100644 --- a/src/format/format-long-animation-frame-data.test.ts +++ b/src/format/format-long-animation-frame-data.test.ts @@ -173,6 +173,51 @@ describe('formatLongAnimationFrameData', () => { expect(result).toEqual({}); }); + + it('should format INP data correctly if there is falsy data', () => { + const attribution = { + longAnimationFrameEntries: [ + { + name: 'long-animation-frame', + entryType: 'long-animation-frame', + startTime: 5168847.600000024, + duration: 90, + renderStart: '', + styleAndLayoutStart: '', + firstUIEventTimestamp: 5168847, + blockingDuration: 38, + scripts: [ + { + name: 'script', + entryType: 'script', + startTime: 5168847.899999976, + duration: 85, + invoker: 'BUTTON#thrash-layout.onclick', + invokerType: 'event-listener', + windowAttribution: 'self', + executionStart: 5168847.899999976, + forcedStyleAndLayoutDuration: 80, + pauseDuration: 0, + sourceURL: 'http://localhost:8000/demo/', + sourceFunctionName: '', + sourceCharPosition: 72, + }, + ], + }, + ], + }; + + const { + debug_loaf_entry_render_duration, + debug_loaf_entry_style_and_layout_duration, + debug_loaf_entry_work_duration, + // @ts-expect-error - the object is pared down for testing. It won't be compliant with all props for the type + } = formatLongAnimationFrameData(attribution); + + expect(debug_loaf_entry_render_duration).toEqual(0); + expect(debug_loaf_entry_style_and_layout_duration).toEqual(0); + expect(debug_loaf_entry_work_duration).toEqual(90); + }); }); /** diff --git a/src/index.ts b/src/index.ts index 1499302..44b2e69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,12 +9,19 @@ const initWebVitalsEvents = () => { onTTFB(sendToAnalytics); }; -if (typeof window !== 'undefined' && 'gas4' in window) { - initWebVitalsEvents(); -} else { - window.addEventListener('dap-universal-federated-analytics-load', () => { +if (typeof window !== 'undefined') { + if ('gas4' in window) { initWebVitalsEvents(); - }); -} + } else { + (window as Window).addEventListener( + 'dap-universal-federated-analytics-load', + () => { + initWebVitalsEvents(); + }, + ); + } -performance.mark('dap-performance-addon-loaded'); + if ('performance' in window) { + performance.mark('dap-performance-addon-loaded'); + } +} diff --git a/src/track/send-to-analytics.ts b/src/track/send-to-analytics.ts index 0836904..b688439 100644 --- a/src/track/send-to-analytics.ts +++ b/src/track/send-to-analytics.ts @@ -40,7 +40,7 @@ export const sendToAnalytics = ({ metric_value: value, // Value for querying in BQ metric_delta: delta, // Delta for querying in BQ metric_navigation_type: navigationType, - // Send the returned values from getDebugInfo() as custom parameters + // Send the returned values from formatEventData() as custom parameters ...formatEventData(name as WebVitalsName, attribution), }); } diff --git a/src/index.test.ts b/src/web-vitals.test.ts similarity index 100% rename from src/index.test.ts rename to src/web-vitals.test.ts diff --git a/vitest.config.ts b/vitest.config.ts index 3364c1c..5448b6a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,9 @@ export default defineConfig({ include: ['src/**/*.test.ts'], globals: true, environment: 'jsdom', + coverage: { + include: ['src/**/*'], + }, }, resolve: { alias: {