@zendesk/retrace
Version:
define and capture Product Operation Traces along with computed metrics with an optional friendly React beacon API
322 lines • 14.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.TraceManager = void 0;
const rxjs_1 = require("rxjs");
const createParentSpanResolver_1 = require("./createParentSpanResolver");
const ensureMatcherFn_1 = require("./ensureMatcherFn");
const ensureTimestamp_1 = require("./ensureTimestamp");
const findSpanInParentHierarchy_1 = require("./findSpanInParentHierarchy");
const spanTypes_1 = require("./spanTypes");
const TickParentResolver_1 = require("./TickParentResolver");
const Tracer_1 = require("./Tracer");
const START_TO_END_SPAN_TYPES = {
'component-render-start': 'component-render',
'hook-render-start': 'hook-render',
mark: 'measure',
};
/**
* Class representing the centralized trace manager.
* Usually you'll have a single instance of this class in your app.
*/
class TraceManager {
currentTrace = undefined;
// Event subjects for all traces
eventSubjects = {
'trace-start': new rxjs_1.Subject(),
'state-transition': new rxjs_1.Subject(),
'required-span-seen': new rxjs_1.Subject(),
'add-span-to-recording': new rxjs_1.Subject(),
'definition-modified': new rxjs_1.Subject(),
};
get currentTraceContext() {
if (!this.currentTrace)
return undefined;
return this.currentTrace;
}
tickParentResolver;
constructor({ enableTickTracking = true, ...configInput }) {
this.utilities = {
// by default noop for warnings
reportWarningFn: () => { },
enableTickTracking,
acceptSpansStartedBeforeTraceStartThreshold: 100,
...configInput,
replaceCurrentTrace: (getNewTrace, reason) => {
let newTrace;
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_1.TickParentResolver(this.utilities)
: undefined;
}
/**
* Subscribe to events from a trace and forward them to the TraceManager subjects
*/
subscribeToTraceEvents(trace) {
// 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);
});
}
when(event) {
return this.eventSubjects[event].asObservable();
}
utilities;
createTracer(traceDefinition) {
const requiredSpans = (0, ensureMatcherFn_1.convertMatchersToFns)(traceDefinition.requiredSpans);
const labelMatching = traceDefinition.labelMatching
? (0, ensureMatcherFn_1.convertLabelMatchersToFns)(traceDefinition.labelMatching)
: undefined;
const debounceOnSpans = (0, ensureMatcherFn_1.convertMatchersToFns)(traceDefinition.debounceOnSpans);
const interruptOnSpans = (0, ensureMatcherFn_1.convertMatchersToFns)(traceDefinition.interruptOnSpans);
const suppressErrorStatusPropagationOnSpans = (0, ensureMatcherFn_1.convertMatchersToFns)(traceDefinition.suppressErrorStatusPropagationOnSpans);
const computedSpanDefinitions = Object.fromEntries(Object.entries(traceDefinition.computedSpanDefinitions ?? {}).map(([name, def]) => [
name,
{
startSpan: typeof def.startSpan === 'string'
? def.startSpan
: (0, ensureMatcherFn_1.ensureMatcherFn)(def.startSpan),
endSpan: typeof def.endSpan === 'string'
? def.endSpan
: (0, ensureMatcherFn_1.ensureMatcherFn)(def.endSpan),
},
]));
const computedValueDefinitionsInputEntries = Object.entries(traceDefinition.computedValueDefinitions ?? {});
const computedValueDefinitions = Object.fromEntries(computedValueDefinitionsInputEntries.map(([name, def]) => [
name,
{
...def,
matches: def.matches.map((m) => (0, ensureMatcherFn_1.ensureMatcherFn)(m)),
computeValueFromMatches: def.computeValueFromMatches,
},
]));
const completeTraceDefinition = {
...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,
});
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 });
}
return new Tracer_1.Tracer(completeTraceDefinition, this.utilities);
}
/**
* Internal use only.
* Use createAndProcessSpan to create a valid span, and process it immediately.
* @internal
*/
processSpan(inputSpan, isEndingSpan = false) {
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 ?? inputSpan;
const resolveParent = (recursiveAncestors = false) => {
const parentSpan = span.getParentSpan({
traceContext: currentTrace,
thisSpanAndAnnotation: thisSpanAndAnnotation ?? { span },
}, recursiveAncestors);
return parentSpan;
};
const updateSpan = ({ 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;
if (typeof value === 'object' &&
typeof span[key] === 'object' &&
value !== null) {
// merge objects, such as attributes or relatedTo:
Object.assign(span[key], value);
// eslint-disable-next-line no-continue
continue;
}
// for other properties, just assign the value to the new one:
span[key] = value;
}
if (reprocess) {
// re-process the span
currentTrace.processSpan(span);
}
};
return {
span,
annotations: annotationRecord,
resolveParent,
updateSpan,
findAncestor: (spanMatch) => (0, findSpanInParentHierarchy_1.findAncestor)(span, spanMatch, currentTrace),
};
}
ensureCompleteSpan({ parentSpan, parentSpanMatcher, ...partialSpan }) {
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: (0, ensureTimestamp_1.ensureTimestamp)(partialSpan.startTime),
attributes: partialSpan.attributes ?? {},
duration: partialSpan.duration ?? 0,
[spanTypes_1.PARENT_SPAN]: parentSpan,
};
// let's create a function that resolves the parent span based on the matcher:
span.getParentSpan = (0, createParentSpanResolver_1.createParentSpanResolver)(span, parentSpanMatcher, this.utilities.heritableSpanAttributes);
return span;
}
// helper functions to create and process spans that have a start event and an end event
endSpan(startSpan, { parentSpanMatcher, parentSpan, ...endSpanAttributes } = {}) {
// 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] ??
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,
[spanTypes_1.PARENT_SPAN]: parentSpan ?? startSpan[spanTypes_1.PARENT_SPAN],
});
if (parentSpanMatcher) {
// let's re-create the function that resolves the parent span based on the matcher:
const parentSpanResolver = (0, createParentSpanResolver_1.createParentSpanResolver)(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) {
return this.createAndProcessSpan({
name: partialSpan.error.name ?? 'Error',
status: 'error',
type: 'error',
...partialSpan,
});
}
createAndProcessSpan(partialSpan) {
const span = this.ensureCompleteSpan(partialSpan);
return this.processSpan(span);
}
makePerformanceEntrySpan(partialSpan) {
return this.ensureCompleteSpan(partialSpan);
}
makeRenderSpan(partialSpan) {
return this.ensureCompleteSpan(partialSpan);
}
startRenderSpan({ kind, ...startSpanInput }) {
return this.createAndProcessSpan({
...startSpanInput,
type: kind === 'hook' ? 'hook-render-start' : 'component-render-start',
});
}
endRenderSpan(startSpan, endSpanAttributes) {
return this.endSpan(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,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
spanMatch) {
return (0, findSpanInParentHierarchy_1.findAncestor)(span, spanMatch, this.currentTrace);
}
}
exports.TraceManager = TraceManager;
//# sourceMappingURL=TraceManager.js.map