UNPKG

@zendesk/retrace

Version:

define and capture Product Operation Traces along with computed metrics with an optional friendly React beacon API

463 lines (415 loc) 12.9 kB
import { ActionLogCache } from './ActionLogCache' import { generateReport } from './generateReport' import { getExternalApi } from './getExternalApi' const garbageCollectMs = 100_000 const debounceMs = 500 const renderTime = 100 const timeoutMs = 45_000 describe('getExternalApi', () => { const originalTimeOrigin = performance.timeOrigin beforeEach(() => { jest.useFakeTimers() Object.defineProperty(performance, 'timeOrigin', { configurable: true, enumerable: true, get() { return 0 }, }) }) afterEach(() => { jest.useRealTimers() Object.defineProperty(performance, 'timeOrigin', { configurable: true, enumerable: true, get() { return originalTimeOrigin }, }) }) it('sends the report after debounce when no final stages are specified', () => { const reportFn = jest.fn() const onInternalError = jest.fn() const actionLogCache = new ActionLogCache({ garbageCollectMs, debounceMs, reportFn, }) const api = getExternalApi({ actionLogCache, idPrefix: 'test', placement: 'beacon', }) api.markRenderStart('1') const actionLog = api.getActionLogForIdIfExists('1') expect(actionLog).toBeDefined() // no actions yet cause we didn't mark render end expect(actionLog?.getActions()).toHaveLength(0) expect(actionLog?.isCapturingData).toBe(true) jest.advanceTimersByTime(renderTime) api.markRenderEnd('1') // start and end render expected: expect(actionLog?.getActions()).toHaveLength(2) expect(actionLog?.getActions()).toMatchInlineSnapshot(` [ { "entry": {}, "marker": "start", "mountedPlacements": [ "beacon", ], "source": "beacon", "timestamp": 0, "timingId": "test/1", "type": "render", }, { "entry": {}, "marker": "end", "mountedPlacements": [ "beacon", ], "source": "beacon", "timestamp": 100, "timingId": "test/1", "type": "render", }, ] `) // not send yet cause we're waiting for debounce expect(reportFn).not.toHaveBeenCalled() jest.advanceTimersByTime(debounceMs) expect(reportFn).toHaveBeenCalledTimes(1) jest.advanceTimersByTime(garbageCollectMs) api.dispose('1') expect(actionLogCache.get('test/1')).toBeUndefined() expect(onInternalError).not.toHaveBeenCalled() }) it('sends the report after debounce when a final stage is specified', () => { const reportFn = jest.fn() const onInternalError = jest.fn() const actionLogCache = new ActionLogCache({ garbageCollectMs, debounceMs, finalStages: ['ready'], reportFn, }) const api = getExternalApi({ actionLogCache, idPrefix: 'test', placement: 'beacon', }) api.markRenderStart('1') const actionLog = api.getActionLogForIdIfExists('1') expect(actionLog).toBeDefined() // no actions yet cause we didn't mark render end expect(actionLog?.getActions()).toHaveLength(0) expect(actionLog?.isCapturingData).toBe(true) jest.advanceTimersByTime(renderTime) api.markRenderEnd('1') // start and end render expected: expect(actionLog?.getActions()).toHaveLength(2) api.markStage('1', 'ready') // not send yet cause we're waiting for debounce expect(reportFn).not.toHaveBeenCalled() expect(actionLog?.getActions()).toHaveLength(3) expect(actionLog?.getActions()[2]).toMatchInlineSnapshot(` { "entry": {}, "marker": "point", "metadata": undefined, "mountedPlacements": [ "beacon", ], "source": "beacon", "stage": "ready", "timestamp": 100, "timingId": "test/1", "type": "stage-change", } `) jest.advanceTimersByTime(debounceMs) expect(reportFn).toHaveBeenCalledTimes(1) expect(onInternalError).not.toHaveBeenCalled() }) it('switches stages correctly and without duplication', () => { const reportFn = jest.fn() const onInternalError = jest.fn() const actionLogCache = new ActionLogCache({ garbageCollectMs, debounceMs, finalStages: ['ready'], reportFn, }) const api1 = getExternalApi({ actionLogCache, idPrefix: 'test', placement: 'beacon1', }) const api2 = getExternalApi({ actionLogCache, idPrefix: 'test', placement: 'beacon2', }) api1.markStage('1', 'first') jest.advanceTimersByTime(renderTime) api1.markStage('1', 'first') jest.advanceTimersByTime(renderTime) api1.markStage('1', 'second') jest.advanceTimersByTime(renderTime) api2.markStage('1', 'second') jest.advanceTimersByTime(renderTime) api2.markStage('1', 'ready') const actionLog = api1.getActionLogForIdIfExists('1') expect(actionLog).toBeDefined() expect(actionLog?.isCapturingData).toBe(true) // not send yet cause we're waiting for debounce expect(reportFn).not.toHaveBeenCalled() expect(actionLog?.getActions()).toHaveLength(3) expect(actionLog?.getActions()).toMatchInlineSnapshot(` [ { "entry": {}, "marker": "point", "metadata": undefined, "mountedPlacements": [ "beacon1", ], "source": "beacon1", "stage": "first", "timestamp": 0, "timingId": "test/1", "type": "stage-change", }, { "entry": {}, "marker": "point", "metadata": undefined, "mountedPlacements": [ "beacon1", ], "source": "beacon1", "stage": "second", "timestamp": 200, "timingId": "test/1", "type": "stage-change", }, { "entry": {}, "marker": "point", "metadata": undefined, "mountedPlacements": [ "beacon1", "beacon2", ], "source": "beacon2", "stage": "ready", "timestamp": 400, "timingId": "test/1", "type": "stage-change", }, ] `) jest.advanceTimersByTime(debounceMs) expect(reportFn).toHaveBeenCalledTimes(1) expect(onInternalError).not.toHaveBeenCalled() }) it('ignores stages changed before all "waitForBeaconActivation" are active', () => { const reportFn = jest.fn() const onInternalError = jest.fn() const actionLogCache = new ActionLogCache({ garbageCollectMs, debounceMs, timeoutMs, finalStages: ['ready'], waitForBeaconActivation: ['beacon2'], minimumExpectedSimultaneousBeacons: 2, reportFn, }) const api1 = getExternalApi({ actionLogCache, idPrefix: 'test', placement: 'beacon1', }) const api2 = getExternalApi({ actionLogCache, idPrefix: 'test', placement: 'beacon2', }) // this will be ignored: api1.markStage('1', 'first') jest.advanceTimersByTime(renderTime) // this will be ignored: api1.markStage('1', 'second') jest.advanceTimersByTime(renderTime) const actionLog = api1.getActionLogForIdIfExists('1') expect(actionLog).toBeDefined() expect(actionLog?.isCapturingData).toBe(false) // this will be the first stage change: api2.markStage('1', 'second') jest.advanceTimersByTime(renderTime) api2.markStage('1', 'ready') expect(actionLog?.isCapturingData).toBe(true) // not send yet cause we're waiting for debounce expect(reportFn).not.toHaveBeenCalled() expect(actionLog?.getActions()).toHaveLength(2) expect(actionLog?.getActions()).toMatchInlineSnapshot(` [ { "entry": {}, "marker": "point", "metadata": undefined, "mountedPlacements": [ "beacon1", "beacon2", ], "source": "beacon2", "stage": "second", "timestamp": 200, "timingId": "test/1", "type": "stage-change", }, { "entry": {}, "marker": "point", "metadata": undefined, "mountedPlacements": [ "beacon1", "beacon2", ], "source": "beacon2", "stage": "ready", "timestamp": 300, "timingId": "test/1", "type": "stage-change", }, ] `) jest.advanceTimersByTime(debounceMs) expect(reportFn).toHaveBeenCalledTimes(1) expect(onInternalError).not.toHaveBeenCalled() }) it('does not report when minimumExpectedSimultaneousBeacons is not reached within expected time', () => { const reportFn = jest.fn() const onInternalError = jest.fn() const actionLogCache = new ActionLogCache({ garbageCollectMs, debounceMs, timeoutMs, waitForBeaconActivation: ['beacon1'], minimumExpectedSimultaneousBeacons: 2, reportFn, onInternalError, }) const api1 = getExternalApi({ actionLogCache, idPrefix: 'test', placement: 'beacon1', }) api1.markStage('1', 'first') jest.advanceTimersByTime(renderTime) api1.markStage('1', 'second') jest.advanceTimersByTime(renderTime) const actionLog = api1.getActionLogForIdIfExists('1') expect(actionLog).toBeDefined() expect(actionLog?.isCapturingData).toBe(true) // not send yet cause we're waiting for debounce expect(reportFn).not.toHaveBeenCalled() expect(actionLog?.getActions()).toHaveLength(2) jest.advanceTimersByTime(debounceMs) expect(reportFn).not.toHaveBeenCalled() expect(onInternalError).not.toHaveBeenCalled() jest.advanceTimersByTime(timeoutMs) expect(reportFn).not.toHaveBeenCalled() expect(onInternalError).not.toHaveBeenCalled() // recover and report after the timeout has passed: api1.markStage('1', 'third') jest.advanceTimersByTime(renderTime) expect(actionLog?.getActions()).toHaveLength(1) }) it('timeouts when final stage is not reached within expected time', () => { const reportFn = jest.fn() const onInternalError = jest.fn() const actionLogCache = new ActionLogCache({ garbageCollectMs, debounceMs, timeoutMs, finalStages: ['ready'], reportFn, onInternalError, }) const api1 = getExternalApi({ actionLogCache, idPrefix: 'test', placement: 'beacon1', }) api1.markStage('1', 'first') // api1.markRenderStart('1') const actionLog = api1.getActionLogForIdIfExists('1') expect(actionLog).toBeDefined() expect(actionLog?.isCapturingData).toBe(true) jest.advanceTimersByTime(renderTime) expect(reportFn).not.toHaveBeenCalled() expect(actionLog?.getActions()).toHaveLength(1) jest.advanceTimersByTime(debounceMs) expect(reportFn).not.toHaveBeenCalled() expect(onInternalError).not.toHaveBeenCalled() expect(actionLog?.isCapturingData).toBe(true) jest.advanceTimersByTime(timeoutMs) // we stopped capturing after timeout expect(actionLog?.isCapturingData).toBe(false) // and sent out a report with the timeout expect(reportFn).toHaveBeenCalledTimes(1) expect(onInternalError).not.toHaveBeenCalled() expect(actionLog?.getActions()).toHaveLength(0) expect(generateReport(reportFn.mock.calls[0][0]).spans) .toMatchInlineSnapshot(` [ { "data": { "dependencyChanges": 0, "metadata": {}, "mountedPlacements": [ "beacon1", ], "previousStage": "first", "source": "timeout", "stage": "timeout", "timeToStage": 45000, "timingId": "test/1", }, "description": "first to timeout", "endTime": 45000, "relativeEndTime": 45000, "startTime": 0, "type": "stage-change", }, ] `) reportFn.mockClear() // an entirely new marker after the timeout has passed should work too: api1.markStage('1', 'ready') expect(actionLog?.isCapturingData).toBe(true) expect(actionLog?.getActions()).toHaveLength(1) expect(actionLog?.getActions()).toMatchInlineSnapshot(` [ { "entry": {}, "marker": "point", "metadata": undefined, "mountedPlacements": [ "beacon1", ], "source": "beacon1", "stage": "ready", "timestamp": 45600, "timingId": "test/1", "type": "stage-change", }, ] `) jest.advanceTimersByTime(debounceMs) // and sent out a report with the ready state expect(reportFn).toHaveBeenCalledTimes(1) }) })