UNPKG

@zendesk/retrace

Version:

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

1,425 lines (1,225 loc) 103 kB
import './testUtility/asciiTimelineSerializer' import { afterEach, beforeEach, describe, expect, it, type Mock, vitest, } from 'vitest' import * as matchSpan from './matchSpan' import { type TicketAndUserAndGlobalRelationSchemasFixture, ticketAndUserAndGlobalRelationSchemasFixture, type TicketIdRelationSchemasFixture, } from './testUtility/fixtures/relationSchemas' import { Check, getSpansFromTimeline, LongTask, Render, } from './testUtility/makeTimeline' import { processSpans } from './testUtility/processSpans' import { TraceManager } from './TraceManager' import type { AnyPossibleReportFn, GenerateIdFn, ReportErrorFn } from './types' describe('TraceManager - Child Traces (Nested Proposal)', () => { let reportFn: Mock<AnyPossibleReportFn<TicketIdRelationSchemasFixture>> // TS doesn't like that reportFn is wrapped in Mock<> type const getReportFn = () => reportFn as AnyPossibleReportFn<TicketIdRelationSchemasFixture> let generateId: Mock<GenerateIdFn> let reportErrorFn: Mock<ReportErrorFn<TicketIdRelationSchemasFixture>> const DEFAULT_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() reportErrorFn = vitest.fn() }) afterEach(() => { vitest.clearAllMocks() vitest.clearAllTimers() }) describe('Basic Child Adoption (F-1, F-2)', () => { it('should adopt child trace instead of interrupting parent when child trace name is in adoptAsChildren', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer that can adopt 'child-operation' traces const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: ['ticket.child-operation'], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create child tracer const childTracer = traceManager.createTracer({ name: 'ticket.child-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start parent trace const parentTraceId = parentTracer.start({ id: 'parent-trace-id', relatedTo: { ticketId: '1' }, variant: 'default', }) expect(parentTraceId).toBe('parent-trace-id') // Start child trace - should be adopted, not interrupt parent const childTraceId = childTracer.start({ id: 'child-trace-id', relatedTo: { ticketId: '1' }, variant: 'default', }) expect(childTraceId).toBe('child-trace-id') // the Trace Manager should still have a reference to the parent trace, child never overrides it: expect(traceManager.currentTraceContext?.input.id).toBe('parent-trace-id') // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-start', 0)}---${Render('child-end', 0)}---${Render('parent-end', 0)} Time: ${0} ${50} ${100} ${150} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalledTimes(2) // Both parent and child should complete // Verify parent trace completed with child const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] expect(parentReport).toBeDefined() expect(parentReport?.status).toBe('ok') expect(parentReport?.interruption).toBeUndefined() expect(parentReport?.parentTraceId).toBeUndefined() // Parent has no parent // Verify child trace completed const childReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.child-operation', )?.[0] expect(childReport).toBeDefined() expect(childReport?.status).toBe('ok') expect(childReport?.interruption).toBeUndefined() expect(childReport?.parentTraceId).toBe('parent-trace-id') // Child adopted by parent expect(reportErrorFn).not.toHaveBeenCalled() }) it('should interrupt parent trace when child trace name is NOT in adoptAsChildren (existing behavior)', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer that does NOT adopt 'other-operation' traces const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: ['ticket.child-operation'], // Only adopts child-operation, not other-operation variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create tracer for a different operation that should interrupt const otherTracer = traceManager.createTracer({ name: 'ticket.other-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'other-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start parent trace const parentTraceId = parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) expect(parentTraceId).toBe('trace-0') // Start other trace - should interrupt parent (existing behavior) const otherTraceId = otherTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) expect(otherTraceId).toBe('trace-1') // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('other-end', 0)} Time: ${0} ${50} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalledTimes(2) // Verify parent trace was interrupted const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] expect(parentReport).toBeDefined() expect(parentReport?.status).toBe('interrupted') expect(parentReport?.interruption).toMatchObject({ reason: 'another-trace-started', }) expect(parentReport?.parentTraceId).toBeUndefined() // Parent has no parent // Verify other trace completed const otherReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.other-operation', )?.[0] expect(otherReport).toBeDefined() expect(otherReport?.status).toBe('ok') expect(otherReport?.interruption).toBeUndefined() expect(otherReport?.parentTraceId).toBeUndefined() // Other trace has no parent (it interrupted) expect(reportErrorFn).not.toHaveBeenCalled() }) it('should handle multiple children being adopted by the same parent', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer that can adopt multiple child trace types const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: [ 'ticket.child-operation-1', 'ticket.child-operation-2', ], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create first child tracer const childTracer1 = traceManager.createTracer({ name: 'ticket.child-operation-1', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-1-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create second child tracer const childTracer2 = traceManager.createTracer({ name: 'ticket.child-operation-2', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-2-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start parent trace const parentTraceId = parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) expect(parentTraceId).toBe('trace-0') // Start first child trace - should be adopted const childTraceId1 = childTracer1.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) expect(childTraceId1).toBe('trace-1') // Start second child trace - should also be adopted const childTraceId2 = childTracer2.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) expect(childTraceId2).toBe('trace-2') // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-1-start', 0)}---${Render('child-2-start', 0)}---${Render('child-1-end', 0)}---${Render('child-2-end', 0)}---${Render('parent-end', 0)} Time: ${0} ${25} ${50} ${75} ${100} ${125} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalledTimes(3) // Parent and both children should complete // Verify parent trace completed const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] expect(parentReport).toBeDefined() expect(parentReport?.status).toBe('ok') expect(parentReport?.interruption).toBeUndefined() expect(parentReport?.parentTraceId).toBeUndefined() // Parent has no parent // Verify first child trace completed const child1Report = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.child-operation-1', )?.[0] expect(child1Report).toBeDefined() expect(child1Report?.status).toBe('ok') expect(child1Report?.interruption).toBeUndefined() expect(child1Report?.parentTraceId).toBe('trace-0') // Child-1 adopted by parent // Verify second child trace completed const child2Report = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.child-operation-2', )?.[0] expect(child2Report).toBeDefined() expect(child2Report?.status).toBe('ok') expect(child2Report?.interruption).toBeUndefined() expect(child2Report?.parentTraceId).toBe('trace-0') // Child-2 adopted by parent expect(reportErrorFn).not.toHaveBeenCalled() }) it('should transition parent to waiting-for-children state immediately after completing its own requirements', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer that can adopt children const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: ['ticket.child-operation'], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create child tracer const childTracer = traceManager.createTracer({ name: 'ticket.child-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start parent trace parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // Start child trace childTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-start', 0)}---${Render('parent-end', 0)}---${Check} Time: ${0} ${50} ${100} ${150} ` // Process only parent completion first - child is still running processSpans(spans.slice(0, 3), traceManager) // Parent should not have reported yet (waiting for child) expect(reportFn).not.toHaveBeenCalled() // Now complete the child // prettier-ignore const { spans: childEndSpans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('child-end', 0)} Time: ${200} ` processSpans(childEndSpans, traceManager) // Now both should be reported expect(reportFn).toHaveBeenCalledTimes(2) // Verify parent completed after child const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] expect(parentReport).toBeDefined() expect(parentReport?.status).toBe('ok') expect(parentReport?.interruption).toBeUndefined() expect(parentReport?.parentTraceId).toBeUndefined() // Parent has no parent expect(reportErrorFn).not.toHaveBeenCalled() }) }) describe('Parent Completion (F-3)', () => { it('should transition parent from waiting-for-children to complete when all children finish', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: [ 'ticket.child-operation-1', 'ticket.child-operation-2', ], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create child tracers const childTracer1 = traceManager.createTracer({ name: 'ticket.child-operation-1', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-1-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) const childTracer2 = traceManager.createTracer({ name: 'ticket.child-operation-2', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-2-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start traces parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) childTracer1.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) childTracer2.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // Complete parent first, but children are still running // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-1-start', 0)}---${Render('child-2-start', 0)}---${Render('parent-end', 0)} Time: ${0} ${25} ${50} ${75} ` processSpans(spans, traceManager) // Parent should not be completed yet (waiting for children) expect(reportFn).not.toHaveBeenCalled() // Complete first child // prettier-ignore const { spans: child1EndSpans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('child-1-end', 0)} Time: ${100} ` processSpans(child1EndSpans, traceManager) // Still waiting for second child expect(reportFn).toHaveBeenCalledTimes(1) // Only child-1 should be reported // Complete second child // prettier-ignore const { spans: child2EndSpans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('child-2-end', 0)} Time: ${125} ` processSpans(child2EndSpans, traceManager) // Now all should be completed expect(reportFn).toHaveBeenCalledTimes(3) // Both children + parent // Verify all traces completed successfully const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] expect(parentReport).toBeDefined() expect(parentReport?.status).toBe('ok') expect(parentReport?.interruption).toBeUndefined() expect(parentReport?.parentTraceId).toBeUndefined() // Parent has no parent expect(reportErrorFn).not.toHaveBeenCalled() }) it('should transition parent directly to complete if no children exist when entering waiting-for-children', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer that CAN adopt children but none are started const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: ['ticket.child-operation'], // Can adopt but won't variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start only parent trace (no children) parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('parent-end', 0)} Time: ${0} ${50} ` processSpans(spans, traceManager) // Parent should complete immediately since no children exist expect(reportFn).toHaveBeenCalledTimes(1) const parentReport = reportFn.mock.calls[0]![0] expect(parentReport.name).toBe('ticket.parent-operation') expect(parentReport.status).toBe('ok') expect(parentReport.interruption).toBeUndefined() expect(parentReport.parentTraceId).toBeUndefined() // Parent has no parent }) it('should maintain parent in waiting-for-children state while any child is still running', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: ['ticket.child-operation'], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create child tracer with long-running requirement const childTracer = traceManager.createTracer({ name: 'ticket.child-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start traces parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) childTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // Complete parent but keep child running // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-start', 0)}---${Render('parent-end', 0)}---${Check} Time: ${0} ${25} ${50} ${1_000} ` processSpans(spans, traceManager) // Parent should still be waiting (not reported) expect(reportFn).not.toHaveBeenCalled() // Now complete the child // prettier-ignore const { spans: childEndSpans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('child-end', 0)} Time: ${6_050} ` processSpans(childEndSpans, traceManager) // Now both should complete expect(reportFn).toHaveBeenCalledTimes(2) const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] expect(parentReport).toBeDefined() expect(parentReport?.status).toBe('ok') expect(parentReport?.interruption).toBeUndefined() expect(parentReport?.parentTraceId).toBeUndefined() // Parent has no parent expect(reportErrorFn).not.toHaveBeenCalled() }) }) describe('Parent Interruption Propagation (F-4)', () => { it('should interrupt all children with parent-interrupted when parent is interrupted manually', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer that can be interrupted const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], interruptOnSpans: [matchSpan.withName('interrupt')], adoptAsChildren: ['ticket.child-operation'], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create child tracer const childTracer = traceManager.createTracer({ name: 'ticket.child-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start traces parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) childTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // Interrupt parent manually // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-start', 0)}---${Render('interrupt', 0)} Time: ${0} ${25} ${50} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalledTimes(2) // Both parent and child should be interrupted // Verify parent was interrupted const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] expect(parentReport).toBeDefined() expect(parentReport?.status).toBe('interrupted') expect(parentReport?.interruption).toMatchObject({ reason: 'matched-on-interrupt', }) expect(parentReport?.parentTraceId).toBeUndefined() // Parent has no parent // Verify child was interrupted with parent-interrupted const childReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.child-operation', )?.[0] expect(childReport).toBeDefined() expect(childReport?.status).toBe('interrupted') expect(childReport?.interruption).toMatchObject({ reason: 'parent-interrupted', }) expect(childReport?.parentTraceId).toBe('trace-0') // Child was adopted by parent expect(reportErrorFn).not.toHaveBeenCalled() }) it('should interrupt all children with parent-interrupted when parent times out', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer with short timeout const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: ['ticket.child-operation'], variants: { default: { timeout: 100 }, // Very short timeout }, }) // Create child tracer const childTracer = traceManager.createTracer({ name: 'ticket.child-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start traces parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) childTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-start', 0)}---<===+125ms===>----${Check} Time: ${0} ${25} ${150} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalledTimes(2) // Both parent and child should be interrupted // Verify parent timed out const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] expect(parentReport).toBeDefined() expect(parentReport?.status).toBe('interrupted') expect(parentReport?.interruption).toMatchObject({ reason: 'timeout' }) expect(parentReport?.parentTraceId).toBeUndefined() // Parent has no parent // Verify child was interrupted with parent-interrupted const childReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.child-operation', )?.[0] expect(childReport).toBeDefined() expect(childReport?.status).toBe('interrupted') expect(childReport?.interruption).toMatchObject({ reason: 'parent-interrupted', }) expect(childReport?.parentTraceId).toBe('trace-0') // Child was adopted by parent expect(reportErrorFn).not.toHaveBeenCalled() }) it('should clear children set when parent is interrupted', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], interruptOnSpans: [matchSpan.withName('interrupt')], adoptAsChildren: ['ticket.child-operation'], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create child tracer const childTracer = traceManager.createTracer({ name: 'ticket.child-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start traces parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) childTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // Verify traces are running expect(traceManager.currentTraceContext).toBeDefined() // Interrupt parent // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-start', 0)}---${Render('interrupt', 0)} Time: ${0} ${25} ${50} ` processSpans(spans, traceManager) // Both traces should be interrupted and cleaned up expect(reportFn).toHaveBeenCalledTimes(2) // Verify no active traces remain (memory cleaned up) // This is a basic check - in real implementation, we'd verify children sets are cleared expect(traceManager.currentTraceContext).toBeUndefined() // Verify parentTraceId relationships const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] const childReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.child-operation', )?.[0] expect(parentReport?.parentTraceId).toBeUndefined() // Parent has no parent expect(childReport?.parentTraceId).toBe('trace-0') // Child was adopted by parent expect(reportErrorFn).not.toHaveBeenCalled() }) }) describe('Child Interruption Propagation (F-5, F-6)', () => { it('should interrupt parent with child-timeout when child times out', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: ['ticket.child-operation'], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create child tracer with short timeout const childTracer = traceManager.createTracer({ name: 'ticket.child-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-end' }], variants: { default: { timeout: 100 }, // Very short timeout }, }) // Start traces parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) childTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-start', 0)}---<===+150ms===>----${Check} Time: ${0} ${25} ${150} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalledTimes(2) // Both child and parent should be interrupted // Verify child timed out const childReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.child-operation', )?.[0] expect(childReport).toBeDefined() expect(childReport?.status).toBe('interrupted') expect(childReport?.interruption).toMatchObject({ reason: 'timeout' }) expect(childReport?.parentTraceId).toBe('trace-0') // Child was adopted by parent // Verify parent was interrupted due to child timeout const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] expect(parentReport).toBeDefined() expect(parentReport?.status).toBe('interrupted') expect(parentReport?.interruption).toMatchObject({ reason: 'child-timeout', }) expect(parentReport?.parentTraceId).toBeUndefined() // Parent has no parent expect(reportErrorFn).not.toHaveBeenCalled() }) it('should interrupt parent with child-interrupted when child is interrupted for other reasons', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: ['ticket.child-operation'], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create child tracer that can be interrupted const childTracer = traceManager.createTracer({ name: 'ticket.child-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-end' }], interruptOnSpans: [matchSpan.withName('child-interrupt')], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start traces parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) childTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // Interrupt child manually // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-start', 0)}---${Render('child-interrupt', 0)} Time: ${0} ${25} ${50} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalledTimes(2) // Both child and parent should be interrupted // Verify child was interrupted const childReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.child-operation', )?.[0] expect(childReport).toBeDefined() expect(childReport?.status).toBe('interrupted') expect(childReport?.interruption).toMatchObject({ reason: 'matched-on-interrupt', }) expect(childReport?.parentTraceId).toBe('trace-0') // Child was adopted by parent // Verify parent was interrupted due to child interruption const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] expect(parentReport).toBeDefined() expect(parentReport?.status).toBe('interrupted') expect(parentReport?.interruption).toMatchObject({ reason: 'child-interrupted', }) expect(parentReport?.parentTraceId).toBeUndefined() // Parent has no parent expect(reportErrorFn).not.toHaveBeenCalled() }) it('should NOT interrupt parent when child is interrupted with child-swap reason', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: ['ticket.child-operation'], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create child tracer const childTracer = traceManager.createTracer({ name: 'ticket.child-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start parent and child parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) const childId = childTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // Modify the child tracer's definition - this should cause child-swap childTracer.addRequirementsToCurrentTraceOnly({ additionalRequiredSpans: [{ name: 'new-requirement' }], }) // Complete child and parent // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-start', 0)}---${Render('child-end', 0)}---${Render('parent-end', 0)}--${Render('new-requirement', 0)} Time: ${0} ${25} ${75} ${125} ${175} ` processSpans(spans, traceManager) // Should get reports for: new child (ok), parent (ok) expect(reportFn).toHaveBeenCalledTimes(2) const completedChildReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.child-operation' && call[0].status === 'ok', )?.[0] // Verify new child completed successfully expect(completedChildReport?.status).toBe('ok') expect(completedChildReport?.interruption).toBeUndefined() expect(completedChildReport?.parentTraceId).toBe('trace-0') // Verify parent was NOT interrupted (continued successfully) const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] expect(parentReport?.status).toBe('ok') expect(parentReport?.interruption).toBeUndefined() expect(parentReport?.parentTraceId).toBeUndefined() expect(reportErrorFn).not.toHaveBeenCalled() }) it('should handle onChildEnd event and remove child from children set', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: ['ticket.child-operation'], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create child tracer const childTracer = traceManager.createTracer({ name: 'ticket.child-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start traces parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) childTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // Complete child first // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-start', 0)}---${Render('child-end', 0)} Time: ${0} ${25} ${75} ` processSpans(spans, traceManager) // Child should be reported expect(reportFn).toHaveBeenCalledTimes(1) const childReport = reportFn.mock.calls[0]![0] expect(childReport.name).toBe('ticket.child-operation') expect(childReport.status).toBe('ok') expect(childReport.parentTraceId).toBe('trace-0') // Parent should still be waiting, not yet reported // Now complete parent // prettier-ignore const { spans: parentEndSpans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-end', 0)} Time: ${125} ` processSpans(parentEndSpans, traceManager) // Now parent should also be reported expect(reportFn).toHaveBeenCalledTimes(2) const parentReport = reportFn.mock.calls[1]![0] expect(parentReport.name).toBe('ticket.parent-operation') expect(parentReport.status).toBe('ok') expect(parentReport.parentTraceId).toBeUndefined() expect(reportErrorFn).not.toHaveBeenCalled() }) it('should move completed child to completedChildren set', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) // Create parent tracer const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: ['ticket.child-operation'], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Create child tracer const childTracer = traceManager.createTracer({ name: 'ticket.child-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start traces parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) childTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // Complete child, then parent // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-start', 0)}---${Render('child-end', 0)}---${Render('parent-end', 0)} Time: ${0} ${25} ${75} ${125} ` processSpans(spans, traceManager) // Both should be reported expect(reportFn).toHaveBeenCalledTimes(2) const childReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.child-operation', )?.[0] const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] // Child completed and was moved to completedChildren expect(childReport?.status).toBe('ok') expect(childReport?.parentTraceId).toBe('trace-0') // Parent completed after all children expect(parentReport?.status).toBe('ok') expect(parentReport?.parentTraceId).toBeUndefined() expect(reportErrorFn).not.toHaveBeenCalled() }) }) describe('Span Forwarding (F-7)', () => { it('should forward spans from parent to all running children after processing in parent', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: ['ticket.child-operation'], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) const childTracer = traceManager.createTracer({ name: 'ticket.child-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start parent and child parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) childTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-start', 0)}---${Render('shared-span', 50)}---${Render('child-end', 0)}---${Render('parent-end', 0)} Time: ${0} ${25} ${75} ${125} ${175} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalledTimes(2) // Verify both parent and child received the shared-span const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] const childReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.child-operation', )?.[0] expect(parentReport?.status).toBe('ok') expect(childReport?.status).toBe('ok') // Verify parentTraceId relationships expect(parentReport?.parentTraceId).toBeUndefined() // Parent has no parent expect(childReport?.parentTraceId).toBe('trace-0') // Child was adopted by parent // Both should have processed the shared-span const parentSpanNames = parentReport?.entries.map( (entry) => entry.span.performanceEntry?.name, ) const childSpanNames = childReport?.entries.map( (entry) => entry.span.performanceEntry?.name, ) expect(parentSpanNames).toContain('shared-span') expect(childSpanNames).toContain('shared-span') expect(reportErrorFn).not.toHaveBeenCalled() }) it('should not forward spans to children that have already completed', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: getReportFn(), generateId, reportErrorFn, reportWarningFn: reportErrorFn, }) const parentTracer = traceManager.createTracer({ name: 'ticket.parent-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'parent-end' }], adoptAsChildren: ['ticket.child-operation'], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) const childTracer = traceManager.createTracer({ name: 'ticket.child-operation', type: 'operation', relationSchemaName: 'ticket', requiredSpans: [{ name: 'child-end' }], variants: { default: { timeout: DEFAULT_TIMEOUT_DURATION }, }, }) // Start parent and child parentTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) childTracer.start({ relatedTo: { ticketId: '1' }, variant: 'default', }) // Complete child first, then add span to parent, then complete parent // prettier-ignore const { spans } = getSpansFromTimeline<TicketIdRelationSchemasFixture>` Events: ${Render('parent-start', 0)}---${Render('child-start', 0)}---${Render('child-end', 0)}---${Render('late-span', 50)}---${Render('parent-end', 0)} Time: ${0} ${25} ${75} ${125} ${175} ` processSpans(spans, traceManager) expect(reportFn).toHaveBeenCalledTimes(2) const childReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.child-operation', )?.[0] const parentReport = reportFn.mock.calls.find( (call) => call[0].name === 'ticket.parent-operation', )?.[0] // Parent should have the late-span const parentSpanNames = parentReport?.entries.map( (entry) => entry.span.performanceEntry?.name, ) expect(parentSpanNames).toContain('late-span') // Child should NOT have the late-span (it completed before the span arrived) const childSpanNames = childReport?.entries.map( (entry) => entry.span.performanceEntry?.name, ) expect(childSpanNames).not.toContain('late-span') expect(childReport?.status).toBe('ok') expect(parentReport?.status).toBe('ok') expect(reportErrorFn).not.toHaveBeenCalled() }) it('should handle span forwarding when children are in different states', () => { const traceManager = new TraceManager({ relationSchemas: { ticket: { ticketId: String } }, reportFn: get