UNPKG

@zendesk/retrace

Version:

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

486 lines (434 loc) 13.6 kB
import { assert, describe, expect, it } from 'vitest' import { convertTraceToRUM, recursivelyRoundValues } from './convertToRum' import { createTraceRecording } from './recordingComputeUtils' import type { ActiveTraceInput } from './spanTypes' import { createMockSpanAndAnnotation, createTimestamp, } from './testUtility/createMockFactory' import type { TicketIdRelationSchemasFixture } from './testUtility/fixtures/relationSchemas' import type { CompleteTraceDefinition, MapTypesToSchema } from './types' describe('convertTraceToRUM', () => { it('should round all numeric values in the trace recording', () => { const definition: CompleteTraceDefinition< 'ticket', TicketIdRelationSchemasFixture, 'origin' > = { name: 'test-trace', relationSchemaName: 'ticket', relationSchema: { ticketId: String }, requiredSpans: [() => true], computedSpanDefinitions: {}, computedValueDefinitions: {}, variants: { origin: { timeout: 45_000 }, }, } const input: ActiveTraceInput< MapTypesToSchema<TicketIdRelationSchemasFixture['ticket']>, 'origin' > = { id: 'test', startTime: createTimestamp(0), relatedTo: { ticketId: '74' }, variant: 'origin', } const recordedItems = new Map([ createMockSpanAndAnnotation(100.501, { name: 'test-component', type: 'component-render', relatedTo: {}, duration: 50.499, isIdle: false, renderCount: 1, renderedOutput: 'loading', }), createMockSpanAndAnnotation( 200.001, { name: 'test-component', type: 'component-render', relatedTo: {}, duration: 50.999, isIdle: true, renderCount: 2, renderedOutput: 'content', }, { occurrence: 2 }, ), ]) const traceRecording = createTraceRecording( { definition, input, recordedItemsByLabel: {}, recordedItems, }, { transitionFromState: 'active', lastRelevantSpanAndAnnotation: undefined, transitionToState: 'complete', completeSpanAndAnnotation: undefined, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: undefined, }, ) const context = { definition, input, recordedItemsByLabel: {}, recordedItems, } const result = convertTraceToRUM({ traceRecording, context }) // Check rounded values in embeddedSpans const embeddedSpan = result.embeddedSpans['component-render|test-component'] if (embeddedSpan) { expect(Number.isInteger(embeddedSpan.totalDuration)).toBe(true) expect(Number.isInteger(embeddedSpan.spans[0]!.startOffset)).toBe(true) expect(Number.isInteger(embeddedSpan.spans[0]!.duration)).toBe(true) expect(Number.isInteger(embeddedSpan.spans[1]!.startOffset)).toBe(true) expect(Number.isInteger(embeddedSpan.spans[1]!.duration)).toBe(true) // Check specific rounded values expect(embeddedSpan.spans[0]!.startOffset).toBe(101) // 100.501 rounded expect(embeddedSpan.spans[0]!.duration).toBe(50) // 50.499 rounded expect(embeddedSpan.spans[1]!.startOffset).toBe(200) // 200.001 rounded expect(embeddedSpan.spans[1]!.duration).toBe(51) // 50.999 rounded expect(result.nonEmbeddedSpans).toEqual([]) } }) it('should return correct non embedded spans', () => { const definition: CompleteTraceDefinition< 'ticket', TicketIdRelationSchemasFixture, 'origin' > = { name: 'test-trace', relationSchemaName: 'ticket', relationSchema: { ticketId: String }, requiredSpans: [() => true], computedSpanDefinitions: {}, computedValueDefinitions: {}, variants: { origin: { timeout: 45_000 }, }, } const input: ActiveTraceInput< MapTypesToSchema<TicketIdRelationSchemasFixture['ticket']>, 'origin' > = { id: 'test', startTime: createTimestamp(0), relatedTo: { ticketId: '74' }, variant: 'origin', } const recordedItems = new Map([ createMockSpanAndAnnotation(100.501, { name: 'test-component', type: 'component-render', relatedTo: {}, duration: 50.499, isIdle: false, renderCount: 1, renderedOutput: 'loading', }), createMockSpanAndAnnotation( 200.001, { name: 'test-component', type: 'component-render', relatedTo: {}, duration: 50.999, isIdle: true, renderCount: 2, renderedOutput: 'content', }, { occurrence: 2 }, ), ]) const traceRecording = createTraceRecording( { definition, input, recordedItemsByLabel: {}, recordedItems, }, { transitionFromState: 'active', lastRelevantSpanAndAnnotation: undefined, transitionToState: 'complete', completeSpanAndAnnotation: undefined, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: undefined, }, ) const context = { definition, input, recordedItemsByLabel: {}, recordedItems, } // we dont want to return any embedded spans const result = convertTraceToRUM({ traceRecording, context, embedSpanSelector: () => false, }) expect(Object.keys(result.embeddedSpans)).toHaveLength(0) expect(result.nonEmbeddedSpans).toEqual(['component-render|test-component']) expect(result.nonEmbeddedSpans).toHaveLength(1) }) it('should identify the longest span correctly', () => { const definition: CompleteTraceDefinition< 'ticket', TicketIdRelationSchemasFixture, 'origin' > = { name: 'test-trace', relationSchemaName: 'ticket', relationSchema: { ticketId: String }, requiredSpans: [() => true], computedSpanDefinitions: {}, computedValueDefinitions: {}, variants: { origin: { timeout: 45_000 }, }, } const input: ActiveTraceInput< MapTypesToSchema<TicketIdRelationSchemasFixture['ticket']>, 'origin' > = { id: 'test', startTime: createTimestamp(0), relatedTo: { ticketId: '74' }, variant: 'origin', } const recordedItems = new Map([ createMockSpanAndAnnotation(100, { name: 'short-component', type: 'component-render', relatedTo: {}, duration: 30, isIdle: false, renderCount: 1, renderedOutput: 'loading', }), createMockSpanAndAnnotation(200, { name: 'long-component', type: 'component-render', relatedTo: {}, duration: 150, isIdle: true, renderCount: 1, renderedOutput: 'content', }), createMockSpanAndAnnotation(300, { name: 'medium-component', type: 'component-render', relatedTo: {}, duration: 75, isIdle: false, renderCount: 1, renderedOutput: 'error', }), ]) const traceRecording = createTraceRecording( { definition, input, recordedItemsByLabel: {}, recordedItems, }, { transitionFromState: 'active', lastRelevantSpanAndAnnotation: undefined, transitionToState: 'complete', completeSpanAndAnnotation: undefined, cpuIdleSpanAndAnnotation: undefined, lastRequiredSpanAndAnnotation: undefined, }, ) const context = { definition, input, recordedItemsByLabel: {}, recordedItems, } const result = convertTraceToRUM({ traceRecording, context }) // Should identify the longest span expect(result.longestSpan).toBeDefined() assert(result.longestSpan) expect(result.longestSpan.span.name).toBe('long-component') expect(result.longestSpan.span.duration).toBe(150) expect(result.longestSpan.span.type).toBe('component-render') expect(result.longestSpan.key).toBe('component-render|long-component') }) }) describe('recursivelyRoundValues', () => { it('should handle null and undefined values', () => { const input = { nullValue: null, undefinedValue: undefined, number: 1.5, } const result = recursivelyRoundValues(input) expect(result.nullValue).toBe(null) expect(result.undefinedValue).toBe(undefined) expect(result.number).toBe(2) }) it('should return null/undefined when passed as root object', () => { expect(recursivelyRoundValues(null as any)).toBe(null) expect(recursivelyRoundValues(undefined as any)).toBe(undefined) }) it('should round numeric values using default Math.round', () => { const input = { integer: 42, positiveFloat: 3.141_59, negativeFloat: -2.718_28, zero: 0, largeNumber: 1_234_567.89, } const result = recursivelyRoundValues(input) expect(result.integer).toBe(42) expect(result.positiveFloat).toBe(3) expect(result.negativeFloat).toBe(-3) expect(result.zero).toBe(0) expect(result.largeNumber).toBe(1_234_568) }) it('should use custom round function when provided', () => { const input = { value1: 3.141_59, value2: 2.718_28, } const customRound = (x: number) => Math.floor(x * 10) / 10 // Round to 1 decimal const result = recursivelyRoundValues(input, customRound) expect(result.value1).toBe(3.1) expect(result.value2).toBe(2.7) }) it('should preserve non-numeric primitive values', () => { const input = { string: 'hello world', boolean: true, booleanFalse: false, bigint: 123n, symbol: Symbol('test'), } const result = recursivelyRoundValues(input) expect(result.string).toBe('hello world') expect(result.boolean).toBe(true) expect(result.booleanFalse).toBe(false) expect(result.bigint).toBe(123n) expect(result.symbol).toBe(input.symbol) }) it('should handle nested objects recursively', () => { const input = { level1: { number: 1.7, level2: { anotherNumber: 2.3, level3: { deepNumber: 3.9, }, }, }, } const result = recursivelyRoundValues(input) expect(result.level1.number).toBe(2) expect(result.level1.level2.anotherNumber).toBe(2) expect(result.level1.level2.level3.deepNumber).toBe(4) }) it('should handle arrays with mixed types', () => { const input = { mixedArray: [1.5, 'string', 2.7, true, null, undefined, { nested: 3.2 }], } const result = recursivelyRoundValues(input) expect(result.mixedArray).toEqual([ 2, // 1.5 rounded 'string', // preserved 3, // 2.7 rounded true, // preserved null, // preserved undefined, // preserved { nested: 3 }, // nested object processed ]) }) it('should handle arrays with null/undefined elements', () => { const input = { arrayWithNulls: [1.5, null, 2.7, undefined, 3.9], nestedArrays: [ [1.1, null], [undefined, 2.9], ], } const result = recursivelyRoundValues(input) expect(result.arrayWithNulls).toEqual([2, null, 3, undefined, 4]) expect(result.nestedArrays).toEqual([ [1, null], [undefined, 3], ]) }) it('should handle complex nested structures', () => { const input = { data: { metrics: { duration: 123.456, startTime: 789.012, spans: [ { offset: 10.7, duration: 20.3 }, { offset: 30.9, duration: null }, null, { offset: undefined, duration: 40.1 }, ], }, metadata: { version: '1.0.0', enabled: true, config: { timeout: 5_000.99, retries: 3.14, }, }, }, } const result = recursivelyRoundValues(input) expect(result.data.metrics.duration).toBe(123) expect(result.data.metrics.startTime).toBe(789) expect(result.data.metrics.spans[0]).toEqual({ offset: 11, duration: 20 }) expect(result.data.metrics.spans[1]).toEqual({ offset: 31, duration: null }) expect(result.data.metrics.spans[2]).toBe(null) expect(result.data.metrics.spans[3]).toEqual({ offset: undefined, duration: 40, }) expect(result.data.metadata.version).toBe('1.0.0') expect(result.data.metadata.enabled).toBe(true) expect(result.data.metadata.config.timeout).toBe(5_001) expect(result.data.metadata.config.retries).toBe(3) }) it('should handle empty objects and arrays', () => { const input = { emptyObject: {}, emptyArray: [], nestedEmpty: { inner: {}, innerArray: [], }, } const result = recursivelyRoundValues(input) expect(result.emptyObject).toEqual({}) expect(result.emptyArray).toEqual([]) expect(result.nestedEmpty.inner).toEqual({}) expect(result.nestedEmpty.innerArray).toEqual([]) }) it('should not mutate the original object', () => { const input = { number: 1.5, nested: { array: [2.7, { value: 3.9 }], }, } const originalInput = JSON.parse(JSON.stringify(input)) recursivelyRoundValues(input) expect(input).toEqual(originalInput) // Original should be unchanged }) })