diff --git a/src/v3/ActiveTrace.ts b/src/v3/ActiveTrace.ts index 272250bf..3d5de3bf 100644 --- a/src/v3/ActiveTrace.ts +++ b/src/v3/ActiveTrace.ts @@ -52,7 +52,7 @@ export type TraceStates = NonTerminalTraceStates | TerminalTraceStates interface OnEnterRecording { transitionToState: 'recording' - transitionFromState: 'initial' + transitionFromState: NonTerminalTraceStates } interface OnEnterInterrupted { @@ -263,9 +263,40 @@ export class TraceStateMachine< onProcessSpan: ( spanAndAnnotation: SpanAndAnnotation, ) => { - // if any span is interrupted! + const spanEndTimeEpoch = + spanAndAnnotation.span.startTime.epoch + + spanAndAnnotation.span.duration + + if (spanEndTimeEpoch > this.#timeoutDeadline) { + // we consider this interrupted, because of the clamping of the total duration of the operation + // as potential other events could have happened and prolonged the operation + // we can be a little picky, because we expect to record many operations + // it's best to compare like-to-like + return { + transitionToState: 'interrupted', + interruptionReason: 'timeout', + } + } + + // if the entry matches any of the interruptOn criteria, + // transition to complete state with the 'matched-on-interrupt' interruptionReason + if (this.context.definition.interruptOn) { + for (const doesSpanMatch of this.context.definition.interruptOn) { + if (doesSpanMatch(spanAndAnnotation, this.context)) { + return { + transitionToState: 'complete', + interruptionReason: 'matched-on-interrupt', + lastRequiredSpanAndAnnotation: this.lastRequiredSpan, + completeSpanAndAnnotation: this.completeSpan, + } + } + } + } + // else, add into array buffer this.#provisionalBuffer.push(spanAndAnnotation) + + return undefined }, onInterrupt: (reason: TraceInterruptionReason) => ({ @@ -284,11 +315,13 @@ export class TraceStateMachine< return undefined }, }, + recording: { - // eslint-disable-next-line consistent-return - onEnterState: () => { - const transition = this.#processProvisionalBuffer() - if (transition) return transition + onEnterState: (_transition: OnEnterRecording) => { + const nextTransition = this.#processProvisionalBuffer() + if (nextTransition) return nextTransition + + return undefined }, onProcessSpan: ( @@ -377,7 +410,7 @@ export class TraceStateMachine< // we want to ensure the end of the operation captures // the final, settled state of the component debouncing: { - onEnterState: (payload: OnEnterDebouncing) => { + onEnterState: (_payload: OnEnterDebouncing) => { if (!this.lastRelevant) { // this should never happen return { @@ -510,7 +543,7 @@ export class TraceStateMachine< }, 'waiting-for-interactive': { - onEnterState: (payload: OnEnterWaitingForInteractive) => { + onEnterState: (_payload: OnEnterWaitingForInteractive) => { if (!this.lastRelevant) { // this should never happen return { @@ -850,7 +883,13 @@ export class ActiveTrace< AllPossibleScopesT, const OriginatedFromT extends string, > { - readonly definition: CompleteTraceDefinition< + readonly sourceDefinition: CompleteTraceDefinition< + TracerScopeKeysT, + AllPossibleScopesT, + OriginatedFromT + > + /** the mutable definition */ + definition: CompleteTraceDefinition< TracerScopeKeysT, AllPossibleScopesT, OriginatedFromT @@ -895,7 +934,8 @@ export class ActiveTrace< >, deduplicationStrategy?: SpanDeduplicationStrategy, ) { - this.definition = definition + this.definition = structuredClone(definition) + this.sourceDefinition = definition this.input = { ...input, startTime: ensureTimestamp(input.startTime), diff --git a/src/v3/traceManager.ts b/src/v3/traceManager.ts index c896bf0c..814cee5d 100644 --- a/src/v3/traceManager.ts +++ b/src/v3/traceManager.ts @@ -21,6 +21,7 @@ import type { TraceContext, TraceDefinition, TraceManagerConfig, + TraceModifications, Tracer, } from './types' import type { KeysOfUnion } from './typeUtils' @@ -44,8 +45,11 @@ export class TraceManager< | ActiveTrace, AllPossibleScopesT, string> | undefined = undefined + private reportErrorFn: (error: Error) => void + constructor({ reportFn, + reportErrorFn, generateId, performanceEntryDeduplicationStrategy, }: TraceManagerConfig) { @@ -53,6 +57,7 @@ export class TraceManager< this.generateId = generateId this.performanceEntryDeduplicationStrategy = performanceEntryDeduplicationStrategy + this.reportErrorFn = reportErrorFn } createTracer< @@ -160,9 +165,10 @@ export class TraceManager< } as ComputedValueDefinition) }, start: (input) => this.startTrace(completeTraceDefinition, input), - provisionalStart: (inputWithScope) => { - this.provisionalStartTrace(completeTraceDefinition, inputWithScope) - }, + provisionalStart: (input) => + this.provisionalStartTrace(completeTraceDefinition, input), + initializeProvisional: (inputAndDefinitionModifications) => + void this.initializeActiveTrace(inputAndDefinitionModifications), } } @@ -185,9 +191,10 @@ export class TraceManager< SelectScopeByKey, OriginatedFromT >, - ): string { + ): string | undefined { const traceId = this.provisionalStartTrace(definition, input) - this.initializeActiveTrace() + if (!traceId) return undefined + this.initializeActiveTrace({ scope: input.scope }) return traceId } @@ -195,24 +202,85 @@ export class TraceManager< // from input: scope (required), attributes (optional, merge into) // from definition, can add items to: requiredSpans (additionalRequiredSpans), debounceOn (additionalDebounceOnSpans) // documentation: interruption still works and all the other events are buffered - private initializeActiveTrace(inputAndDefinitionModifications) { - const currentTrace = this.activeTrace - // if a trace has an undefined scope, it means it - if (!currentTrace?.isProvisional) { - // this is an already initialized active trace, do nothing: + private initializeActiveTrace< + TracerScopeKeysT extends KeysOfUnion, + OriginatedFromT extends string, + >( + inputAndDefinitionModifications: TraceModifications< + TracerScopeKeysT, + AllPossibleScopesT, + OriginatedFromT + >, + ): void { + if (!this.activeTrace) { + this.reportErrorFn( + new Error( + `No currently active trace when initializing a trace. Call tracer.startTrace(...) or tracer.provisionalStartTrace(...) beforehand.`, + ), + ) + return + } + + // this is an already initialized active trace, do nothing: + if (!this.activeTrace.isProvisional) { + this.reportErrorFn( + new Error( + `You are trying to initialize a trace that has already been initialized before (${this.activeTrace.definition.name}).`, + ), + ) return } + + const { scope, attributes } = this.activeTrace.input + + this.activeTrace.input.scope = scope + this.activeTrace.input.attributes = { + ...this.activeTrace.input.attributes, + ...attributes, + } + + const additionalRequiredSpans = convertMatchersToFns< + TracerScopeKeysT, + AllPossibleScopesT, + OriginatedFromT + >(inputAndDefinitionModifications.additionalRequiredSpans) + + const additionalDebounceOnSpans = convertMatchersToFns< + TracerScopeKeysT, + AllPossibleScopesT, + OriginatedFromT + >(inputAndDefinitionModifications.additionalDebounceOnSpans) + + const { activeTrace } = this + + if (additionalRequiredSpans?.length) { + activeTrace.definition.requiredSpans = [ + ...activeTrace.sourceDefinition.requiredSpans, + ...additionalRequiredSpans, + ] as (typeof activeTrace)['definition']['requiredSpans'] + } + if (additionalDebounceOnSpans?.length) { + activeTrace.definition.debounceOn = [ + ...(activeTrace.sourceDefinition.debounceOn ?? []), + ...additionalDebounceOnSpans, + ] as (typeof activeTrace)['definition']['debounceOn'] + } + // else, we want to initialize the trace with the scope and other modifications: // TODO ... } - // todo: wont have scope yet private provisionalStartTrace< const TracerScopeKeysT extends KeysOfUnion, + const OriginatedFromT extends string, >( - definition: CompleteTraceDefinition, - input: BaseStartTraceConfig, - ): string { + definition: CompleteTraceDefinition< + TracerScopeKeysT, + AllPossibleScopesT, + OriginatedFromT + >, + input: BaseStartTraceConfig, + ): string | undefined { if (this.activeTrace) { this.activeTrace.interrupt('another-trace-started') this.activeTrace = undefined @@ -222,13 +290,16 @@ export class TraceManager< // Verify that the originatedFrom value is valid and has a corresponding timeout if (!(input.originatedFrom in definition.variantsByOriginatedFrom)) { - throw new Error( - `Invalid originatedFrom value: ${ - input.originatedFrom - }. Must be one of: ${Object.keys( - definition.variantsByOriginatedFrom, - ).join(', ')}`, + this.reportErrorFn( + new Error( + `Invalid originatedFrom value: ${ + input.originatedFrom + }. Must be one of: ${Object.keys( + definition.variantsByOriginatedFrom, + ).join(', ')}`, + ), ) + return undefined } const activeTraceContext: TraceContext< diff --git a/src/v3/types.ts b/src/v3/types.ts index cb038808..5437fb8c 100644 --- a/src/v3/types.ts +++ b/src/v3/types.ts @@ -3,6 +3,8 @@ import type { SpanMatch, SpanMatchDefinition, SpanMatcherFn } from './matchSpan' import type { SpanAndAnnotation } from './spanAnnotationTypes' import type { ActiveTraceInput, + Attributes, + BaseStartTraceConfig, Span, SpanStatus, StartTraceConfig, @@ -90,6 +92,27 @@ export interface TraceManagerConfig< performanceEntryDeduplicationStrategy?: SpanDeduplicationStrategy< Partial > + + reportErrorFn: (error: Error) => void +} + +export interface TraceModifications< + TracerScopeKeysT extends KeysOfUnion, + AllPossibleScopesT, + OriginatedFromT extends string, +> { + scope: SelectScopeByKey + attributes?: Attributes + additionalRequiredSpans?: SpanMatcherFn< + TracerScopeKeysT, + AllPossibleScopesT, + OriginatedFromT + >[] + additionalDebounceOnSpans?: SpanMatcherFn< + TracerScopeKeysT, + AllPossibleScopesT, + OriginatedFromT + >[] } export interface Tracer< @@ -105,7 +128,19 @@ export interface Tracer< SelectScopeByKey, OriginatedFromT >, - ) => string + ) => string | undefined + + provisionalStart: ( + input: BaseStartTraceConfig, + ) => string | undefined + + initializeProvisional: ( + mods: TraceModifications< + TracerScopeKeysT, + AllPossibleScopesT, + OriginatedFromT + >, + ) => void defineComputedSpan: ( computedSpanDefinition: ComputedSpanDefinitionInput<