UNPKG

@zendesk/retrace

Version:

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

493 lines (438 loc) 16.4 kB
import './testUtility/asciiTimelineSerializer' import { beforeEach, describe, expect, it, type Mock, vitest } from 'vitest' import { Check, getSpansFromTimeline, Render } from './testUtility/makeTimeline' import { processSpans } from './testUtility/processSpans' import { TraceManager } from './TraceManager' import type { AnyPossibleReportFn, GenerateIdFn } from './types' interface TestRelationSchema { test: { id: StringConstructor } } describe('Tracer', () => { let reportFn: Mock<AnyPossibleReportFn<TestRelationSchema>> // TS doesn't like that reportFn is wrapped in Mock<> type const getReportFn = () => reportFn as AnyPossibleReportFn<TestRelationSchema> let generateId: Mock<GenerateIdFn> let reportErrorFn: Mock let idPerType = { span: 0, trace: 0, tick: 0, } beforeEach(() => { idPerType = { span: 0, trace: 0, tick: 0, } generateId = vitest.fn((type) => { const seq = idPerType[type]++ return type === 'span' ? `id-${seq}` : type === 'trace' ? `trace-${seq}` : `tick-${seq}` }) reportFn = vitest.fn<AnyPossibleReportFn<TestRelationSchema>>() reportErrorFn = vitest.fn() vitest.useFakeTimers({ now: 0 }) }) describe('variants', () => { it('uses additional required spans from variant', () => { const traceManager = new TraceManager({ relationSchemas: { test: { id: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'test.operation', type: 'operation', relationSchemaName: 'test', requiredSpans: [{ name: 'base-required' }], variants: { variant_a: { timeout: 1_000, additionalRequiredSpans: [{ name: 'extra-required' }], }, variant_b: { timeout: 1_000, }, }, }) // Start trace with variant_a - should require both spans tracer.start({ relatedTo: { id: '1' }, variant: 'variant_a', }) // Only see base-required span - should not complete // prettier-ignore const { spans: firstSpans } = getSpansFromTimeline<TestRelationSchema>` Events: ${Render('start', 0)}-----${Render('base-required', 0)}-----${Check} Time: ${0} ${50} ${100} ` processSpans(firstSpans, traceManager) expect(reportFn).not.toHaveBeenCalled() // See both required spans - should complete // prettier-ignore const { spans: secondSpans } = getSpansFromTimeline<TestRelationSchema>` Events: ${Render('base-required', 0)}-----${Render('extra-required', 0)} Time: ${150} ${200} ` processSpans(secondSpans, traceManager) expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect(report.status).toBe('ok') expect(report.duration).toBe(200) }) it('uses additional interrupt on spans from variant', () => { const traceManager = new TraceManager<TestRelationSchema>({ relationSchemas: { test: { id: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'test.operation', type: 'operation', relationSchemaName: 'test', requiredSpans: [{ name: 'required' }], debounceWindow: 100, variants: { variant_a: { timeout: 1_000, additionalInterruptOnSpans: [{ name: 'variant-a-interrupt' }], }, }, }) tracer.start({ relatedTo: { id: '1' }, variant: 'variant_a', }) // prettier-ignore const { spans } = getSpansFromTimeline<TestRelationSchema>` Events: ${Render('span', 0)}------${Render('variant-a-interrupt', 0)}---${Render('required', 0)} Time: ${0} ${100} ${250} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect(report.status).toBe('interrupted') expect(report.duration).toBe(null) }) it('uses additional debounce spans from variant', () => { const traceManager = new TraceManager<TestRelationSchema>({ relationSchemas: { test: { id: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'test.operation', type: 'operation', relationSchemaName: 'test', requiredSpans: [{ name: 'required' }], debounceOnSpans: [{ name: 'base-debounce' }], debounceWindow: 100, variants: { variant_a: { timeout: 1_000, additionalDebounceOnSpans: [{ name: 'extra-debounce' }], }, }, }) tracer.start({ relatedTo: { id: '1' }, variant: 'variant_a', }) // prettier-ignore const { spans } = getSpansFromTimeline<TestRelationSchema>` Events: ${Render('required', 0)}---${Render('base-debounce', 0)}---${Render('extra-debounce', 0)}---${Check} Time: ${0} ${50} ${100} ${250} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect(report.status).toBe('ok') expect(report.duration).toBe(100) }) it('different variants can have different additional spans', () => { const traceManager = new TraceManager<TestRelationSchema>({ relationSchemas: { test: { id: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'test.operation', type: 'operation', relationSchemaName: 'test', requiredSpans: [{ name: 'base-required' }], variants: { variant_a: { timeout: 1_000, additionalRequiredSpans: [{ name: 'extra-required-a' }], }, variant_b: { timeout: 1_000, additionalRequiredSpans: [{ name: 'extra-required-b' }], }, }, }) // Start trace with variant_a tracer.start({ relatedTo: { id: '1' }, variant: 'variant_a', }) // Complete variant_a requirements // prettier-ignore const { spans: variantASpans } = getSpansFromTimeline<TestRelationSchema>` Events: ${Render('base-required', 0)}-----${Render('extra-required-a', 0)} Time: ${0} ${50} ` processSpans(variantASpans, traceManager) expect(reportFn).toHaveBeenCalled() expect(reportFn.mock.calls[0]![0].status).toBe('ok') reportFn.mockClear() // Start new trace with variant_b tracer.start({ relatedTo: { id: '1' }, variant: 'variant_b', }) // Complete variant_b requirements // prettier-ignore const { spans: variantBSpans } = getSpansFromTimeline<TestRelationSchema>` Events: ${Render('base-required', 0)}-----${Render('extra-required-b', 0)} Time: ${100} ${150} ` processSpans(variantBSpans, traceManager) expect(reportFn).toHaveBeenCalled() expect(reportFn.mock.calls[0]![0].status).toBe('ok') }) }) describe('addRequiredSpansToCurrentTrace', () => { it('adds required spans to an existing trace', () => { const traceManager = new TraceManager<TestRelationSchema>({ relationSchemas: { test: { id: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'test.operation', type: 'operation', relationSchemaName: 'test', requiredSpans: [{ name: 'hello' }, { name: 'initial-required' }], variants: { default: { timeout: 1_000 }, }, }) // Start trace tracer.start({ relatedTo: { id: '1' }, variant: 'default', }) // @ts-expect-error internal prop const trace = tracer.rootTraceUtilities.getCurrentTrace() expect( trace?.stateMachine.successfullyMatchedRequiredSpanMatchers.size, ).toBe(0) expect(trace?.definition.requiredSpans).toHaveLength(2) expect(trace?.definition.interruptOnSpans).toHaveLength(2) // See initial required span - should not complete yet // prettier-ignore const { spans: firstSpans } = getSpansFromTimeline<TestRelationSchema>` Events: ${Render('hello', 0)} Time: ${50} ` processSpans(firstSpans, traceManager) expect(reportFn).not.toHaveBeenCalled() // Now add an additional required span tracer.addRequirementsToCurrentTraceOnly({ additionalRequiredSpans: [{ name: 'added-required' }], }) // @ts-expect-error internal prop const traceRecreated = tracer.rootTraceUtilities.getCurrentTrace() expect(traceRecreated).not.toBe(trace) expect(traceRecreated?.definition.requiredSpans).toHaveLength(3) expect(traceRecreated?.definition.interruptOnSpans).toHaveLength(3) // two required spans left: expect( traceRecreated?.stateMachine.successfullyMatchedRequiredSpanMatchers .size, ).toBe(1) // See the added required span - now should complete // prettier-ignore const { spans: secondSpans } = getSpansFromTimeline<TestRelationSchema>` Events: ${Render('initial-required', 50)}----${Render('added-required', 0)} Time: ${100} ${150} ` processSpans(secondSpans, traceManager) expect( traceRecreated?.stateMachine.successfullyMatchedRequiredSpanMatchers .size, ).toBe(3) // Verify trace completed expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect(report.status).toBe('ok') expect(report.duration).toBe(150) // Verify that previous spans were preserved const recordedSpanNames = report.entries.map((s) => s.span.name) expect(recordedSpanNames).toEqual([ 'hello', 'initial-required', 'added-required', ]) expect(traceManager.currentTraceContext).toBeUndefined() }) }) describe('adding requiredSpans', () => { it('adds requiredSpans when starting a trace', () => { const traceManager = new TraceManager({ relationSchemas: { test: { id: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'ticket.basic-operation', type: 'operation', relationSchemaName: 'test', requiredSpans: [{ name: 'orig-end' }], variants: { cold_boot: { timeout: 10_000 }, }, }) const traceId = tracer.start( { relatedTo: { id: '1' }, variant: 'cold_boot', }, { additionalRequiredSpans: [{ name: 'additional-end' }], }, ) expect(traceId).toBe('trace-0') // @ts-expect-error internals const trace = tracer.rootTraceUtilities.getCurrentTrace() expect(trace?.definition.requiredSpans).toHaveLength(2) expect( trace?.stateMachine.successfullyMatchedRequiredSpanMatchers.size, ).toBe(0) expect(trace?.definition.interruptOnSpans).toHaveLength(2) // prettier-ignore const { spans } = getSpansFromTimeline<TestRelationSchema>` Events: ${Render('start', 0)}-----${Render('middle', 0)}-----${Render('orig-end', 0)}----${Render('additional-end', 0)} Time: ${0} ${50} ${100} ${150} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() expect( trace?.stateMachine.successfullyMatchedRequiredSpanMatchers.size, ).toBe(2) const report = reportFn.mock.calls[0]![0] expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | start middle orig-end additional-end timeline | |-<⋯ +50 ⋯>-|-<⋯ +50 ⋯>-|-<⋯ +50 ⋯>-| time (ms) | 0 50 100 150 `) expect(report.name).toBe('ticket.basic-operation') expect(report.duration).toBe(150) expect(report.status).toBe('ok') expect(report.interruption).toBeUndefined() }) it('adds requiredSpans when creating a trace, and then again after transitioning to active, and again after start', () => { const traceManager = new TraceManager({ relationSchemas: { test: { id: String } }, reportFn: getReportFn(), generateId, reportErrorFn, }) const tracer = traceManager.createTracer({ name: 'ticket.basic-operation', type: 'operation', relationSchemaName: 'test', requiredSpans: [ function origEnd({ span }) { return span.name === 'orig-end' }, ], variants: { cold_boot: { timeout: 10_000, additionalRequiredSpans: [ function variantEnd({ span }) { return span.name === 'variant-end' }, ], }, }, }) const traceId = tracer.createDraft( { variant: 'cold_boot', }, { additionalRequiredSpans: [ function draftEnd({ span }) { return span.name === 'draft-end' }, ], }, ) tracer.transitionDraftToActive({ relatedTo: { id: '1' }, additionalRequiredSpans: [ function transitionToActiveEnd({ span }) { return span.name === 'transition-to-active-end' }, ], }) tracer.addRequirementsToCurrentTraceOnly({ additionalRequiredSpans: [ function activeEnd({ span }) { return span.name === 'active-end' }, ], }) // @ts-expect-error internals const trace = tracer.rootTraceUtilities.getCurrentTrace() expect(trace?.definition.requiredSpans).toHaveLength(5) expect( trace?.stateMachine.successfullyMatchedRequiredSpanMatchers.size, ).toBe(0) expect(trace?.definition.interruptOnSpans).toHaveLength(5) // prettier-ignore const { spans } = getSpansFromTimeline<TestRelationSchema>` Events: ${Render('start', 0)}-----${Render('orig-end', 0)}----${Render('variant-end', 0)}----${Render('draft-end', 0)}----${Render('transition-to-active-end', 0)}---${Render('active-end', 0)} Time: ${0} ${50} ${100} ${150} ${200} ${250} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalled() expect( trace?.stateMachine.successfullyMatchedRequiredSpanMatchers.size, ).toBe(5) const report = reportFn.mock.calls[0]![0] expect( report.entries.map( (spanAndAnnotation) => spanAndAnnotation.span.performanceEntry, ), ).toMatchInlineSnapshot(` events | active-end events | start orig-end variant-end draft-end transition-to-active-end timeline | |-----------|------------|------------|------------|------------|- time (ms) | 0 50 100 150 200 250 `) expect(report.name).toBe('ticket.basic-operation') expect(report.duration).toBe(250) expect(report.status).toBe('ok') expect(report.interruption).toBeUndefined() }) }) })