UNPKG

@zendesk/react-measure-timing-hooks

Version:

react hooks for measuring time to interactive and time to render of components

767 lines (697 loc) 26.8 kB
import './testUtility/asciiTimelineSerializer' import { afterEach, beforeEach, describe, expect, it, type Mock, vitest as jest, } from 'vitest' import * as matchSpan from './matchSpan' import { type TicketAndUserAndGlobalRelationSchemasFixture, ticketAndUserAndGlobalRelationSchemasFixture, type TicketIdRelationSchemasFixture, } from './testUtility/fixtures/relationSchemas' import { Check, getSpansFromTimeline, Render } from './testUtility/makeTimeline' import { processSpans } from './testUtility/processSpans' import { TraceManager } from './TraceManager' import type { AnyPossibleReportFn } from './types' describe('TraceManager', () => { let reportFn: Mock<AnyPossibleReportFn<TicketIdRelationSchemasFixture>> // TS doesn't like that reportFn is wrapped in Mock<> type const getReportFn = () => reportFn as AnyPossibleReportFn<TicketIdRelationSchemasFixture> let generateId: Mock let reportErrorFn: Mock const DEFAULT_COLDBOOT_TIMEOUT_DURATION = 45_000 jest.useFakeTimers({ now: 0, }) beforeEach(() => { reportFn = jest.fn() generateId = jest.fn().mockReturnValue('trace-id') reportErrorFn = jest.fn() }) afterEach(() => { jest.clearAllMocks() jest.clearAllTimers() }) it('tracks trace with minimal requirements', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'ticket.basic-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) const traceId = tracer.start({ relatedTo: { ticketId: '1' }, variant: 'cold_boot', }) expect(traceId).toBe('trace-id') // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}-----${Render('middle', 0)}-----${Render('end', 0)}---<===+2s===>----${Check} Time: ${0} ${50} ${100} ${2_100} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | start middle end timeline | |-<⋯ +50 ⋯>-|-<⋯ +50 ⋯>-| time (ms) | 0 50 100 `) expect(report.name).toBe('ticket.basic-operation') expect(report.duration).toBe(100) expect(report.status).toBe('ok') expect(report.interruptionReason).toBeUndefined() }) it('correctly calculates a computed span', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'ticket.computed-span-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) const computedSpanName = 'render-1-to-3' // Define a computed span tracer.defineComputedSpan({ name: computedSpanName, startSpan: matchSpan.withName('render-1'), endSpan: matchSpan.withName('render-3'), }) const traceId = tracer.start({ relatedTo: { ticketId: '1' }, variant: 'cold_boot', }) // Start trace expect(traceId).toBe('trace-id') // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}---${Render('render-1', 50)}----${Render('render-2', 50)}----${Render('render-3', 50)}--------${Render('end', 0)} Time: ${0} ${50} ${100} ${150} ${200} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect(report.name).toBe('ticket.computed-span-operation') expect(report.duration).toBe(200) expect(report.status).toBe('ok') expect(report.interruptionReason).toBeUndefined() expect(report.computedSpans[computedSpanName]?.startOffset).toBe(50) expect(report.computedSpans[computedSpanName]?.duration).toBe(150) expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | start render-1(50) render-2(50) render-3(50) end timeline | |-<⋯ +50 ⋯>-[++++++++++++++]-[++++++++++++++]-[++++++++++++++| time (ms) | 0 50 100 150 200 `) }) it('correctly calculates a computed value', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'ticket.computed-value-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) // Define a computed value tracer.defineComputedValue({ name: 'feature', matches: [{ name: 'feature' }], computeValueFromMatches: (feature) => feature.length, }) const traceId = tracer.start({ relatedTo: { ticketId: '1' }, variant: 'cold_boot', }) // Start trace expect(traceId).toBe('trace-id') // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}--${Render('feature', 50)}--${Render('feature', 50)}-${Render('end', 0)} Time: ${0} ${50} ${100} ${150} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect(report.name).toBe('ticket.computed-value-operation') expect(report.duration).toBe(150) expect(report.status).toBe('ok') expect(report.interruptionReason).toBeUndefined() expect(report.computedValues).toEqual({ feature: 2, }) expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | start feature(50) feature(50) end timeline | |-<⋯ +50 ⋯>-[+++++++++++++++++++++++][+++++++++++++++++++++++| time (ms) | 0 50 100 150 `) }) it('correctly calculates computedRenderBeaconSpans, adjusting the render start based on the first render-start', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'ticket.computedRenderBeaconSpans', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'Component', isIdle: true }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) const traceId = tracer.start({ relatedTo: { ticketId: '1' }, variant: 'cold_boot', }) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('Component', 0, {type: 'component-render-start'})}--${Render('Component', 50)}--${Render('Component', 50, {renderedOutput: 'content'})} Time: ${0} ${50} ${100} ` processSpans(spans, traceManager) jest.runAllTimers() expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | Component Component(50) Component(50) timeline | |-<⋯ +50 ⋯>-[+++++++++++++++++++++++][+++++++++++++++++++++++] time (ms) | 0 50 100 `) expect(report.name).toBe('ticket.computedRenderBeaconSpans') expect(report.interruptionReason).toBeUndefined() expect(report.status).toBe('ok') expect(report.duration).toBe(150) expect(report.computedRenderBeaconSpans).toEqual({ Component: { firstRenderTillContent: 150, firstRenderTillData: 100, firstRenderTillLoading: 100, renderCount: 2, startOffset: 0, sumOfRenderDurations: 150, }, }) }) it('when relatedTo is true for two relations: tracks trace with relatedTo ticketId: 4 and relatedTo userId: 3', () => { const traceManager = new TraceManager({ relationSchemas: ticketAndUserAndGlobalRelationSchemasFixture, reportFn: reportFn as unknown as AnyPossibleReportFn<TicketAndUserAndGlobalRelationSchemasFixture>, generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'ticket.relatedTo-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end', matchingRelations: true }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) const relatedTo = { ticketId: '4', userId: '3', } const traceId = tracer.start({ relatedTo, variant: 'cold_boot', }) expect(traceId).toBe('trace-id') // prettier-ignore const { spans } = getSpansFromTimeline<TicketAndUserAndGlobalRelationSchemasFixture>` Events: ${Render('start', 0)}-----${Render('middle', 0)}-----${Render('end', 0, { relatedTo })}---<===+6s===>----${Check} Time: ${0} ${50} ${100} ${6_000} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | start middle end timeline | |-<⋯ +50 ⋯>-|-<⋯ +50 ⋯>-| time (ms) | 0 50 100 `) expect(report.name).toBe('ticket.relatedTo-operation') expect(report.interruptionReason).toBeUndefined() expect(report.duration).toBe(100) expect(report.status).toBe('ok') expect(report.relatedTo).toEqual(relatedTo) }) describe('debounce', () => { it('tracks trace when debouncedOn is defined but no debounce events', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'ticket.operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], debounceOnSpans: [{ name: 'debounce' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) const traceId = tracer.start({ relatedTo: { ticketId: '1', }, variant: 'cold_boot', }) expect(traceId).toBe('trace-id') // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}-----${Render('middle', 0)}-----${Render('end', 0)}---<===+2s===>----${Check} Time: ${0} ${50} ${100} ${2_100} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() const report: Parameters< AnyPossibleReportFn<TicketIdRelationSchemasFixture> >[0] = reportFn.mock.calls[0]![0] expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | start middle end timeline | |-<⋯ +50 ⋯>-|-<⋯ +50 ⋯>-| time (ms) | 0 50 100 `) expect(report.name).toBe('ticket.operation') expect(report.duration).toBe(100) expect(report.status).toBe('ok') expect(report.interruptionReason).toBeUndefined() }) it('tracks trace correctly when debounced entries are seen', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'ticket.debounce-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [matchSpan.withName('end')], debounceOnSpans: [ matchSpan.withName((n: string) => n.endsWith('debounce')), ], debounceWindow: 300, variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) tracer.start({ relatedTo: { ticketId: '1', }, variant: 'cold_boot', }) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}-----${Render('end', 0)}-----${Render('shorter-debounce', 0)}-----${Render('short-debounce', 0)}-----${Render('long-debounce', 0)}-----${Check})} Time: ${0} ${50} ${51} ${251} ${451} ${1_051} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() const report: Parameters< AnyPossibleReportFn<TicketIdRelationSchemasFixture> >[0] = reportFn.mock.calls[0]![0] expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | shorter-debounce long-debounce events | start end short-debounce timeline | |-------------------------------------------------|-|-<⋯ +200 ⋯>--|-<⋯ +200 ⋯>-| time (ms) | 0 50 251 451 time (ms) | 51 `) expect(report.name).toBe('ticket.debounce-operation') expect(report.duration).toBe(451) // 50 + 1 + 200 + 200 expect(report.status).toBe('ok') expect(report.interruptionReason).toBeUndefined() }) }) describe('interrupts', () => { it('interrupts a basic trace when interruptOnSpans criteria is met', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'ticket.interrupt-on-basic-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [matchSpan.withName('end')], interruptOnSpans: [matchSpan.withName('interrupt')], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) tracer.start({ relatedTo: { ticketId: '1', }, variant: 'cold_boot', }) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}-----${Render('interrupt', 0)}-----${Render('end', 0)} Time: ${0} ${100} ${200} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() const report: Parameters< AnyPossibleReportFn<TicketIdRelationSchemasFixture> >[0] = reportFn.mock.calls[0]![0] expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | start timeline | | time (ms) | 0 `) expect(report.name).toBe('ticket.interrupt-on-basic-operation') expect(report.duration).toBeNull() expect(report.status).toBe('interrupted') expect(report.interruptionReason).toBe('matched-on-interrupt') }) it('interrupts itself when another trace is started', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'ticket.interrupt-itself-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], debounceOnSpans: [{ name: 'debounce' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) const traceId = tracer.start({ relatedTo: { ticketId: '1', }, variant: 'cold_boot', }) expect(traceId).toBe('trace-id') // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)} Time: ${0} ` processSpans(spans, traceManager) // Start another operation to interrupt the first one const newTraceId = tracer.start({ relatedTo: { ticketId: '1', }, variant: 'cold_boot', }) expect(newTraceId).toBe('trace-id') expect(reportFn).toHaveBeenCalled() const report: Parameters< AnyPossibleReportFn<TicketIdRelationSchemasFixture> >[0] = reportFn.mock.calls[0]![0] expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | start timeline | | time (ms) | 0 `) expect(report.name).toBe('ticket.interrupt-itself-operation') expect(report.duration).toBeNull() expect(report.status).toBe('interrupted') expect(report.interruptionReason).toBe('another-trace-started') }) it('tracks a regression: interrupts a trace when a component is no longer idle', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'ticket.interrupt-on-basic-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end', isIdle: true }], debounceOnSpans: [{ name: 'end' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) tracer.start({ relatedTo: { ticketId: '1', }, variant: 'cold_boot', }) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}-----${Render('end', 50, {isIdle: true})}-----${Render('end', 50, {isIdle: false})} Time: ${0} ${100} ${200} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() const report: Parameters< AnyPossibleReportFn<TicketIdRelationSchemasFixture> >[0] = reportFn.mock.calls[0]![0] expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | start end(50) timeline | |-<⋯ +100 ⋯>-[++++++++++++++++++++++++++++++++++++++++++++++++] time (ms) | 0 100 `) expect(report.name).toBe('ticket.interrupt-on-basic-operation') expect(report.interruptionReason).toBe('idle-component-no-longer-idle') expect(report.status).toBe('interrupted') expect(report.duration).toBeNull() }) describe('timeout', () => { it('timeouts when the basic trace when the default timeout duration is reached', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'ticket.timeout-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'timed-out-render' }], variants: { cold_boot: { timeout: 500 } }, }) const traceId = tracer.start({ startTime: { now: 0, epoch: 0 }, relatedTo: { ticketId: '1', }, variant: 'cold_boot', }) expect(traceId).toBe('trace-id') // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}------${Render('timed-out-render', 0)} Time: ${0} ${500 + 1} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() const report: Parameters< AnyPossibleReportFn<TicketIdRelationSchemasFixture> >[0] = reportFn.mock.calls[0]![0] expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | start timeline | | time (ms) | 0 `) expect(report.interruptionReason).toBe('timeout') expect(report.duration).toBeNull() expect(report.name).toBe('ticket.timeout-operation') expect(report.status).toBe('interrupted') expect(report.interruptionReason).toBe('timeout') expect(report.duration).toBeNull() }) it('timeouts when the basic trace when a custom timeout duration is reached', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const CUSTOM_TIMEOUT_DURATION = 500 const tracer = traceManager.createTracer({ name: 'ticket.timeout-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'timed-out-render' }], variants: { cold_boot: { timeout: CUSTOM_TIMEOUT_DURATION }, }, }) const traceId = tracer.start({ startTime: { now: 0, epoch: 0 }, relatedTo: { ticketId: '1', }, variant: 'cold_boot', }) expect(traceId).toBe('trace-id') // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}------${Render('timed-out-render', 0)} Time: ${0} ${CUSTOM_TIMEOUT_DURATION + 1} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() const report: Parameters< AnyPossibleReportFn<TicketIdRelationSchemasFixture> >[0] = reportFn.mock.calls[0]![0] expect(report.status).toBe('interrupted') expect(report.interruptionReason).toBe('timeout') expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | start timeline | | time (ms) | 0 `) expect(report.name).toBe('ticket.timeout-operation') expect(report.duration).toBeNull() }) it('transitions from debouncing to timeout', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const CUSTOM_TIMEOUT_DURATION = 500 const tracer = traceManager.createTracer({ name: 'ticket.timeout-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], debounceOnSpans: [{ name: 'debounce' }], variants: { cold_boot: { timeout: CUSTOM_TIMEOUT_DURATION }, }, }) const traceId = tracer.start({ startTime: { now: 0, epoch: 0 }, relatedTo: { ticketId: '1', }, variant: 'cold_boot', }) expect(traceId).toBe('trace-id') // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}--${Render('end', 0)}--${Render('debounce', 0)}--${Check}} Time: ${0} ${50} ${51} ${CUSTOM_TIMEOUT_DURATION + 1} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() const report: Parameters< AnyPossibleReportFn<TicketIdRelationSchemasFixture> >[0] = reportFn.mock.calls[0]![0] expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | debounce events | start end timeline | |-<⋯ +50 ⋯>-||- time (ms) | 0 50 time (ms) | 51 `) expect(report.name).toBe('ticket.timeout-operation') expect(report.duration).toBeNull() expect(report.status).toBe('interrupted') expect(report.interruptionReason).toBe('timeout') }) }) }) })