UNPKG

@zendesk/retrace

Version:

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

615 lines (567 loc) 19.9 kB
import type { Observable } from 'rxjs' import { Subject } from 'rxjs' import { createParentSpanResolver } from './createParentSpanResolver' import type { AllPossibleAddSpanToRecordingEvents, AllPossibleDefinitionModifiedEvents, AllPossibleRequiredSpanSeenEvents, AllPossibleStateTransitionEvents, AllPossibleTraceStartEvents, } from './debugTypes' import { convertLabelMatchersToFns, convertMatchersToFns, ensureMatcherFn, } from './ensureMatcherFn' import { ensureTimestamp } from './ensureTimestamp' import { findAncestor } from './findSpanInParentHierarchy' import { type SpanMatch } from './matchSpan' import type { ProcessedSpan } from './spanAnnotationTypes' import { type ComponentRenderSpan, type ConvenienceSpan, type ErrorSpan, type ErrorSpanInput, PARENT_SPAN, type PerformanceEntrySpan, type PerformanceEntrySpanInput, type RenderSpanInput, type Span, type SpanUpdateFunction, } from './spanTypes' import { TickParentResolver } from './TickParentResolver' import type { AllPossibleTraces } from './Trace' import { Tracer } from './Tracer' import type { AllPossibleTraceContexts, CompleteTraceDefinition, ComputedValueDefinitionInput, DraftTraceContext, RelationSchemasBase, ReportErrorFn, TraceDefinition, TraceManagerConfig, TraceManagerUtilities, } from './types' const START_TO_END_SPAN_TYPES = { 'component-render-start': 'component-render', 'hook-render-start': 'hook-render', mark: 'measure', } as const /** * Class representing the centralized trace manager. * Usually you'll have a single instance of this class in your app. */ export class TraceManager< const RelationSchemasT extends RelationSchemasBase<RelationSchemasT>, > { private currentTrace: AllPossibleTraces<RelationSchemasT> | undefined = undefined // Event subjects for all traces private eventSubjects = { 'trace-start': new Subject<AllPossibleTraceStartEvents<RelationSchemasT>>(), 'state-transition': new Subject< AllPossibleStateTransitionEvents<RelationSchemasT> >(), 'required-span-seen': new Subject< AllPossibleRequiredSpanSeenEvents<RelationSchemasT> >(), 'add-span-to-recording': new Subject< AllPossibleAddSpanToRecordingEvents<RelationSchemasT> >(), 'definition-modified': new Subject< AllPossibleDefinitionModifiedEvents<RelationSchemasT> >(), } get currentTraceContext(): | AllPossibleTraceContexts<RelationSchemasT, string> | undefined { if (!this.currentTrace) return undefined return this.currentTrace } tickParentResolver: TickParentResolver<RelationSchemasT> | undefined constructor({ enableTickTracking = true, ...configInput }: Omit<TraceManagerConfig<RelationSchemasT>, 'reportWarningFn'> & { reportWarningFn?: ReportErrorFn<RelationSchemasT> }) { this.utilities = { // by default noop for warnings reportWarningFn: () => {}, enableTickTracking, acceptSpansStartedBeforeTraceStartThreshold: 100, ...configInput, replaceCurrentTrace: (getNewTrace, reason) => { let newTrace: ReturnType<typeof getNewTrace> if (this.currentTrace) { if (reason === 'another-trace-started') { newTrace = getNewTrace() this.currentTrace.interrupt({ reason, anotherTrace: { id: newTrace.input.id, name: newTrace.definition.name, }, }) this.currentTrace = newTrace return newTrace } // the new trace needs to be created AFTER the current trace is interrupted // because the interrupt may unbuffer additional spans this.currentTrace.interrupt({ reason }) newTrace = getNewTrace() } else { newTrace = getNewTrace() } this.currentTrace = newTrace return newTrace }, onTraceConstructed: (newTrace) => { // Subscribe to the new trace's events and forward them to our subjects this.subscribeToTraceEvents(newTrace) // Emit trace-start event this.eventSubjects['trace-start'].next({ traceContext: newTrace, }) }, onTraceEnd: (endedTrace, finalTransition) => { // if (finalTransition?.interruption?.reason === 'definition-changed') if (endedTrace === this.currentTrace) { this.currentTrace = undefined } // warn on miss? }, getCurrentTrace: () => this.currentTrace, } this.tickParentResolver = enableTickTracking ? new TickParentResolver(this.utilities) : undefined } /** * Subscribe to events from a trace and forward them to the TraceManager subjects */ private subscribeToTraceEvents( trace: AllPossibleTraces<RelationSchemasT>, ): void { // Forward state transition events trace.when('state-transition').subscribe((event) => { this.eventSubjects['state-transition'].next(event) }) // Forward required span seen events trace.when('required-span-seen').subscribe((event) => { this.eventSubjects['required-span-seen'].next(event) }) // Forward add-span-to-recording events trace.when('add-span-to-recording').subscribe((event) => { this.eventSubjects['add-span-to-recording'].next(event) }) trace.when('definition-modified').subscribe((event) => { this.eventSubjects['definition-modified'].next(event) }) } /** * Observable for events from all traces * @param event The event type to observe * @returns An Observable that emits events of the specified type from all traces */ when( event: 'trace-start', ): Observable<AllPossibleTraceStartEvents<RelationSchemasT>> when( event: 'state-transition', ): Observable<AllPossibleStateTransitionEvents<RelationSchemasT>> when( event: 'required-span-seen', ): Observable<AllPossibleRequiredSpanSeenEvents<RelationSchemasT>> // New events when( event: 'add-span-to-recording', ): Observable<AllPossibleAddSpanToRecordingEvents<RelationSchemasT>> when( event: 'definition-modified', ): Observable<AllPossibleDefinitionModifiedEvents<RelationSchemasT>> when( event: | 'required-span-seen' | 'trace-start' | 'state-transition' | 'add-span-to-recording' | 'definition-modified', ): | Observable<AllPossibleTraceStartEvents<RelationSchemasT>> | Observable<AllPossibleStateTransitionEvents<RelationSchemasT>> | Observable<AllPossibleRequiredSpanSeenEvents<RelationSchemasT>> | Observable<AllPossibleAddSpanToRecordingEvents<RelationSchemasT>> | Observable<AllPossibleDefinitionModifiedEvents<RelationSchemasT>> { return this.eventSubjects[event].asObservable() } private utilities: TraceManagerUtilities<RelationSchemasT> createTracer< const SelectedRelationNameT extends keyof RelationSchemasT, const VariantsT extends string, const ComputedValueTuplesT extends { [K in keyof ComputedValueTuplesT]: SpanMatch< NoInfer<SelectedRelationNameT>, RelationSchemasT, NoInfer<VariantsT> >[] }, >( traceDefinition: TraceDefinition< SelectedRelationNameT, RelationSchemasT, VariantsT, { [K in keyof ComputedValueTuplesT]: ComputedValueDefinitionInput< NoInfer<SelectedRelationNameT>, RelationSchemasT, NoInfer<VariantsT>, ComputedValueTuplesT[K] > } >, ): Tracer<SelectedRelationNameT, RelationSchemasT, VariantsT> { const requiredSpans = convertMatchersToFns< SelectedRelationNameT, RelationSchemasT, VariantsT >(traceDefinition.requiredSpans) const labelMatching = traceDefinition.labelMatching ? convertLabelMatchersToFns(traceDefinition.labelMatching) : undefined const debounceOnSpans = convertMatchersToFns< SelectedRelationNameT, RelationSchemasT, VariantsT >(traceDefinition.debounceOnSpans) const interruptOnSpans = convertMatchersToFns< SelectedRelationNameT, RelationSchemasT, VariantsT >(traceDefinition.interruptOnSpans) const suppressErrorStatusPropagationOnSpans = convertMatchersToFns< SelectedRelationNameT, RelationSchemasT, VariantsT >(traceDefinition.suppressErrorStatusPropagationOnSpans) const computedSpanDefinitions = Object.fromEntries( Object.entries(traceDefinition.computedSpanDefinitions ?? {}).map( ([name, def]) => [ name, { startSpan: typeof def.startSpan === 'string' ? def.startSpan : ensureMatcherFn< SelectedRelationNameT, RelationSchemasT, VariantsT >(def.startSpan), endSpan: typeof def.endSpan === 'string' ? def.endSpan : ensureMatcherFn< SelectedRelationNameT, RelationSchemasT, VariantsT >(def.endSpan), } as const, ], ), ) const computedValueDefinitionsInputEntries = Object.entries< // eslint-disable-next-line @typescript-eslint/no-explicit-any ComputedValueDefinitionInput<any, any, any, any> >(traceDefinition.computedValueDefinitions ?? {}) const computedValueDefinitions = Object.fromEntries( computedValueDefinitionsInputEntries.map(([name, def]) => [ name, { ...def, matches: def.matches.map( ( m: SpanMatch<SelectedRelationNameT, RelationSchemasT, VariantsT>, ) => ensureMatcherFn< SelectedRelationNameT, RelationSchemasT, VariantsT >(m), ), computeValueFromMatches: def.computeValueFromMatches, } as const, ]), ) const completeTraceDefinition: CompleteTraceDefinition< SelectedRelationNameT, RelationSchemasT, VariantsT > = { ...traceDefinition, requiredSpans: requiredSpans ?? [ // lack of requiredSpan is invalid, but we warn about it below ], debounceOnSpans, interruptOnSpans, suppressErrorStatusPropagationOnSpans, computedSpanDefinitions, computedValueDefinitions, labelMatching, relationSchema: this.utilities.relationSchemas[traceDefinition.relationSchemaName], } if (traceDefinition.adoptAsChildren?.includes(traceDefinition.name)) { this.utilities.reportErrorFn( new Error( `A tracer cannot adopt its own traces as children. Please remove "${traceDefinition.name}" from the adoptAsChildren array.`, ), { definition: completeTraceDefinition as CompleteTraceDefinition< // eslint-disable-next-line @typescript-eslint/no-explicit-any any, RelationSchemasT, // eslint-disable-next-line @typescript-eslint/no-explicit-any any >, } as Partial<AllPossibleTraceContexts<RelationSchemasT, string>>, ) completeTraceDefinition.adoptAsChildren = completeTraceDefinition.adoptAsChildren!.filter( (childName) => childName !== traceDefinition.name, ) } if (!requiredSpans) { this.utilities.reportErrorFn( new Error( 'requiredSpans must be defined along with the trace, as a trace can only end in an interrupted state otherwise', ), { definition: completeTraceDefinition } as Partial< // eslint-disable-next-line @typescript-eslint/no-explicit-any DraftTraceContext<any, RelationSchemasT, any> >, ) } return new Tracer(completeTraceDefinition, this.utilities) } /** * Internal use only. * Use createAndProcessSpan to create a valid span, and process it immediately. * @internal */ processSpan<SpanT extends Span<RelationSchemasT>>( inputSpan: SpanT, isEndingSpan = false, ): ProcessedSpan<RelationSchemasT, SpanT> { if (inputSpan.id === undefined) { this.utilities.reportWarningFn( new Error( 'Span ID for provided span was undefined, generating a new one.', ), this.currentTraceContext, ) // note: mutating span on purpose to preserve object identity // eslint-disable-next-line no-param-reassign inputSpan.id = this.utilities.generateId('span') } this.tickParentResolver?.addSpanToCurrentTick(inputSpan, isEndingSpan) // eslint-disable-next-line prefer-destructuring const currentTrace = this.currentTrace const maybeProcessed = currentTrace?.processSpan(inputSpan) const { spanAndAnnotation: thisSpanAndAnnotation, annotationRecord } = maybeProcessed ?? {} // processing might have swapped (deduplicated) the instance of span const span = (thisSpanAndAnnotation?.span as SpanT | undefined) ?? inputSpan const resolveParent = ( recursiveAncestors = false, ): Span<RelationSchemasT> | undefined => { const parentSpan = span.getParentSpan( { traceContext: currentTrace, thisSpanAndAnnotation: thisSpanAndAnnotation ?? { span }, }, recursiveAncestors, ) return parentSpan } const updateSpan: SpanUpdateFunction<RelationSchemasT, SpanT> = ({ reprocess = true, ...spanUpdates }) => { if (!currentTrace || currentTrace !== this.currentTrace) { // ignore updates if the trace has changed return } for (const [k, value] of Object.entries(spanUpdates)) { const key = k as keyof SpanT if ( typeof value === 'object' && typeof span[key] === 'object' && value !== null ) { // merge objects, such as attributes or relatedTo: Object.assign(span[key] as object, value) // eslint-disable-next-line no-continue continue } // for other properties, just assign the value to the new one: span[key] = value as never } if (reprocess) { // re-process the span currentTrace.processSpan(span) } } return { span, annotations: annotationRecord, resolveParent, updateSpan, findAncestor: (spanMatch) => findAncestor(span, spanMatch, currentTrace), } } ensureCompleteSpan<SpanT extends Span<RelationSchemasT>>({ parentSpan, parentSpanMatcher, ...partialSpan }: ConvenienceSpan<RelationSchemasT, SpanT>): SpanT { const id = partialSpan.id ?? this.utilities.generateId('span') // ensure the span has an ID, and a startTime // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const span = { ...partialSpan, id, startTime: ensureTimestamp(partialSpan.startTime), attributes: partialSpan.attributes ?? {}, duration: partialSpan.duration ?? 0, [PARENT_SPAN]: parentSpan, } as SpanT // let's create a function that resolves the parent span based on the matcher: span.getParentSpan = createParentSpanResolver<RelationSchemasT, SpanT>( span, parentSpanMatcher, this.utilities.heritableSpanAttributes, ) return span } // helper functions to create and process spans that have a start event and an end event endSpan<SpanT extends Span<RelationSchemasT>>( startSpan: SpanT, { parentSpanMatcher, parentSpan, ...endSpanAttributes }: Partial<Omit<ConvenienceSpan<RelationSchemasT, SpanT>, 'id'>> = {}, ): ProcessedSpan<RelationSchemasT, SpanT> { // startTime cannot be updated, but if provided will extend the duration to encompass the time from start to end: const duration = typeof endSpanAttributes.startTime?.now === 'number' && typeof endSpanAttributes.duration === 'number' ? endSpanAttributes.duration + Math.max(0, endSpanAttributes.startTime.now - startSpan.startTime.now) : endSpanAttributes.duration ?? performance.now() - startSpan.startTime.now const originalGetParentSpan = startSpan.getParentSpan Object.assign(startSpan, { type: endSpanAttributes.type ?? START_TO_END_SPAN_TYPES[ startSpan.type as keyof typeof START_TO_END_SPAN_TYPES ] ?? startSpan.type, // all overriding properties from endSpan: ...endSpanAttributes, // merge attributes: attributes: { ...startSpan.attributes, ...endSpanAttributes.attributes, }, duration, // always keep id and startTime of the original span: startTime: startSpan.startTime, id: startSpan.id, [PARENT_SPAN]: parentSpan ?? startSpan[PARENT_SPAN], }) if (parentSpanMatcher) { // let's re-create the function that resolves the parent span based on the matcher: const parentSpanResolver = createParentSpanResolver< RelationSchemasT, SpanT >(startSpan, parentSpanMatcher, this.utilities.heritableSpanAttributes) // try both parent span resolvers, endSpanResolver first, then originalGetParentSpan: // eslint-disable-next-line no-param-reassign startSpan.getParentSpan = (...args) => parentSpanResolver(...args) ?? originalGetParentSpan?.(...args) } return this.processSpan(startSpan) } processErrorSpan( partialSpan: ErrorSpanInput<RelationSchemasT>, ): ProcessedSpan<RelationSchemasT, ErrorSpan<RelationSchemasT>> { return this.createAndProcessSpan({ name: partialSpan.error.name ?? 'Error', status: 'error', type: 'error', ...partialSpan, }) } createAndProcessSpan<SpanT extends Span<RelationSchemasT>>( partialSpan: ConvenienceSpan<RelationSchemasT, SpanT>, ): ProcessedSpan<RelationSchemasT, SpanT> { const span = this.ensureCompleteSpan<SpanT>(partialSpan) return this.processSpan(span) } makePerformanceEntrySpan( partialSpan: PerformanceEntrySpanInput<RelationSchemasT>, ): PerformanceEntrySpan<RelationSchemasT> { return this.ensureCompleteSpan<PerformanceEntrySpan<RelationSchemasT>>( partialSpan, ) } makeRenderSpan( partialSpan: RenderSpanInput<RelationSchemasT>, ): ComponentRenderSpan<RelationSchemasT> { return this.ensureCompleteSpan<ComponentRenderSpan<RelationSchemasT>>( partialSpan, ) } startRenderSpan({ kind, ...startSpanInput }: Omit<RenderSpanInput<RelationSchemasT>, 'type'> & { kind?: 'component' | 'hook' }) { return this.createAndProcessSpan<ComponentRenderSpan<RelationSchemasT>>({ ...startSpanInput, type: kind === 'hook' ? 'hook-render-start' : 'component-render-start', }) } endRenderSpan( startSpan: ComponentRenderSpan<RelationSchemasT>, endSpanAttributes?: Partial<ComponentRenderSpan<RelationSchemasT>> & { duration: number }, ) { return this.endSpan<ComponentRenderSpan<RelationSchemasT>>(startSpan, { ...endSpanAttributes, type: startSpan.type === 'hook-render-start' ? 'hook-render' : 'component-render', }) } /** * Finds the first span matching the provided SpanMatch in the parent hierarchy * of the given Span, starting with the span itself and traversing up * through its parents. */ findSpanInParentHierarchy( span: Span<RelationSchemasT>, // eslint-disable-next-line @typescript-eslint/no-explicit-any spanMatch: SpanMatch<keyof RelationSchemasT, RelationSchemasT, any>, ): Span<RelationSchemasT> | undefined { return findAncestor(span, spanMatch, this.currentTrace) } }