@zendesk/retrace
Version:
define and capture Product Operation Traces along with computed metrics with an optional friendly React beacon API
507 lines (457 loc) • 16.3 kB
text/typescript
import { ensureMatcherFn } from './ensureMatcherFn'
import { ensureTimestamp } from './ensureTimestamp'
import type { SpanMatch, SpanMatcherFn } from './matchSpan'
import type { SpanAndAnnotation } from './spanAnnotationTypes'
import type { DraftTraceConfig, StartTraceConfig } from './spanTypes'
import { type AllPossibleTraces, Trace } from './Trace'
import {
type AllPossibleTraceContexts,
type CompleteTraceDefinition,
type ComputedSpanDefinitionInput,
type ComputedValueDefinitionInput,
type DraftTraceContext,
type RelationSchemasBase,
type TraceChildUtilities,
type TraceDefinitionModifications,
type TraceManagerUtilities,
type TraceModifications,
type TraceUtilities,
type TransitionDraftOptions,
} from './types'
/**
* Look for an adopting parent for the given trace definition
*/
function lookForAdoptingParent<
SelectedRelationNameT extends keyof RelationSchemasT,
RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
VariantsT extends string,
>(
tracerDef: CompleteTraceDefinition<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>,
globalUtils: TraceManagerUtilities<RelationSchemasT>,
): AllPossibleTraces<RelationSchemasT> | undefined {
const maybeParent = globalUtils.getCurrentTrace()
if (!maybeParent) return undefined
// First check if the immediate parent can adopt
if (maybeParent.canAdoptChild(tracerDef.name)) {
return maybeParent
}
// Breadth-first search through children
const queue: AllPossibleTraces<RelationSchemasT>[] = [...maybeParent.children]
while (queue.length > 0) {
const current = queue.shift()!
if (current.canAdoptChild(tracerDef.name)) {
return current
}
// Add current trace's children to the end of the queue for breadth-first traversal
queue.push(...current.children)
}
return undefined
}
function buildTraceUtilities<
RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
>(
utilities:
| TraceManagerUtilities<RelationSchemasT>
| TraceUtilities<RelationSchemasT>,
): TraceUtilities<RelationSchemasT> {
const traceUtilities: TraceUtilities<RelationSchemasT> = {
...utilities,
// every trace gets its own deduplication strategy instance:
performanceEntryDeduplicationStrategy:
utilities.getPerformanceEntryDeduplicationStrategy?.(),
parentTraceRef: undefined,
}
// TODO: make traceUtilities into a class instance,
// and require that instance as the Trace property
// to indicate to TS this object needs to be passed by reference,
// and can not be spread into another one
// (because that looses the reference to the parentTraceRef, causing bugs!)
return traceUtilities
}
/**
* Build child-scoped trace utilities that delegate getCurrentTrace and replaceCurrentTrace
* to work properly with child traces while maintaining parent-child relationships
*/
function buildChildUtilities<
RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
>(
getChildTrace: () => AllPossibleTraces<RelationSchemasT> | undefined,
parent: AllPossibleTraces<RelationSchemasT>,
): TraceChildUtilities<RelationSchemasT> {
const utilities: TraceChildUtilities<RelationSchemasT> = {
// reporting and errors continue to use the original functions
...parent.traceUtilities,
// parent can be swapped out, so we store it here:
parentTraceRef: parent,
// redirect "current trace" queries to return the itself when asked
getCurrentTrace: getChildTrace,
}
utilities.onTraceEnd = (trace, finalTransition, recording) => {
utilities.parentTraceRef.onChildEnd(trace, finalTransition, recording)
}
// handle replacing the current trace in the context of parent-child relationships
utilities.replaceCurrentTrace = (getNewTrace, reason) => {
switch (reason) {
case 'another-trace-started': {
const newTrace = getNewTrace()
// as a child, starting another trace doesn't actually replace it,
// only adds a sibiling to the parent
utilities.parentTraceRef.adoptChild(newTrace)
return newTrace
}
case 'definition-changed': {
// For other reasons, interrupt the current child and adopt the new one
const currentChild = getChildTrace()
if (currentChild) {
currentChild.interrupt({ reason })
}
const newTrace = getNewTrace()
utilities.parentTraceRef.adoptChild(newTrace) // adds to children
return newTrace
}
default: {
const newTrace = getNewTrace()
utilities.parentTraceRef.traceUtilities.reportErrorFn(
new Error(`Unexpected reason for replacing current trace: ${reason}`),
{
definition: newTrace.sourceDefinition as CompleteTraceDefinition<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any,
RelationSchemasT,
string
>,
} as Partial<AllPossibleTraceContexts<RelationSchemasT, string>>,
)
return newTrace
}
}
}
return utilities
}
/**
* Recursively search for a child trace with the specified definition
*/
function findChildWithDefinition<
SelectedRelationNameT extends keyof RelationSchemasT,
RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
VariantsT extends string,
>(
trace: AllPossibleTraces<RelationSchemasT>,
targetDefinition: CompleteTraceDefinition<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>,
): Trace<SelectedRelationNameT, RelationSchemasT, VariantsT> | undefined {
// TOOD: switch to breadth-first
for (const child of trace.children) {
if (child.sourceDefinition === targetDefinition) {
return child as Trace<SelectedRelationNameT, RelationSchemasT, VariantsT>
}
// Recursively search in grandchildren
const found = findChildWithDefinition(child, targetDefinition)
if (found) {
return found
}
}
return undefined
}
/**
* Tracer can create draft traces and start traces
*/
export class Tracer<
const SelectedRelationNameT extends keyof RelationSchemasT,
const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
const VariantsT extends string,
> {
private definition: CompleteTraceDefinition<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>
private rootTraceUtilities: TraceManagerUtilities<RelationSchemasT>
constructor(
definition: CompleteTraceDefinition<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>,
rootTraceUtilities: TraceManagerUtilities<RelationSchemasT>,
) {
this.definition = definition
this.rootTraceUtilities = rootTraceUtilities
}
/**
* @returns The ID of the trace.
*/
start = (
input: StartTraceConfig<RelationSchemasT[SelectedRelationNameT], VariantsT>,
definitionModifications?: TraceDefinitionModifications<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>,
): string | undefined => {
const trace = this.createDraftInternal(input)
trace?.transitionDraftToActive({
relatedTo: input.relatedTo,
...definitionModifications,
})
return trace?.input.id
}
createDraft = (
input: Omit<
DraftTraceConfig<RelationSchemasT[SelectedRelationNameT], VariantsT>,
'relatedTo'
>,
definitionModifications?: TraceDefinitionModifications<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>,
): string | undefined =>
this.createDraftInternal(input, definitionModifications)?.input.id
private createDraftInternal = (
input: Omit<
DraftTraceConfig<RelationSchemasT[SelectedRelationNameT], VariantsT>,
'relatedTo'
>,
definitionModifications?: TraceDefinitionModifications<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>,
): Trace<SelectedRelationNameT, RelationSchemasT, VariantsT> | undefined => {
const id = input.id ?? this.rootTraceUtilities.generateId('trace')
// Look for an adopting parent according to the nested proposal
let parentTrace = lookForAdoptingParent(
this.definition,
this.rootTraceUtilities,
)
let trace: Trace<SelectedRelationNameT, RelationSchemasT, VariantsT>
if (parentTrace) {
// if the child trace started, the parent *must* wait for it to end
// update the parentTrace to requireToEnd the new child trace:
parentTrace = parentTrace.recreateTraceWithDefinitionModifications({
additionalRequiredSpans: [
{ type: 'operation', name: this.definition.name, id },
],
})
// Create child utilities with a getter function that will return the child trace
let childTrace:
| Trace<SelectedRelationNameT, RelationSchemasT, VariantsT>
| undefined
const getChildTrace = () => childTrace
const utilities = buildChildUtilities(getChildTrace, parentTrace)
// Create the trace with child utilities
trace = new Trace<SelectedRelationNameT, RelationSchemasT, VariantsT>({
definition: this.definition,
input: {
...input,
// relatedTo will be overwritten later during initialization of the trace
relatedTo: undefined,
startTime: ensureTimestamp(input.startTime),
id,
parentTraceId: parentTrace.input.id,
},
definitionModifications,
traceUtilities: utilities,
})
// Store reference for the getter function
childTrace = trace
parentTrace.adoptChild(trace) // F-1/F-2 behaviour
// we do not replace the singleton currentTrace in TraceManager
} else {
// it's a new root trace
trace = this.rootTraceUtilities.replaceCurrentTrace(
// Create the trace with normal utilities
() =>
new Trace<SelectedRelationNameT, RelationSchemasT, VariantsT>({
definition: this.definition,
input: {
...input,
// relatedTo will be overwritten later during initialization of the trace
relatedTo: undefined,
startTime: ensureTimestamp(input.startTime),
id,
},
definitionModifications,
traceUtilities: buildTraceUtilities(this.rootTraceUtilities),
}),
'another-trace-started',
)
}
return trace
}
interrupt = ({ error }: { error?: Error } = {}) => {
const trace = this.getCurrentTraceOrWarn()
if (!trace) return
if (error) {
trace.processSpan({
id: this.rootTraceUtilities.generateId('span'),
name: error.name,
startTime: ensureTimestamp(),
type: 'error',
status: 'error',
relatedTo: { ...trace.input.relatedTo },
attributes: {},
duration: 0,
error,
getParentSpan: () => undefined,
})
trace.interrupt({ reason: 'aborted' })
return
}
if (trace.isDraft) {
trace.interrupt({ reason: 'draft-cancelled' })
return
}
trace.interrupt({ reason: 'aborted' })
}
/**
* Adds additional required spans or debounce spans to the current trace *only*.
* Note: This recreates the Trace instance with the modified definition and replays all the spans.
*/
addRequirementsToCurrentTraceOnly = (
definitionModifications: TraceDefinitionModifications<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>,
): void => {
const trace = this.getCurrentTraceOrWarn()
if (!trace) return
trace.recreateTraceWithDefinitionModifications(definitionModifications)
}
// can have config changed until we move into active
// from input: relatedTo (required), attributes (optional, merge into)
// from definition, can add items to: requiredSpans (additionalRequiredSpans), debounceOnSpans (additionalDebounceOnSpans)
// documentation: interruption still works and all the other events are buffered
transitionDraftToActive = (
inputAndDefinitionModifications: TraceModifications<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>,
opts?: TransitionDraftOptions,
): void => {
const trace = this.getCurrentTraceOrWarn()
if (!trace) return
trace.transitionDraftToActive(inputAndDefinitionModifications, opts)
}
private getCurrentTraceInternal = ():
| Trace<SelectedRelationNameT, RelationSchemasT, VariantsT>
| undefined => {
const rootTrace = this.rootTraceUtilities.getCurrentTrace()
if (!rootTrace) {
return undefined
}
// verify that trace is the same definition as the Tracer's definition
if (rootTrace.sourceDefinition === this.definition) {
return rootTrace
}
const foundChild = findChildWithDefinition(rootTrace, this.definition)
if (foundChild) {
return foundChild
}
return undefined
}
/**
* @returns The current Trace's context if it exists anywhere in the trace tree,
* and matches the Tracer's definition.
*/
getCurrentTrace = ():
| DraftTraceContext<SelectedRelationNameT, RelationSchemasT, VariantsT>
| undefined => this.getCurrentTraceInternal()
// same as getCurrentTrace, but with a warning if no trace or a different trace is found
private getCurrentTraceOrWarn = ():
| Trace<SelectedRelationNameT, RelationSchemasT, VariantsT>
| undefined => {
const trace = this.getCurrentTraceInternal()
if (trace) {
return trace
}
const rootTrace = this.rootTraceUtilities.getCurrentTrace()
if (!rootTrace) {
// No active trace at all
this.rootTraceUtilities.reportWarningFn(
new Error(
`No current active trace when initializing a trace. Call tracer.start(...) or tracer.createDraft(...) beforehand.`,
),
{ definition: this.definition } as Partial<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DraftTraceContext<any, RelationSchemasT, any>
>,
)
return undefined
}
this.rootTraceUtilities.reportWarningFn(
new Error(
`Trying to find an active '${this.definition.name}' trace, however the started root trace (${rootTrace.sourceDefinition.name}) has a different definition`,
),
{ definition: this.definition } as Partial<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DraftTraceContext<any, RelationSchemasT, any>
>,
)
return undefined
}
/**
* Dynamically add a computed span to the trace definition.
* Will apply to any trace created *after* calling this function.
*/
defineComputedSpan = (
definition: ComputedSpanDefinitionInput<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
> & { name: string },
): void => {
this.definition.computedSpanDefinitions[definition.name] = {
startSpan:
typeof definition.startSpan === 'string'
? definition.startSpan
: ensureMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT>(
definition.startSpan,
),
endSpan:
typeof definition.endSpan === 'string'
? definition.endSpan
: ensureMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT>(
definition.endSpan,
),
}
}
/**
* Dynamically add a computed value to the trace definition.
* Will apply to any trace created *after* calling this function.
*/
defineComputedValue = <
const MatchersT extends SpanMatch<
SelectedRelationNameT,
RelationSchemasT,
VariantsT
>[],
>(
definition: ComputedValueDefinitionInput<
SelectedRelationNameT,
RelationSchemasT,
VariantsT,
MatchersT
> & { name: string },
): void => {
const convertedMatches = definition.matches.map<
SpanMatcherFn<SelectedRelationNameT, RelationSchemasT, VariantsT>
>((m) => ensureMatcherFn(m))
this.definition.computedValueDefinitions[definition.name] = {
matches: convertedMatches,
computeValueFromMatches: definition.computeValueFromMatches as (
...matches: (readonly SpanAndAnnotation<RelationSchemasT>[])[]
) => number | string | boolean | undefined,
}
}
}