UNPKG

@zendesk/retrace

Version:

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

1,370 lines (1,157 loc) 42.3 kB
import { promisify } from 'node:util' import { afterEach, assert, beforeEach, describe, expect, it, type Mock, type MockInstance, vitest, } from 'vitest' import type { ProcessedSpan } from './spanAnnotationTypes' import { type ComponentRenderSpan, type ErrorSpan, type GetParentSpanContext, PARENT_SPAN, type PerformanceEntrySpan, type Span, } from './spanTypes' import type { TicketIdRelationSchemasFixture } from './testUtility/fixtures/relationSchemas' import { TICK_META } from './TickParentResolver' import type { Trace } from './Trace' import { TraceManager } from './TraceManager' import type { AnyPossibleReportFn } from './types' const waitOneTick = promisify(setImmediate) describe('creating spans', () => { let reportFn: Mock<AnyPossibleReportFn<TicketIdRelationSchemasFixture>> let generateId: Mock let reportErrorFn: Mock let traceManager: TraceManager<TicketIdRelationSchemasFixture> vitest.useFakeTimers({ now: 0, }) let id = 0 beforeEach(() => { reportFn = vitest.fn() id = 0 generateId = vitest.fn(() => `id-${id++}`) reportErrorFn = vitest.fn() traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: reportFn as AnyPossibleReportFn<TicketIdRelationSchemasFixture>, generateId, reportErrorFn, enableTickTracking: true, }) }) afterEach(() => { vitest.clearAllMocks() vitest.clearAllTimers() }) describe('TraceManager with tick tracking enabled', () => { it('should have tickParentResolver initialized when enableTickTracking is true', () => { expect(traceManager.tickParentResolver).toBeDefined() }) it('should not have tickParentResolver when enableTickTracking is false', () => { const traceManagerWithoutTicks = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: reportFn as AnyPossibleReportFn<TicketIdRelationSchemasFixture>, generateId, reportErrorFn, enableTickTracking: false, }) expect(traceManagerWithoutTicks.tickParentResolver).toBeUndefined() }) it('should assign tickId to spans when processing them', () => { const span = traceManager.ensureCompleteSpan({ type: 'mark', name: 'test-span', }) traceManager.processSpan(span) expect(span.tickId).toBeDefined() // first generated id goes to TickParentResolver, second to span expect(span.tickId).toBe('id-0') expect(span.id).toBe('id-1') }) }) describe('startSpan and endSpan functionality', () => { it('should create and process start span correctly', () => { const startResult: ProcessedSpan< TicketIdRelationSchemasFixture, PerformanceEntrySpan<TicketIdRelationSchemasFixture> > = traceManager.createAndProcessSpan({ type: 'mark', name: 'operation-start', relatedTo: { ticketId: '123' }, }) expect(startResult.span).toBeDefined() expect(startResult.span.tickId).toBe('id-0') expect(startResult.span.id).toBe('id-1') expect(startResult.span.name).toBe('operation-start') expect(startResult.span.type).toBe('mark') expect(startResult.span.relatedTo).toEqual({ ticketId: '123' }) expect(startResult.annotations).toBeUndefined() // no active trace }) it('should create and process end span with reference to start span', () => { const startSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'operation-start', relatedTo: { ticketId: '123' }, }) const endResult = traceManager.endSpan(startSpan, { name: 'operation-end', duration: 100, }) expect(endResult.span).toBeDefined() expect(endResult.span).toBe(startSpan) expect(endResult.span.name).toBe('operation-end') expect(endResult.span.duration).toBe(100) expect(endResult.span.relatedTo).toEqual({ ticketId: '123' }) expect(endResult.span.tickId).toBeDefined() }) it('should handle startRenderSpan and endRenderSpan', () => { const startResult = traceManager.startRenderSpan({ name: 'MyComponent', relatedTo: { ticketId: '123' }, isIdle: false, renderCount: 1, renderedOutput: 'loading', }) expect(startResult.span.type).toBe('component-render-start') expect(startResult.span.name).toBe('MyComponent') expect(startResult.span.isIdle).toBe(false) expect(startResult.span.renderCount).toBe(1) const endResult = traceManager.endRenderSpan(startResult.span, { duration: 50, isIdle: true, }) expect(endResult.span.type).toBe('component-render') expect(endResult.span.duration).toBe(50) expect(endResult.span.isIdle).toBe(true) expect(endResult.span).toBe(startResult.span) }) }) describe('processErrorSpan functionality', () => { it('should create and process error span', () => { const error = new Error('Test error') const errorResult: ProcessedSpan< TicketIdRelationSchemasFixture, ErrorSpan<TicketIdRelationSchemasFixture> > = traceManager.processErrorSpan({ error, relatedTo: { ticketId: '123' }, }) const parent = errorResult.resolveParent() expect(errorResult.span).toBeDefined() expect(errorResult.span.name).toBe('Error') expect(errorResult.span.type).toBe('error') expect(errorResult.span.status).toBe('error') expect(errorResult.span.error).toBe(error) expect(errorResult.span.relatedTo).toEqual({ ticketId: '123' }) expect(errorResult.span.tickId).toBeDefined() expect(parent).toBeUndefined() // no parent found }) it('should handle custom error span name and type', () => { const error = new Error('Custom error') const errorResult = traceManager.processErrorSpan({ error, name: 'CustomErrorName', relatedTo: { ticketId: '456' }, }) expect(errorResult.span.name).toBe('CustomErrorName') expect(errorResult.span.type).toBe('error') expect(errorResult.span.status).toBe('error') }) }) describe('createAndProcessSpan functionality', () => { it('should create and process any type of span', () => { const spanResult: ProcessedSpan< TicketIdRelationSchemasFixture, PerformanceEntrySpan<TicketIdRelationSchemasFixture> > = traceManager.createAndProcessSpan({ type: 'measure', name: 'custom-measure', duration: 200, relatedTo: { ticketId: '789' }, attributes: { customAttribute: 'value' }, }) expect(spanResult.span).toBeDefined() expect(spanResult.span.type).toBe('measure') expect(spanResult.span.name).toBe('custom-measure') expect(spanResult.span.duration).toBe(200) expect(spanResult.span.relatedTo).toEqual({ ticketId: '789' }) expect(spanResult.span.attributes).toEqual({ customAttribute: 'value' }) expect(spanResult.span.tickId).toBeDefined() expect(spanResult.resolveParent()).toBeUndefined() // no parent resolved }) it('should handle component render span creation', () => { const renderResult: ProcessedSpan< TicketIdRelationSchemasFixture, ComponentRenderSpan<TicketIdRelationSchemasFixture> > = traceManager.createAndProcessSpan({ type: 'component-render', name: 'TestComponent', isIdle: true, renderCount: 3, relatedTo: { ticketId: '999' }, duration: 25, renderedOutput: 'content', }) expect(renderResult.span.type).toBe('component-render') expect(renderResult.span.isIdle).toBe(true) expect(renderResult.span.renderCount).toBe(3) expect(renderResult.span.duration).toBe(25) }) }) describe('parent span resolution with parentSpanMatcher', () => { let activeTrace: string | undefined beforeEach(() => { // Create a tracer to enable trace recording const tracer = traceManager.createTracer({ name: 'test-operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { default: { timeout: 10_000 }, }, }) // Start a trace to have an active context activeTrace = tracer.start({ relatedTo: { ticketId: '123' }, variant: 'default', }) }) it('should create getParentSpan function from parentSpanMatcher for span-created-tick search', () => { // Create spans with parent resolution in the same tick const parentSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'parent-span', relatedTo: { ticketId: '123' }, }) traceManager.processSpan(parentSpan) const childSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'child-span', relatedTo: { ticketId: '123' }, parentSpanMatcher: { search: 'span-created-tick', searchDirection: 'before-self', match: { name: 'parent-span' }, }, }) expect(childSpan.getParentSpan).toBeDefined() expect(typeof childSpan.getParentSpan).toBe('function') // Process the child span traceManager.processSpan(childSpan) // trace hasn't ended yet: expect(reportFn).not.toHaveBeenCalled() const trace = traceManager.currentTraceContext assert(trace) const childSpanAndAnnotation = trace.recordedItems.get(childSpan.id) assert(childSpanAndAnnotation) expect(childSpanAndAnnotation.span).toBe(childSpan) const getParentSpan: MockInstance< NonNullable<typeof childSpan.getParentSpan> > = vitest.spyOn(childSpan, 'getParentSpan') const endSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'end', relatedTo: { ticketId: '123' }, }) // complete the trace to trigger parent resolution traceManager.processSpan(endSpan) // trace should have finished expect(traceManager.currentTraceContext).toBeUndefined() expect(reportFn).toHaveBeenCalledTimes(1) expect(getParentSpan).toHaveBeenCalledTimes(1) expect(getParentSpan).toHaveReturnedWith(parentSpan) // The parentSpanMatcher should have generated a working getParentSpan expect(childSpan[PARENT_SPAN]).toBe(parentSpan) }) it('should create getParentSpan function from parentSpanMatcher for span-created-tick search with after-self direction', () => { // Process the child span const childSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'child-span', relatedTo: { ticketId: '123' }, parentSpanMatcher: { search: 'span-created-tick', searchDirection: 'after-self', match: { name: 'parent-span' }, }, }) expect(childSpan.getParentSpan).toBeDefined() expect(typeof childSpan.getParentSpan).toBe('function') traceManager.processSpan(childSpan) // Create parent span in the same tick AFTER the child span const parentSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'parent-span', relatedTo: { ticketId: '123' }, }) traceManager.processSpan(parentSpan) // trace hasn't ended yet: expect(reportFn).not.toHaveBeenCalled() const trace = traceManager.currentTraceContext assert(trace) const childSpanAndAnnotation = trace.recordedItems.get(childSpan.id) assert(childSpanAndAnnotation) expect(childSpanAndAnnotation.span).toBe(childSpan) const getParentSpan: MockInstance< NonNullable<typeof childSpan.getParentSpan> > = vitest.spyOn(childSpan, 'getParentSpan') const endSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'end', relatedTo: { ticketId: '123' }, }) // complete the trace to trigger parent resolution traceManager.processSpan(endSpan) // trace should have finished expect(traceManager.currentTraceContext).toBeUndefined() expect(reportFn).toHaveBeenCalledTimes(1) expect(getParentSpan).toHaveBeenCalledTimes(1) expect(getParentSpan).toHaveReturnedWith(parentSpan) // The parentSpanMatcher should have generated a working getParentSpan expect(childSpan[PARENT_SPAN]).toBe(parentSpan) }) it('should create getParentSpan function from parentSpanMatcher for entire-recording search', async () => { // Create parent span first const parentSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'parent-span', relatedTo: { ticketId: '123' }, }) traceManager.processSpan(parentSpan) await waitOneTick() // trace hasn't ended yet: expect(reportFn).not.toHaveBeenCalled() const childSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'child-span', relatedTo: { ticketId: '123' }, parentSpanMatcher: { search: 'entire-recording', searchDirection: 'before-self', match: { type: 'mark', name: 'parent-span' }, }, }) expect(childSpan.getParentSpan).toBeDefined() // Process the child span and complete trace traceManager.processSpan(childSpan) const endSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'end', relatedTo: { ticketId: '123' }, }) traceManager.processSpan(endSpan) // trace should have finished expect(traceManager.currentTraceContext).toBeUndefined() // The parentSpanMatcher should have generated a working getParentSpan expect(childSpan[PARENT_SPAN]).toBe(parentSpan) }) }) describe('tick tracking in same event loop tick', () => { it('should assign the same tickId to spans created in the same synchronous execution', () => { const span1 = traceManager.ensureCompleteSpan({ type: 'mark', name: 'span1', }) traceManager.processSpan(span1) const span2 = traceManager.ensureCompleteSpan({ type: 'mark', name: 'span2', }) traceManager.processSpan(span2) const span3 = traceManager.ensureCompleteSpan({ type: 'mark', name: 'span3', }) traceManager.processSpan(span3) expect(span1.tickId).toBe(span2.tickId) expect(span2.tickId).toBe(span3.tickId) expect(span1.tickId).toBeDefined() }) it('should assign different tickIds to spans created in different ticks', async () => { const span1 = traceManager.ensureCompleteSpan({ type: 'mark', name: 'span1', }) traceManager.processSpan(span1) // Ensure all microtasks are processed await waitOneTick() const span2 = traceManager.ensureCompleteSpan({ type: 'mark', name: 'span2', }) traceManager.processSpan(span2) expect(span1.tickId).not.toBe(span2.tickId) expect(span1.tickId).toBeDefined() expect(span2.tickId).toBeDefined() }) }) describe('convenience span creation methods', () => { it('should create performance entry spans using makePerformanceEntrySpan', () => { const span = traceManager.makePerformanceEntrySpan({ type: 'measure', name: 'custom-measure', relatedTo: { ticketId: '123' }, duration: 50, }) expect(span.type).toBe('measure') expect(span.name).toBe('custom-measure') expect(span.duration).toBe(50) expect(span.id).toBeDefined() expect(span.startTime).toBeDefined() }) it('should create render spans using makeRenderSpan', () => { const span = traceManager.makeRenderSpan({ type: 'component-render', name: 'MyComponent', isIdle: true, renderCount: 5, relatedTo: { ticketId: '456' }, renderedOutput: 'content', }) expect(span.type).toBe('component-render') expect(span.name).toBe('MyComponent') expect(span.isIdle).toBe(true) expect(span.renderCount).toBe(5) expect(span.id).toBeDefined() expect(span.startTime).toBeDefined() }) }) describe('ensureCompleteSpan functionality', () => { it('should auto-generate id when not provided', () => { const span = traceManager.ensureCompleteSpan({ type: 'mark', name: 'test-span', }) expect(span.id).toBe('id-1') expect(span.startTime).toBeDefined() expect(span.attributes).toEqual({}) expect(span.duration).toBe(0) }) it('should use provided id when given', () => { const span = traceManager.ensureCompleteSpan({ type: 'mark', name: 'test-span', id: 'custom-id', }) expect(span.id).toBe('custom-id') }) it('should merge provided attributes', () => { const span = traceManager.ensureCompleteSpan({ type: 'mark', name: 'test-span', attributes: { custom: 'value', other: 123 }, }) expect(span.attributes).toEqual({ custom: 'value', other: 123 }) }) it('should handle partial startTime', () => { const span = traceManager.ensureCompleteSpan({ type: 'mark', name: 'test-span', startTime: { now: 100 }, }) expect(span.startTime.now).toBe(100) expect(span.startTime.epoch).toBeDefined() }) }) describe('edge cases and error handling', () => { it('should handle spans without tick tracking', () => { const traceManagerNoTicks = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: reportFn as AnyPossibleReportFn<TicketIdRelationSchemasFixture>, generateId, reportErrorFn, enableTickTracking: false, }) const span = traceManagerNoTicks.ensureCompleteSpan({ type: 'mark', name: 'test-span', }) const processed = traceManagerNoTicks.processSpan(span) expect(span.tickId).toBeUndefined() expect(span.id).toBeDefined() }) }) describe('updateSpan functionality', () => { let activeTrace: string | undefined beforeEach(() => { // Create a tracer to enable trace recording const tracer = traceManager.createTracer({ name: 'test-operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { default: { timeout: 10_000 }, }, }) // Start a trace to have an active context activeTrace = tracer.start({ relatedTo: { ticketId: '123' }, variant: 'default', }) }) it('should merge object properties like attributes', () => { const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'test-span', relatedTo: { ticketId: '123' }, attributes: { originalProp: 'original', keepThis: 'value1', }, }) // Update attributes - should merge with existing attributes result.updateSpan({ attributes: { originalProp: 'updated', newProp: 'added', }, }) expect(result.span.attributes).toEqual({ originalProp: 'updated', keepThis: 'value1', newProp: 'added', }) }) it('should merge relatedTo object properties', () => { const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'test-span', relatedTo: { ticketId: '123' }, }) // Update relatedTo - should merge with existing relatedTo result.updateSpan({ relatedTo: { ticketId: '456' }, }) expect(result.span.relatedTo).toEqual({ ticketId: '456' }) }) it('should update component render span specific properties', () => { const result = traceManager.createAndProcessSpan< ComponentRenderSpan<TicketIdRelationSchemasFixture> >({ type: 'component-render', name: 'TestComponent', relatedTo: { ticketId: '123' }, isIdle: false, renderCount: 1, renderedOutput: 'loading', attributes: {}, }) // Update component-specific properties result.updateSpan({ isIdle: true, renderedOutput: 'content', }) expect(result.span.isIdle).toBe(true) expect(result.span.renderedOutput).toBe('content') }) it('should handle undefined values to remove properties from objects', () => { const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'test-span', relatedTo: { ticketId: '123' }, attributes: { prop1: 'value1', prop2: 'value2', }, }) // Update attributes to remove prop1 by setting it to undefined result.updateSpan({ attributes: { prop1: undefined, prop3: 'new value', }, }) expect(result.span.attributes).toEqual({ prop1: undefined, prop2: 'value2', prop3: 'new value', }) }) it('should ignore updates when trace has changed', () => { const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'test-span', relatedTo: { ticketId: '123' }, }) const originalAttributes = { ...result.span.attributes } // Complete the current trace by adding the required end span const endSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'end', relatedTo: { ticketId: '123' }, }) traceManager.processSpan(endSpan) // Now the trace has ended, updateSpan should be ignored result.updateSpan({ attributes: { shouldNotUpdate: 'ignored' }, }) // Span should remain unchanged expect(result.span.attributes).toEqual(originalAttributes) }) it('should work with error spans', () => { const error = new Error('Test error') const result = traceManager.processErrorSpan({ error, relatedTo: { ticketId: '123' }, attributes: { errorType: 'validation' }, }) // Update error span attributes result.updateSpan({ attributes: { errorType: 'network', severity: 'high', }, }) expect(result.span.attributes).toEqual({ errorType: 'network', severity: 'high', }) expect(result.span.error).toBe(error) // Error object should remain unchanged }) it('should update multiple properties in one call for component render spans', () => { const result = traceManager.createAndProcessSpan< ComponentRenderSpan<TicketIdRelationSchemasFixture> >({ type: 'component-render', name: 'TestComponent', relatedTo: { ticketId: '123' }, isIdle: false, renderCount: 1, renderedOutput: 'loading', attributes: { version: '1.0' }, }) // Update multiple properties at once result.updateSpan({ isIdle: true, renderedOutput: 'content', attributes: { version: '2.0', updated: true, }, }) expect(result.span.isIdle).toBe(true) expect(result.span.renderedOutput).toBe('content') expect(result.span.attributes).toEqual({ version: '2.0', updated: true, }) }) it('should not affect span matching since spans are processed synchronously', () => { // This test documents the caveat mentioned in the updateSpan documentation const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'test-span', relatedTo: { ticketId: '123' }, attributes: { matchable: false }, }) // Update the attribute after processing result.updateSpan({ attributes: { matchable: true }, }) // The span's attribute is updated expect(result.span.attributes?.matchable).toBe(true) // But if there were matchers that relied on this attribute, // they would have already been evaluated during processSpan() // This test just documents this behavior - the actual matching // logic would be tested in the tracer/matcher tests }) it('should not do anything without an active trace', () => { const endSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'end', relatedTo: { ticketId: '123' }, }) traceManager.processSpan(endSpan) // Create a span without an active trace const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'standalone-span', }) const originalAttributes = { ...result.span.attributes } // updateSpan should be a no-op, as there is no active trace result.updateSpan({ attributes: { updated: true }, }) expect(result.span.attributes).toEqual(originalAttributes) }) it('should only allow updating specific properties defined in UpdatableSpanProperties', () => { const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'test-span', relatedTo: { ticketId: '123' }, attributes: { prop: 'value' }, }) // These properties should be updatable result.updateSpan({ attributes: { newProp: 'new' }, relatedTo: { ticketId: '456' }, }) expect(result.span.attributes).toEqual({ prop: 'value', newProp: 'new' }) expect(result.span.relatedTo).toEqual({ ticketId: '456' }) // Properties like name, duration, id, etc. are not in UpdatableSpanProperties // so they cannot be updated via updateSpan (this is by design) }) it('should preserve object references when merging', () => { const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'test-span', relatedTo: { ticketId: '123' }, attributes: { nested: { prop1: 'value1' }, topLevel: 'keep' }, }) const originalAttributesRef = result.span.attributes // Update should merge into the existing attributes object result.updateSpan({ attributes: { nested: { prop2: 'value2' }, newTopLevel: 'added' }, }) // The attributes object reference should remain the same expect(result.span.attributes).toBe(originalAttributesRef) // Top-level properties should be merged (Object.assign behavior) expect(result.span.attributes).toEqual({ nested: { prop2: 'value2' }, // nested object is replaced, not merged topLevel: 'keep', newTopLevel: 'added', }) }) }) describe('span re-processing after updateSpan', () => { let activeTrace: string | undefined beforeEach(() => { // Create a tracer to enable trace recording const tracer = traceManager.createTracer({ name: 'reprocessing-test', relationSchemaName: 'ticket', requiredSpans: [ { name: 'test-span', attributes: { status: 'ready' } }, { name: 'final-span', attributes: { completed: true } }, ], variants: { default: { timeout: 10_000 }, }, }) // Start a trace to have an active context activeTrace = tracer.start({ relatedTo: { ticketId: '123' }, variant: 'default', }) }) it('should re-evaluate matchers when updateSpan is called', () => { // Create a span with the correct name but wrong attribute const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'test-span', relatedTo: { ticketId: '123' }, attributes: { status: 'pending' }, // Wrong status, should be 'ready' }) // Verify the trace is still active (required spans not met) expect( ( traceManager.currentTraceContext as Trace< 'ticket', TicketIdRelationSchemasFixture, 'default' > )?.stateMachine?.currentState, ).toBe('active') // Update the span attributes to match the requirement - this should trigger re-processing result.updateSpan({ attributes: { status: 'ready' }, // Now matches the required attribute }) // Verify the attribute was updated expect(result.span.attributes?.status).toBe('ready') // Create the second required span to complete the trace const finalSpan = traceManager.createAndProcessSpan({ type: 'mark', name: 'final-span', relatedTo: { ticketId: '123' }, attributes: { completed: true }, }) // The trace should now be complete expect(traceManager.currentTraceContext).toBeUndefined() expect(reportFn).toHaveBeenCalledTimes(1) }) it('should re-evaluate attribute-based matchers when attributes are updated', () => { // End the current trace first const endCurrentTrace = traceManager.ensureCompleteSpan({ type: 'mark', name: 'final-span', relatedTo: { ticketId: '123' }, attributes: { completed: true }, }) traceManager.processSpan(endCurrentTrace) // Create a tracer that requires a specific attribute value const attributeTracer = traceManager.createTracer({ name: 'attribute-test', relationSchemaName: 'ticket', requiredSpans: [ { name: 'test-span', attributes: { required: true } }, { name: 'end' }, ], variants: { default: { timeout: 10_000 }, }, }) // Start the new trace attributeTracer.start({ relatedTo: { ticketId: '456' }, variant: 'default', }) // Create a span without the required attribute const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'test-span', relatedTo: { ticketId: '456' }, attributes: { required: false, other: 'value' }, }) // Verify the trace is still active (required span not matched due to attribute) expect( ( traceManager.currentTraceContext as Trace< 'ticket', TicketIdRelationSchemasFixture, 'default' > )?.stateMachine?.currentState, ).toBe('active') // Update the span attributes to match the requirement result.updateSpan({ attributes: { required: true, additional: 'new' }, }) // Verify the attributes were merged correctly expect(result.span.attributes).toEqual({ required: true, other: 'value', additional: 'new', }) // Add the end span to complete the trace const endSpan = traceManager.createAndProcessSpan({ type: 'mark', name: 'end', relatedTo: { ticketId: '456' }, }) // The trace should now be complete expect(traceManager.currentTraceContext).toBeUndefined() expect(reportFn).toHaveBeenCalledTimes(2) // Original trace + this new one }) it('should not re-process spans after trace has ended', () => { const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'test-span', relatedTo: { ticketId: '123' }, attributes: { original: 'value' }, }) // Complete the trace const initialSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'test-span', relatedTo: { ticketId: '123' }, attributes: { status: 'ready' }, }) traceManager.processSpan(initialSpan) const finalSpan = traceManager.ensureCompleteSpan({ type: 'mark', name: 'final-span', relatedTo: { ticketId: '123' }, attributes: { completed: true }, }) traceManager.processSpan(finalSpan) // Trace should be complete expect(traceManager.currentTraceContext).toBeUndefined() const originalAttributes = { ...result.span.attributes } // Try to update the span after trace completion - should be ignored result.updateSpan({ attributes: { updated: 'ignored' }, }) // Attributes should remain unchanged expect(result.span.attributes).toEqual(originalAttributes) }) it('should preserve span object references during re-processing', () => { const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'test-span', relatedTo: { ticketId: '123' }, attributes: { prop: 'value' }, }) const originalSpanRef = result.span const originalAttributesRef = result.span.attributes // Update the span result.updateSpan({ attributes: { newProp: 'newValue' }, }) // Object references should be preserved expect(result.span).toBe(originalSpanRef) expect(result.span.attributes).toBe(originalAttributesRef) // But content should be updated expect(result.span.attributes).toEqual({ prop: 'value', newProp: 'newValue', }) }) it('should handle multiple updateSpan calls correctly', () => { const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'evolving-span', relatedTo: { ticketId: '123' }, attributes: { step: 1 }, }) // First update result.updateSpan({ attributes: { step: 2, phase: 'early' }, }) expect(result.span.attributes).toEqual({ step: 2, phase: 'early', }) // Second update result.updateSpan({ attributes: { step: 3, phase: 'late', final: true }, }) expect(result.span.attributes).toEqual({ step: 3, phase: 'late', final: true, }) // Third update - partial result.updateSpan({ attributes: { phase: 'complete' }, }) expect(result.span.attributes).toEqual({ step: 3, phase: 'complete', final: true, }) }) }) describe('findSpanInParentHierarchy functionality', () => { let activeTrace: string | undefined beforeEach(() => { // Create a tracer to enable trace recording const tracer = traceManager.createTracer({ name: 'test-operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'end' }], variants: { default: { timeout: 10_000 }, }, }) // Start a trace to have an active context activeTrace = tracer.start({ relatedTo: { ticketId: '123' }, variant: 'default', }) }) it('should find the span itself when it matches the criteria', () => { const result = traceManager.createAndProcessSpan({ type: 'mark', name: 'target-span', relatedTo: { ticketId: '123' }, attributes: { category: 'test' }, }) const found = traceManager.findSpanInParentHierarchy(result.span, { name: 'target-span', }) expect(found).toBeDefined() expect(found?.id).toBe(result.span.id) expect(found?.name).toBe('target-span') }) it('should find parent span when child does not match but parent does', () => { // Create grandparent const grandparentResult = traceManager.createAndProcessSpan({ type: 'mark', name: 'grandparent', relatedTo: { ticketId: '123' }, attributes: { level: 'root' }, }) // Create parent with explicit parentSpan const parentResult = traceManager.createAndProcessSpan({ type: 'mark', name: 'parent', relatedTo: { ticketId: '123' }, parentSpan: grandparentResult.span, attributes: { level: 'middle' }, }) // Create child with explicit parentSpan const childResult = traceManager.createAndProcessSpan({ type: 'mark', name: 'child', relatedTo: { ticketId: '123' }, parentSpan: parentResult.span, attributes: { level: 'leaf' }, }) // Search for parent from child const found = traceManager.findSpanInParentHierarchy(childResult.span, { name: 'parent', }) expect(found).toBeDefined() expect(found?.id).toBe(parentResult.span.id) expect(found?.name).toBe('parent') }) it('should traverse multiple levels to find matching ancestor', () => { // Create a hierarchy: root -> middle -> leaf const rootResult = traceManager.createAndProcessSpan({ type: 'mark', name: 'root', relatedTo: { ticketId: '123' }, attributes: { category: 'system' }, }) const middleResult = traceManager.createAndProcessSpan({ type: 'mark', name: 'middle', relatedTo: { ticketId: '123' }, parentSpan: rootResult.span, attributes: { category: 'business' }, }) const leafResult = traceManager.createAndProcessSpan({ type: 'mark', name: 'leaf', relatedTo: { ticketId: '123' }, parentSpan: middleResult.span, attributes: { category: 'ui' }, }) // Search for root from leaf (should skip middle) const found = traceManager.findSpanInParentHierarchy(leafResult.span, { attributes: { category: 'system' }, }) expect(found).toBeDefined() expect(found?.id).toBe(rootResult.span.id) expect(found?.name).toBe('root') }) it('should return undefined when no match is found in hierarchy', () => { const parentResult = traceManager.createAndProcessSpan({ type: 'mark', name: 'parent', relatedTo: { ticketId: '123' }, }) const childResult = traceManager.createAndProcessSpan({ type: 'mark', name: 'child', relatedTo: { ticketId: '123' }, parentSpan: parentResult.span, }) // Search for non-existent span name const found = traceManager.findSpanInParentHierarchy(childResult.span, { name: 'non-existent', }) expect(found).toBeUndefined() }) it('should work with complex matchers', () => { const parentResult = traceManager.createAndProcessSpan({ type: 'measure', name: 'complex-parent', relatedTo: { ticketId: '123' }, attributes: { category: 'performance', priority: 'high' }, }) const childResult = traceManager.createAndProcessSpan({ type: 'mark', name: 'simple-child', relatedTo: { ticketId: '123' }, parentSpan: parentResult.span, attributes: { category: 'ui' }, }) // Use complex matcher with multiple conditions const found = traceManager.findSpanInParentHierarchy(childResult.span, { type: 'measure', attributes: { category: 'performance', priority: 'high' }, }) expect(found).toBeDefined() expect(found?.id).toBe(parentResult.span.id) expect(found?.type).toBe('measure') }) it('should work with function-based matchers', () => { const parentResult = traceManager.createAndProcessSpan({ type: 'mark', name: 'dynamic-parent', relatedTo: { ticketId: '123' }, attributes: { timestamp: Date.now() }, }) const childResult = traceManager.createAndProcessSpan({ type: 'mark', name: 'child', relatedTo: { ticketId: '123' }, parentSpan: parentResult.span, }) // Use function matcher const found = traceManager.findSpanInParentHierarchy( childResult.span, ({ span }) => span.name.startsWith('dynamic-'), ) expect(found).toBeDefined() expect(found?.id).toBe(parentResult.span.id) expect(found?.name).toBe('dynamic-parent') }) it('should stop traversal when parent span is not found in recorded items', () => { const childResult = traceManager.createAndProcessSpan({ type: 'mark', name: 'orphaned-child', relatedTo: { ticketId: '123' }, parentSpan: undefined, }) const found = traceManager.findSpanInParentHierarchy(childResult.span, { name: 'any-name', }) // Should only check the child itself, then stop when parent is not found expect(found).toBeUndefined() }) it('should work with component render spans', () => { const parentRenderResult = traceManager.createAndProcessSpan< ComponentRenderSpan<TicketIdRelationSchemasFixture> >({ type: 'component-render', name: 'ParentComponent', relatedTo: { ticketId: '123' }, isIdle: false, renderCount: 1, renderedOutput: 'content', }) const childRenderResult = traceManager.createAndProcessSpan< ComponentRenderSpan<TicketIdRelationSchemasFixture> >({ type: 'component-render', name: 'ChildComponent', relatedTo: { ticketId: '123' }, parentSpan: parentRenderResult.span, isIdle: false, renderCount: 2, renderedOutput: 'loading', }) const found = traceManager.findSpanInParentHierarchy( childRenderResult.span, { name: 'ParentComponent' }, ) expect(found).toBeDefined() expect(found?.id).toBe(parentRenderResult.span.id) expect( (found as ComponentRenderSpan<TicketIdRelationSchemasFixture>) .renderCount, ).toBe(1) }) it('should work with error spans', () => { const contextResult = traceManager.createAndProcessSpan({ type: 'mark', name: 'context-span', relatedTo: { ticketId: '123' }, attributes: { context: 'error-handling' }, }) const error = new Error('Test error') const errorResult = traceManager.processErrorSpan({ error, relatedTo: { ticketId: '123' }, parentSpan: contextResult.span, }) const found = traceManager.findSpanInParentHierarchy(errorResult.span, { attributes: { context: 'error-handling' }, }) expect(found).toBeDefined() expect(found?.id).toBe(contextResult.span.id) }) }) })