@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
text/typescript
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