UNPKG

@zendesk/retrace

Version:

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

980 lines (936 loc) 30.4 kB
import { describe, expect, it, vitest as jest } from 'vitest' import { getSpanSummaryAttributes } from './convertToRum' import { fromDefinition } from './matchSpan' import { createTraceRecording, getComputedSpans, getComputedValues, } from './recordingComputeUtils' import { createMockSpanAndAnnotation, createTimestamp, } from './testUtility/createMockFactory' import type { CompleteTraceDefinition } from './types' interface AnyRelation { global: {} } const baseDefinitionFixture: CompleteTraceDefinition< 'global', AnyRelation, 'origin' > = { name: 'test-trace', relationSchemaName: 'global', relationSchema: { global: {} }, requiredSpans: [() => true], computedSpanDefinitions: {}, computedValueDefinitions: {}, variants: { origin: { timeout: 45_000 }, }, } describe('recordingComputeUtils', () => { describe('error status propagation', () => { it('should mark trace as error if any non-suppressed span has error status', () => { const recording = createTraceRecording( { definition: baseDefinitionFixture, recordedItems: new Map([ createMockSpanAndAnnotation(100, { status: 'error', name: 'error-span', }), createMockSpanAndAnnotation(200, { status: 'ok', name: 'ok-span' }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }, { transitionFromState: 'active', lastRelevantSpanAndAnnotation: undefined, transitionToState: 'complete', completeSpanAndAnnotation: undefined, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: undefined, }, ) expect(recording.status).toBe('error') expect(recording.additionalDurations.startTillInteractive).toBeNull() expect(recording.additionalDurations.completeTillInteractive).toBeNull() expect(recording.additionalDurations.startTillRequirementsMet).toBeNull() }) it('should not mark trace as error if all error spans are suppressed', () => { const recording = createTraceRecording( { definition: { ...baseDefinitionFixture, suppressErrorStatusPropagationOnSpans: [ ({ span }) => span.name === 'suppressed-error-span', ], }, recordedItems: new Map([ createMockSpanAndAnnotation(100, { status: 'error', name: 'suppressed-error-span', }), createMockSpanAndAnnotation(200, { status: 'ok', name: 'ok-span' }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }, { transitionFromState: 'active', lastRelevantSpanAndAnnotation: undefined, transitionToState: 'complete', completeSpanAndAnnotation: undefined, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: undefined, }, ) expect(recording.status).toBe('ok') expect(recording.additionalDurations.startTillInteractive).toBeNull() }) it('should mark trace as error if any error span is not suppressed', () => { const recording = createTraceRecording( { definition: { ...baseDefinitionFixture, suppressErrorStatusPropagationOnSpans: [ ({ span }) => span.name === 'suppressed-error-span', ], }, recordedItems: new Map([ createMockSpanAndAnnotation(100, { status: 'error', name: 'suppressed-error-span', }), createMockSpanAndAnnotation(200, { status: 'error', name: 'non-suppressed-error-span', }), createMockSpanAndAnnotation(300, { status: 'ok', name: 'ok-span' }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }, { transitionFromState: 'active', lastRelevantSpanAndAnnotation: undefined, transitionToState: 'complete', completeSpanAndAnnotation: undefined, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: undefined, }, ) expect(recording.status).toBe('error') expect(recording.additionalDurations.startTillInteractive).toBeNull() }) it('should prioritize interrupted status over error status', () => { const recording = createTraceRecording( { definition: baseDefinitionFixture, recordedItems: new Map([ createMockSpanAndAnnotation(100, { status: 'error', name: 'error-span', }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }, { transitionFromState: 'active', interruption: { reason: 'timeout' }, transitionToState: 'interrupted', lastRelevantSpanAndAnnotation: undefined, }, ) expect(recording.status).toBe('interrupted') expect(recording.additionalDurations.startTillInteractive).toBeNull() }) }) describe('getComputedSpans', () => { const baseDefinition: CompleteTraceDefinition< 'global', AnyRelation, 'origin' > = { ...baseDefinitionFixture, computedSpanDefinitions: { 'test-computed-span': { startSpan: ({ span }) => span.name === 'start-span', endSpan: ({ span }) => span.name === 'end-span', }, }, } it('should compute duration and startOffset correctly', () => { const result = getComputedSpans({ definition: baseDefinition, recordedItems: new Map([ createMockSpanAndAnnotation(100, { name: 'start-span', duration: 50, }), createMockSpanAndAnnotation(200, { name: 'end-span', duration: 50, }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }) expect(result['test-computed-span']).toEqual({ duration: 150, // (200 + 50) - 100 startOffset: 100, }) }) it('should handle operation-start and operation-end special matchers', () => { const definition: CompleteTraceDefinition< 'global', AnyRelation, 'origin' > = { ...baseDefinition, computedSpanDefinitions: { 'operation-span': { startSpan: 'operation-start', endSpan: 'operation-end', }, }, } const markedCompleteSpan = createMockSpanAndAnnotation(200, { name: 'end-span', }) markedCompleteSpan[1].annotation.markedComplete = true const result = getComputedSpans( { definition, recordedItems: new Map([ createMockSpanAndAnnotation(100, { name: 'start-span' }), markedCompleteSpan, ]), input: { id: 'test', startTime: createTimestamp(0), // eslint-disable-next-line @typescript-eslint/consistent-type-assertions relatedTo: {} as never, variant: 'origin', }, recordedItemsByLabel: {}, }, { completeSpanAndAnnotation: markedCompleteSpan[1] }, ) expect(result['operation-span']).toBeDefined() }) describe('nthMatch', () => { it('should select the correct start span using a positive nthMatch', () => { const definition: CompleteTraceDefinition< 'global', AnyRelation, 'origin' > = { ...baseDefinitionFixture, computedSpanDefinitions: { 'test-computed-span': { startSpan: fromDefinition({ name: 'start-span', nthMatch: 1, // starts at 0th index }), endSpan: fromDefinition({ name: 'end-span' }), }, }, } const result = getComputedSpans({ definition, recordedItems: new Map([ createMockSpanAndAnnotation(100, { name: 'start-span' }), createMockSpanAndAnnotation(300, { name: 'start-span' }), // matching start span createMockSpanAndAnnotation(400, { name: 'start-span' }), createMockSpanAndAnnotation(500, { name: 'end-span', duration: 50, }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }) expect(result['test-computed-span']).toEqual({ duration: 250, // (500 + 50) - 300 startOffset: 300, // should use the second start-span }) }) it('should select the correct start span using a negative nthMatch', () => { const definition: CompleteTraceDefinition< 'global', AnyRelation, 'origin' > = { ...baseDefinitionFixture, computedSpanDefinitions: { 'test-computed-span': { startSpan: fromDefinition({ name: 'start-span', nthMatch: -3, }), endSpan: fromDefinition({ name: 'end-span' }), }, }, } const result = getComputedSpans({ definition, recordedItems: new Map([ createMockSpanAndAnnotation(100, { name: 'start-span' }), // starting span createMockSpanAndAnnotation(200, { name: 'start-span' }), createMockSpanAndAnnotation(300, { name: 'start-span' }), createMockSpanAndAnnotation(500, { name: 'end-span', duration: 50, }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }) expect(result['test-computed-span']).toEqual({ duration: 450, // (500 + 50) - 100 startOffset: 100, }) }) it('should select the correct end span using a positive nthMatch', () => { const definition: CompleteTraceDefinition< 'global', AnyRelation, 'origin' > = { ...baseDefinitionFixture, computedSpanDefinitions: { 'test-computed-span': { startSpan: fromDefinition({ name: 'start-span' }), endSpan: fromDefinition({ name: 'end-span', nthMatch: 2, }), }, }, } const result = getComputedSpans({ definition, recordedItems: new Map([ createMockSpanAndAnnotation(100, { name: 'start-span' }), createMockSpanAndAnnotation(150, { name: 'span' }), createMockSpanAndAnnotation(200, { name: 'end-span', duration: 50, }), createMockSpanAndAnnotation(400, { name: 'end-span', duration: 50, }), createMockSpanAndAnnotation(600, { // matching span name: 'end-span', duration: 50, }), createMockSpanAndAnnotation(700, { name: 'end-span', duration: 50, }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }) expect(result['test-computed-span']).toEqual({ duration: 550, // (600 + 50) - 100 startOffset: 100, }) }) it('should select the correct end span using a negative nthMatch', () => { const definition: CompleteTraceDefinition< 'global', AnyRelation, 'origin' > = { ...baseDefinitionFixture, computedSpanDefinitions: { 'test-computed-span': { startSpan: fromDefinition({ name: 'start-span' }), endSpan: fromDefinition({ name: 'end-span', nthMatch: -1, }), }, }, } const result = getComputedSpans({ definition, recordedItems: new Map([ createMockSpanAndAnnotation(100, { name: 'start-span' }), createMockSpanAndAnnotation(200, { name: 'end-span', duration: 50, }), createMockSpanAndAnnotation(400, { name: 'end-span', duration: 50, }), createMockSpanAndAnnotation(600, { name: 'end-span', duration: 50, }), createMockSpanAndAnnotation(700, { // matching span name: 'end-span', duration: 50, }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }) expect(result['test-computed-span']).toEqual({ duration: 650, // (700 + 50) - 100 startOffset: 100, }) }) it('should not return any computed spans using a invalid nthMatch', () => { const definition: CompleteTraceDefinition< 'global', AnyRelation, 'origin' > = { ...baseDefinitionFixture, computedSpanDefinitions: { 'test-computed-span': { startSpan: fromDefinition({ name: 'start-span' }), endSpan: fromDefinition({ name: 'end-span', nthMatch: -100, }), }, }, } const result = getComputedSpans({ definition, recordedItems: new Map([ createMockSpanAndAnnotation(100, { name: 'start-span' }), createMockSpanAndAnnotation(200, { name: 'end-span', duration: 50, }), createMockSpanAndAnnotation(400, { // matching span name: 'end-span', duration: 50, }), createMockSpanAndAnnotation(600, { name: 'end-span', duration: 50, }), createMockSpanAndAnnotation(700, { name: 'end-span', duration: 50, }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }) expect(result).toEqual({}) }) it('should work with span definition objects containing nthMatch', () => { const definition: CompleteTraceDefinition< 'global', AnyRelation, 'origin' > = { ...baseDefinitionFixture, computedSpanDefinitions: { 'test-computed-span': { startSpan: fromDefinition({ name: 'start-span', nthMatch: 1, }), endSpan: fromDefinition({ name: 'end-span', nthMatch: -1 }), }, }, } const result = getComputedSpans({ definition, recordedItems: new Map([ createMockSpanAndAnnotation(100, { name: 'start-span' }), createMockSpanAndAnnotation(200, { name: 'start-span' }), createMockSpanAndAnnotation(300, { name: 'start-span' }), createMockSpanAndAnnotation(400, { name: 'end-span', duration: 50, }), createMockSpanAndAnnotation(500, { name: 'end-span', duration: 50, }), createMockSpanAndAnnotation(600, { name: 'end-span', duration: 50, }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }) expect(result['test-computed-span']).toEqual({ duration: 450, // (600 + 50) - 200 startOffset: 200, // should use second start-span }) }) }) }) describe('getComputedValues', () => { const baseDefinition: CompleteTraceDefinition< 'global', AnyRelation, 'origin' > = { ...baseDefinitionFixture, computedValueDefinitions: { 'error-count': { matches: [({ span }) => span.status === 'error'], computeValueFromMatches: (matches) => matches.length, }, }, } it('should compute values based on matching spans', () => { const result = getComputedValues({ definition: baseDefinition, recordedItems: new Map([ createMockSpanAndAnnotation(100, { status: 'error' }), createMockSpanAndAnnotation(200, { status: 'error' }), createMockSpanAndAnnotation(300, { status: 'ok' }), ]), input: { id: 'test', startTime: createTimestamp(0), // eslint-disable-next-line @typescript-eslint/consistent-type-assertions relatedTo: {} as never, variant: 'origin', }, recordedItemsByLabel: {}, }) expect(result['error-count']).toBe(2) }) it('should handle multiple matches in computeValueFromMatches', () => { const definition: CompleteTraceDefinition< 'global', AnyRelation, 'origin' > = { ...baseDefinition, computedValueDefinitions: { 'status-counts': { matches: [ ({ span }) => span.status === 'error', ({ span }) => span.status === 'ok', ], computeValueFromMatches: (errors, oks) => errors.length + oks.length, }, }, } const result = getComputedValues({ definition, recordedItems: new Map([ createMockSpanAndAnnotation(100, { status: 'error' }), createMockSpanAndAnnotation(200, { status: 'ok' }), createMockSpanAndAnnotation(300, { status: 'error' }), ]), input: { id: 'test', startTime: createTimestamp(0), // eslint-disable-next-line @typescript-eslint/consistent-type-assertions relatedTo: {} as never, variant: 'origin', }, recordedItemsByLabel: {}, }) expect(result['status-counts']).toEqual(3) }) }) describe('getSpanSummaryAttributes', () => { it('should merge attributes from spans with the same name', () => { const result = getSpanSummaryAttributes([ createMockSpanAndAnnotation(100, { name: 'test-span', attributes: { first: true }, })[1], createMockSpanAndAnnotation(200, { name: 'test-span', attributes: { second: true }, })[1], ]) expect(result['test-span']).toEqual({ first: true, second: true, }) }) }) describe('computedRenderBeaconSpans', () => { it('should compute render beacon metrics correctly', () => { const recording = createTraceRecording( { definition: baseDefinitionFixture, recordedItems: new Map([ createMockSpanAndAnnotation(100, { name: 'test-component', type: 'component-render', relatedTo: {}, duration: 50, isIdle: true, renderCount: 1, renderedOutput: 'loading', }), createMockSpanAndAnnotation( 200, { name: 'test-component', type: 'component-render', relatedTo: {}, duration: 50, isIdle: true, renderCount: 2, renderedOutput: 'content', }, { occurrence: 2 }, ), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }, { transitionFromState: 'active', transitionToState: 'complete', lastRelevantSpanAndAnnotation: undefined, completeSpanAndAnnotation: undefined, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: undefined, }, ) expect(recording.computedRenderBeaconSpans['test-component']).toEqual({ startOffset: 100, firstRenderTillContent: 150, firstRenderTillLoading: 50, firstRenderTillData: 100, renderCount: 2, sumOfRenderDurations: 100, }) }) }) describe('variant property', () => { it('should include the variant in the recording', () => { const recording = createTraceRecording( { definition: baseDefinitionFixture, recordedItems: new Map([ createMockSpanAndAnnotation(100, { status: 'ok', name: 'test-span', }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }, { transitionFromState: 'active', transitionToState: 'complete', lastRelevantSpanAndAnnotation: undefined, completeSpanAndAnnotation: undefined, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: undefined, }, ) // Verify the variant is included in the recording expect(recording.variant).toBe('origin') }) }) describe('promoteSpanAttributesForTrace and attribute promotion', () => { const promotedAttributesTraceDefinition = { ...baseDefinitionFixture, promoteSpanAttributes: [ { span: { name: 'foo-span' }, attributes: ['foo', 'bar'], }, { span: { name: 'baz-span' }, attributes: ['baz'], }, ], } it('should promote specified attributes from last matching spans to trace', () => { const recording = createTraceRecording( { definition: promotedAttributesTraceDefinition, recordedItems: new Map([ createMockSpanAndAnnotation(100, { name: 'foo-span', attributes: { foo: 1, bar: 2, unused: 42 }, }), createMockSpanAndAnnotation(200, { name: 'foo-span', attributes: { foo: 7, bar: 8 }, }), createMockSpanAndAnnotation(300, { name: 'baz-span', attributes: { baz: 'hello' }, }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }, { transitionFromState: 'active', transitionToState: 'complete', lastRelevantSpanAndAnnotation: undefined, completeSpanAndAnnotation: undefined, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: undefined, }, ) // Should select last foo-span (timestamp 200) for foo/bar, and baz-span for baz expect(recording.attributes.foo).toBe(7) expect(recording.attributes.bar).toBe(8) expect(recording.attributes.baz).toBe('hello') expect('unused' in recording.attributes).toBe(false) }) it('should promote specified attributes from last matching spans when there is an attribute name collision to trace', () => { const promotedAttributesTraceDefinitionWithOverrideAttributeNames = { ...baseDefinitionFixture, promoteSpanAttributes: [ { span: { name: 'foo-span' }, attributes: ['foo', 'bar'], }, { span: { name: 'baz-span' }, attributes: ['foo', 'bar'], }, ], } const recording = createTraceRecording( { definition: promotedAttributesTraceDefinitionWithOverrideAttributeNames, recordedItems: new Map([ createMockSpanAndAnnotation(200, { name: 'foo-span', attributes: { foo: 7, bar: 8 }, }), createMockSpanAndAnnotation(300, { name: 'baz-span', // should replace the trace attributes from 'foo-span' attributes: { foo: 'hello', bar: 'world' }, }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }, { transitionFromState: 'active', transitionToState: 'complete', lastRelevantSpanAndAnnotation: undefined, completeSpanAndAnnotation: undefined, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: undefined, }, ) // Should select attributes from baz-span (timestamp 300) expect(recording.attributes.foo).toBe('hello') expect(recording.attributes.bar).toBe('world') expect('unused' in recording.attributes).toBe(false) }) it('should not set unset promoted attributes if not found', () => { const partialAttrDefinition = { ...baseDefinitionFixture, promoteSpanAttributes: [ { span: { name: 'foo-span' }, attributes: ['foo', 'bar'], }, { span: { name: 'no-match' }, attributes: ['baz', 'shouldNotBeSet'], }, ], } const recording = createTraceRecording( { definition: partialAttrDefinition, recordedItems: new Map([ createMockSpanAndAnnotation(111, { name: 'foo-span', attributes: { foo: 99 }, }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }, { transitionFromState: 'active', transitionToState: 'complete', lastRelevantSpanAndAnnotation: undefined, completeSpanAndAnnotation: undefined, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: undefined, }, ) expect(recording.attributes.foo).toBe(99) expect('bar' in recording.attributes).toBe(false) expect('baz' in recording.attributes).toBe(false) expect('shouldNotBeSet' in recording.attributes).toBe(false) }) it('should allow attribute promotion on interruption', () => { const recording = createTraceRecording( { definition: promotedAttributesTraceDefinition, recordedItems: new Map([ createMockSpanAndAnnotation(100, { name: 'foo-span', attributes: { foo: 'z' }, }), createMockSpanAndAnnotation(200, { name: 'baz-span', attributes: { baz: 10 }, }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', }, recordedItemsByLabel: {}, }, { transitionFromState: 'active', transitionToState: 'interrupted', interruption: { reason: 'timeout' }, lastRelevantSpanAndAnnotation: undefined, }, ) expect(recording.attributes.foo).toBe('z') expect(recording.attributes.baz).toBe(10) }) it('should allow original trace attributes to take precedence over promoted', () => { const definition = { ...promotedAttributesTraceDefinition, } const recording = createTraceRecording( { definition, recordedItems: new Map([ createMockSpanAndAnnotation(100, { name: 'foo-span', attributes: { foo: 'notUsed' }, }), createMockSpanAndAnnotation(200, { name: 'baz-span', attributes: { baz: 111 }, }), ]), input: { id: 'test', startTime: createTimestamp(0), relatedTo: {}, variant: 'origin', attributes: { foo: 'owned', baz: 'shouldWin' }, }, recordedItemsByLabel: {}, }, { transitionFromState: 'active', transitionToState: 'complete', lastRelevantSpanAndAnnotation: undefined, completeSpanAndAnnotation: undefined, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: undefined, }, ) expect(recording.attributes.foo).toBe('owned') expect(recording.attributes.baz).toBe('shouldWin') }) }) })