UNPKG

@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
"use strict"; 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