@zendesk/retrace
Version:
define and capture Product Operation Traces along with computed metrics with an optional friendly React beacon API
335 lines • 16.4 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getComputedValues = getComputedValues;
exports.getComputedSpans = getComputedSpans;
exports.createTraceRecording = createTraceRecording;
/* eslint-disable no-continue */
const ensureMatcherFn_1 = require("./ensureMatcherFn");
/**
* Helper function to find matching spans according to a matcher and matching index
*/
function findMatchingSpan(matcher, recordedItemsArray, context) {
// For positive or undefined indices - find with specified index offset
if (!('matchingIndex' in matcher) ||
matcher.matchingIndex === undefined ||
matcher.matchingIndex >= 0) {
let matchCount = 0;
for (const spanAndAnnotation of recordedItemsArray) {
if (matcher(spanAndAnnotation, context)) {
if (matcher.matchingIndex === undefined ||
matcher.matchingIndex === matchCount) {
return spanAndAnnotation;
}
matchCount++;
}
}
return undefined;
}
// For negative indices - iterate from the end
// If matchingIndex is -1, we need the last match
// If matchingIndex is -2, we need the second-to-last match, etc.
const targetIndex = Math.abs(matcher.matchingIndex) - 1;
let matchCount = 0;
// Iterate from the end of the array
// TODO: I'm wondering if we should sort recordedItemsArrayReversed by the end time...?
// For that matter, should recordedItemsArray be sorted by their start time?
// If yes, it might be good to do this in createTraceRecording and pass in both recordedItemsArray and recordedItemsArrayReversed pre-sorted, so we don't sort every time we need to calculate a computed span.
for (let i = recordedItemsArray.length - 1; i >= 0; i--) {
const spanAndAnnotation = recordedItemsArray[i];
if (matcher(spanAndAnnotation, context)) {
if (matchCount === targetIndex) {
return spanAndAnnotation;
}
matchCount++;
}
}
return undefined;
}
/**
* ### Deriving SLIs and other metrics from a trace
*
* ℹ️ It is our recommendation that the primary way of creating duration metrics would be to derive them from data in the trace.
*
* Instead of the traditional approach of capturing isolated metrics imperatively in the code,
* the **trace** model allows us the flexibility to define and compute any number of metrics from the **trace recording**.
*
* We can distinguish the following types of metrics:
*
* 1. **Duration of a Computed Span** — the time between any two **spans** that appeared in the **trace**. For example:
* 1. _time between the user’s click on a ticket_ and _everything in the ticket page has fully rendered with content_ (duration of the entire operation)
* 2. _time between the user’s click on a ticket_ and _the moment the first piece of the ticket UI was displayed_ (duration of a segment of the operation)
*
* 2. **Computed Values** — any numerical value derived from the **spans** or their attributes. For example:
* 1. _The total number of times the log component re-rendered while loading the ticket_
* 2. _The total number of requests made while loading the ticket_
* 3. _The total number of iframe apps were initialized while loading the ticket_
*/
function getComputedValues(context) {
const computedValues = {};
for (const [name, computedValueDefinition] of Object.entries(context.definition.computedValueDefinitions)) {
const { matches, computeValueFromMatches } = computedValueDefinition;
// Initialize arrays to hold matches for each matcher
const matchingEntriesByMatcher = Array.from({ length: matches.length }, () => []);
// Single pass through recordedItems
for (const item of context.recordedItems.values()) {
matches.forEach((doesSpanMatch, index) => {
if (doesSpanMatch(item, context)) {
matchingEntriesByMatcher[index].push(item);
}
});
}
const value = computeValueFromMatches(...matchingEntriesByMatcher);
if (value !== undefined) {
computedValues[name] = value;
}
}
return computedValues;
}
function getComputedSpans(context, finalState) {
const computedSpans = {};
const recordedItemsArray = [...context.recordedItems.values()];
for (const [name, computedSpanDefinition] of Object.entries(context.definition.computedSpanDefinitions)) {
// Create matchers from the span definitions
const startSpanMatcher = (0, ensureMatcherFn_1.ensureMatcherFnOrSpecialToken)(computedSpanDefinition.startSpan);
const endSpanMatcher = (0, ensureMatcherFn_1.ensureMatcherFnOrSpecialToken)(computedSpanDefinition.endSpan);
// Find matching start entry
let matchingStartEntry = startSpanMatcher === 'operation-start' ? 'operation-start' : undefined;
if (typeof startSpanMatcher === 'function') {
matchingStartEntry = findMatchingSpan(startSpanMatcher, recordedItemsArray, context);
}
// Find matching end entry
let matchingEndEntry;
if (typeof endSpanMatcher === 'function') {
matchingEndEntry = findMatchingSpan(endSpanMatcher, recordedItemsArray, context);
}
else if (endSpanMatcher === 'operation-end') {
matchingEndEntry = finalState?.completeSpanAndAnnotation;
}
else if (endSpanMatcher === 'interactive') {
matchingEndEntry = finalState?.cpuIdleSpanAndAnnotation;
}
// Calculate timing values
const matchingStartTime = matchingStartEntry === 'operation-start'
? context.input.startTime.now
: matchingStartEntry?.span.startTime.now;
const matchingEndTime = matchingEndEntry
? matchingEndEntry.span.startTime.now + matchingEndEntry.span.duration
: undefined;
// Create computed span if both start and end times are found
if (typeof matchingStartTime === 'number' &&
typeof matchingEndTime === 'number') {
computedSpans[name] = {
duration: matchingEndTime - matchingStartTime,
startOffset: matchingStartTime - context.input.startTime.now,
// DECISION: After considering which events happen first and which one is defined as the start
// the start offset is always going to be anchored to the start span.
// cases:
// -----S------E (computed val is positive)
// -----E------S (computed val is negative)
// this way the `endOffset` can be derived as follows:
// endOffset = computedSpan.startOffset + computedSpan.duration
};
}
}
return computedSpans;
}
function getComputedRenderBeaconSpans(recordedItems, input) {
const renderSpansByBeacon = new Map();
const relatedToKey = Object.keys(input.relatedTo);
// Group render spans by beacon and compute firstStart and lastEnd
for (const entry of recordedItems) {
if (entry.span.type !== 'component-render' &&
entry.span.type !== 'component-render-start') {
continue;
}
const { name, startTime, duration, relatedTo: r, renderedOutput, } = entry.span;
const relatedTo = r;
const inputRelatedTo = input.relatedTo;
const relationMatch = relatedToKey.every((key) => relatedTo?.[key] === undefined ||
inputRelatedTo[key] === relatedTo[key]);
if (!relationMatch)
continue;
const start = startTime.now;
const contentfulRenderEnd = entry.span.type === 'component-render' && renderedOutput === 'content'
? start + duration
: undefined;
const spanTimes = renderSpansByBeacon.get(name);
if (!spanTimes) {
renderSpansByBeacon.set(name, {
firstStart: start,
firstContentfulRenderEnd: contentfulRenderEnd,
renderCount: entry.span.type === 'component-render' ? 1 : 0,
sumOfDurations: duration,
firstContentStart: renderedOutput === 'content' ? start : undefined,
firstLoadingEnd: entry.span.type === 'component-render' && renderedOutput === 'loading'
? start + duration
: undefined,
lastRenderStartTime: entry.span.type === 'component-render-start' ? start : undefined,
});
}
else {
spanTimes.firstStart = Math.min(spanTimes.firstStart, start);
spanTimes.firstContentfulRenderEnd =
contentfulRenderEnd && spanTimes.firstContentfulRenderEnd
? Math.min(spanTimes.firstContentfulRenderEnd, contentfulRenderEnd)
: contentfulRenderEnd ?? spanTimes.firstContentfulRenderEnd;
if (entry.span.type === 'component-render') {
spanTimes.renderCount += 1;
// React's concurrent rendering might pause and discard a render,
// which would mean that an effect scheduled for that render does not execute because the render itself was not committed to the DOM.
// we want to extend the the render span backwards, to first time that rendering was scheduled as the start time of rendering
if (spanTimes.lastRenderStartTime !== undefined) {
spanTimes.sumOfDurations +=
start + duration - spanTimes.lastRenderStartTime;
spanTimes.lastRenderStartTime = undefined;
}
else {
spanTimes.sumOfDurations += duration;
}
}
else if (entry.span.type === 'component-render-start') {
spanTimes.lastRenderStartTime = start;
}
if (spanTimes.firstContentStart === undefined &&
renderedOutput === 'content') {
spanTimes.firstContentStart = start;
}
if (spanTimes.firstLoadingEnd === undefined &&
entry.span.type === 'component-render' &&
renderedOutput === 'loading') {
spanTimes.firstLoadingEnd = start + duration;
}
}
}
const computedRenderBeaconSpans = {};
// Calculate duration and startOffset for each beacon
for (const [beaconName, spanTimes] of renderSpansByBeacon) {
if (!spanTimes.firstContentfulRenderEnd)
continue;
computedRenderBeaconSpans[beaconName] = {
startOffset: spanTimes.firstStart - input.startTime.now,
firstRenderTillContent: spanTimes.firstContentfulRenderEnd - spanTimes.firstStart,
firstRenderTillLoading: spanTimes.firstLoadingEnd
? spanTimes.firstLoadingEnd - spanTimes.firstStart
: 0,
firstRenderTillData: spanTimes.firstContentStart
? spanTimes.firstContentStart - spanTimes.firstStart
: 0,
renderCount: spanTimes.renderCount,
sumOfRenderDurations: spanTimes.sumOfDurations,
};
}
return computedRenderBeaconSpans;
}
/**
* Find and promote span attributes to trace attributes per promoteSpanAttributes definition.
*/
function promoteSpanAttributesForTrace(definition, recordedItemsArray, context) {
if (!definition.promoteSpanAttributes)
return {};
const promoted = {};
for (const rule of definition.promoteSpanAttributes) {
const matcher = (0, ensureMatcherFn_1.ensureMatcherFn)(rule.span);
if (matcher.matchingIndex === undefined) {
// if no specific index is provided, we accumulate attributes from all matches
// last one wins
for (const spanAnn of recordedItemsArray) {
if (matcher(spanAnn, context)) {
const attrs = spanAnn.span.attributes;
if (attrs) {
for (const key of rule.attributes) {
if (key in attrs)
promoted[key] = attrs[key];
}
}
}
}
}
else {
const matchingSpan = findMatchingSpan(matcher, recordedItemsArray, context);
if (matchingSpan) {
const attrs = matchingSpan.span.attributes;
if (attrs) {
for (const key of rule.attributes) {
if (key in attrs)
promoted[key] = attrs[key];
}
}
}
}
}
return promoted;
}
function isActiveTraceInput(input) {
return Boolean(input.relatedTo);
}
function createTraceRecording(context, transition) {
const { definition, recordedItems, input } = context;
const { id, relatedTo, variant } = input;
const { name } = definition;
const { transitionToState, interruptionReason, cpuIdleSpanAndAnnotation, completeSpanAndAnnotation, lastRequiredSpanAndAnnotation, lastRelevantSpanAndAnnotation, } = {
cpuIdleSpanAndAnnotation: undefined,
completeSpanAndAnnotation: undefined,
lastRequiredSpanAndAnnotation: undefined,
...transition,
};
const endOfOperationSpan = (transitionToState === 'complete' &&
(cpuIdleSpanAndAnnotation ?? completeSpanAndAnnotation)) ||
lastRelevantSpanAndAnnotation;
// only keep items captured until the endOfOperationSpan or if not available, the lastRelevantSpan
const recordedItemsArray = endOfOperationSpan
? [...recordedItems].filter((item) => item.span.startTime.now + item.span.duration <=
endOfOperationSpan.span.startTime.now +
endOfOperationSpan.span.duration)
: [...recordedItems.values()];
// CODE CLEAN UP TODO: let's get this information (wasInterrupted) from up top (in FinalState)
const wasInterrupted = transitionToState === 'interrupted';
const computedSpans = !wasInterrupted
? getComputedSpans(context, {
completeSpanAndAnnotation,
cpuIdleSpanAndAnnotation,
})
: {};
const computedValues = !wasInterrupted ? getComputedValues(context) : {};
const computedRenderBeaconSpans = !wasInterrupted && isActiveTraceInput(input)
? getComputedRenderBeaconSpans(recordedItemsArray, input)
: {};
const anyNonSuppressedErrors = recordedItemsArray.some((spanAndAnnotation) => spanAndAnnotation.span.status === 'error' &&
!definition.suppressErrorStatusPropagationOnSpans?.some((doesSpanMatch) => doesSpanMatch(spanAndAnnotation, context)));
// promote span attributes to trace attributes per configuration
const promotedAttributes = promoteSpanAttributesForTrace(definition, recordedItemsArray, context);
const traceAttributes = { ...promotedAttributes, ...input.attributes };
const duration = completeSpanAndAnnotation?.annotation.operationRelativeEndTime ?? null;
const startTillInteractive = cpuIdleSpanAndAnnotation?.annotation.operationRelativeEndTime ?? null;
const startTillRequirementsMet = lastRequiredSpanAndAnnotation?.annotation.operationRelativeEndTime ?? null;
return {
id,
name,
startTime: input.startTime,
relatedTo,
type: 'operation',
duration,
variant,
additionalDurations: {
startTillRequirementsMet,
startTillInteractive,
// last entry until the tti?
completeTillInteractive: startTillInteractive && duration
? startTillInteractive - duration
: null,
},
// ?: If we have any error entries then should we mark the status as 'error'
status: wasInterrupted
? 'interrupted'
: anyNonSuppressedErrors
? 'error'
: 'ok',
computedSpans,
computedRenderBeaconSpans,
computedValues,
attributes: traceAttributes,
interruptionReason,
entries: recordedItemsArray,
};
}
//# sourceMappingURL=recordingComputeUtils.js.map