@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
text/typescript
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)
})
})