UNPKG

@zendesk/retrace

Version:

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

639 lines (565 loc) 21 kB
import './testUtility/asciiTimelineSerializer' import { afterEach, beforeEach, describe, expect, it, type Mock, vitest, } from 'vitest' import * as matchSpan from './matchSpan' import { 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, GenerateIdFn } from './types' describe('TraceManager', () => { let reportFn: Mock<AnyPossibleReportFn<TicketIdRelationSchemasFixture>> let generateId: Mock<GenerateIdFn> let reportErrorFn: Mock let reportWarningFn: Mock const DEFAULT_COLDBOOT_TIMEOUT_DURATION = 45_000 vitest.useFakeTimers({ now: 0, }) 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<TicketIdRelationSchemasFixture>>() reportErrorFn = vitest.fn() reportWarningFn = vitest.fn() }) afterEach(() => { vitest.clearAllMocks() vitest.clearAllTimers() }) it('tracks trace after creating a draft and transitioning to active trace', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, // @ts-expect-error type mismatch reportFn, generateId, reportErrorFn, reportWarningFn, }) 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.createDraft({ variant: 'cold_boot', }) expect(traceId).toBe('trace-0') // 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) tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } }) 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.basic-operation') expect(report.duration).toBe(100) expect(report.status).toBe('ok') expect(report.interruption).toBeUndefined() }) it('timeouts when the basic trace when an timeout duration from variant is reached', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, // @ts-expect-error type mismatch reportFn, generateId, reportErrorFn, reportWarningFn, }) 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.createDraft({ startTime: { now: 0, epoch: 0 }, variant: 'cold_boot', }) expect(traceId).toBe('trace-0') tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } }) // 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.interruption).toMatchObject({ reason: 'timeout' }) expect(report.duration).toBeNull() expect(report.name).toBe('ticket.timeout-operation') expect(report.status).toBe('interrupted') expect(report.interruption).toMatchObject({ reason: 'timeout' }) expect(report.duration).toBeNull() }) describe('transitionDraftToActive()', () => { it('after a trace is active: reports warning', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, // @ts-expect-error type mismatch reportFn, generateId, reportErrorFn, reportWarningFn, }) const tracer = traceManager.createTracer({ name: 'ticket.basic-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) tracer.createDraft({ variant: 'cold_boot', }) tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } }) tracer.transitionDraftToActive({ relatedTo: { ticketId: '2' } }) expect(reportWarningFn).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining( 'You are trying to activate a trace that has already been activated', ), }), expect.objectContaining({ definition: expect.objectContaining({ name: 'ticket.basic-operation', }), }), ) }) it('after a trace is active and previouslyActivatedBehavior is "warn-and-continue": reports error and continues trace with new trace modification', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, // @ts-expect-error type mismatch reportFn, generateId, reportErrorFn, reportWarningFn, }) const tracer = traceManager.createTracer({ name: 'ticket.basic-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) tracer.createDraft({ variant: 'cold_boot', }) tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } }) tracer.transitionDraftToActive( { relatedTo: { ticketId: '2' } }, { previouslyActivatedBehavior: 'warn-and-continue' }, ) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('Component', 0, {type: 'component-render-start'})}--${Render('Component', 50)}--${Render('end', 50,)} Time: ${0} ${50} ${100} ` processSpans(spans, traceManager) expect(reportWarningFn).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining( 'You are trying to activate a trace that has already been activated', ), }), expect.objectContaining({ definition: expect.objectContaining({ name: 'ticket.basic-operation', }), }), ) expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect(report.relatedTo).toEqual({ ticketId: '2', // relatedTo successfully replaced }) }) it('after a trace is active and previouslyActivatedBehavior is "error-and-continue": reports error and continues trace with new trace modification', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, // @ts-expect-error type mismatch reportFn, generateId, reportErrorFn, reportWarningFn, }) const tracer = traceManager.createTracer({ name: 'ticket.basic-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) tracer.createDraft({ variant: 'cold_boot', }) tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } }) tracer.transitionDraftToActive( { relatedTo: { ticketId: '2' } }, { previouslyActivatedBehavior: 'error-and-continue' }, ) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('Component', 0, {type: 'component-render-start'})}--${Render('Component', 50)}--${Render('end', 50,)} Time: ${0} ${50} ${100} ` processSpans(spans, traceManager) expect(reportErrorFn).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining( 'You are trying to activate a trace that has already been activated', ), }), expect.objectContaining({ definition: expect.objectContaining({ name: 'ticket.basic-operation', }), }), ) expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect(report.relatedTo).toEqual({ ticketId: '2', // relatedTo successfully replaced }) }) it('after a trace is active and previouslyActivatedBehavior is "error": reports error and continues trace with original trace definition', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, // @ts-expect-error type mismatch reportFn, generateId, reportErrorFn, reportWarningFn, }) const tracer = traceManager.createTracer({ name: 'ticket.basic-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) tracer.createDraft({ variant: 'cold_boot', }) tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } }) tracer.transitionDraftToActive( { relatedTo: { ticketId: '2' } }, { previouslyActivatedBehavior: 'error' }, ) expect(reportErrorFn).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining( 'You are trying to activate a trace that has already been activated', ), }), expect.objectContaining({ definition: expect.objectContaining({ name: 'ticket.basic-operation', }), }), ) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('Component', 0, {type: 'component-render-start'})}--${Render('Component', 50)}--${Render('end', 50,)} Time: ${0} ${50} ${100} ` processSpans(spans, traceManager) expect(reportErrorFn).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining( 'You are trying to activate a trace that has already been activated', ), }), expect.objectContaining({ definition: expect.objectContaining({ name: 'ticket.basic-operation', }), }), ) expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect(report.relatedTo).toEqual({ ticketId: '1', // relatedTo was not replaced! }) }) }) describe('interrupt()', () => { it('interrupts a basic trace when interruptOnSpans criteria is met in draft mode, trace stops immediately', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, // @ts-expect-error type mismatch reportFn, generateId, reportErrorFn, reportWarningFn, }) 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.createDraft({ 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] // this trace was interrupted before transitioning from draft to active, so the only available entries are up to the interrupt expect(report.entries).toHaveLength(2) expect(report.name).toBe('ticket.interrupt-on-basic-operation') expect(report.duration).toBeNull() expect(report.interruption).toMatchObject({ reason: 'matched-on-interrupt', }) expect(report.status).toBe('interrupted') }) it('interrupts a draft trace when interrupt() is called with error', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, // @ts-expect-error type mismatch reportFn, generateId, reportErrorFn, reportWarningFn, }) const tracer = traceManager.createTracer({ name: 'ticket.basic-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) tracer.createDraft({ variant: 'cold_boot', }) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}-----${Render('middle', 0)} Time: ${0} ${50} ` processSpans(spans, traceManager) const error = new Error('Test error') tracer.interrupt({ error }) expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect(report.entries.length).toBe(3) // start, middle, and error mark expect(report.status).toBe('interrupted') expect(report.interruption).toMatchObject({ reason: 'aborted' }) // Last entry should be the error mark expect(report.entries[report.entries.length - 1]!.span.error).toBe(error) }) it('interrupts a draft trace when interrupt() is called without error', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, // @ts-expect-error type mismatch reportFn, generateId, reportErrorFn, reportWarningFn, }) const tracer = traceManager.createTracer({ name: 'ticket.basic-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) tracer.createDraft({ variant: 'cold_boot', }) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}-----${Render('middle', 0)} Time: ${0} ${50} ` processSpans(spans, traceManager) tracer.interrupt() expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect(report.status).toBe('interrupted') expect(report.entries.length).toBe(0) expect(report.interruption).toMatchObject({ reason: 'draft-cancelled' }) }) it('interrupts an active trace when interrupt() is called with error', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, // @ts-expect-error type mismatch reportFn, generateId, reportErrorFn, reportWarningFn, }) const tracer = traceManager.createTracer({ name: 'ticket.basic-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) tracer.createDraft({ variant: 'cold_boot', }) tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } }) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}-----${Render('middle', 0)} Time: ${0} ${50} ` processSpans(spans, traceManager) const error = new Error('Test error') tracer.interrupt({ error }) expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect(report.entries.length).toBe(3) // start, middle, and error mark expect(report.status).toBe('interrupted') expect(report.interruption).toMatchObject({ reason: 'aborted' }) // Last entry should be the error mark expect(report.entries[report.entries.length - 1]?.span.error).toBe(error) }) it('interrupts an active trace when interrupt() is called without error', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, // @ts-expect-error type mismatch reportFn, generateId, reportErrorFn, reportWarningFn, }) const tracer = traceManager.createTracer({ name: 'ticket.basic-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) tracer.createDraft({ variant: 'cold_boot', }) tracer.transitionDraftToActive({ relatedTo: { ticketId: '1' } }) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('start', 0)}-----${Render('middle', 0)} Time: ${0} ${50} ` processSpans(spans, traceManager) tracer.interrupt() expect(reportFn).toHaveBeenCalled() const report = reportFn.mock.calls[0]![0] expect(report.status).toBe('interrupted') expect(report.entries.length).toBe(2) // start, middle expect(report.interruption).toMatchObject({ reason: 'aborted' }) }) it('reports warning when interrupting a non-existent trace', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, // @ts-expect-error type mismatch reportFn, generateId, reportErrorFn, reportWarningFn, }) const tracer = traceManager.createTracer({ name: 'ticket.basic-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { cold_boot: { timeout: DEFAULT_COLDBOOT_TIMEOUT_DURATION }, }, }) tracer.interrupt({}) expect(reportWarningFn).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining( 'No current active trace when initializing a trace', ), }), expect.objectContaining({ definition: expect.any(Object), }), ) }) }) })