@zendesk/retrace
Version:
define and capture Product Operation Traces along with computed metrics with an optional friendly React beacon API
454 lines • 22.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.createConsoleTraceLogger = createConsoleTraceLogger;
const debugUtils_1 = require("./debugUtils");
const recordingComputeUtils_1 = require("./recordingComputeUtils");
const Trace_1 = require("./Trace");
// --- Basic ANSI Color Codes ---
const RESET = '\u001B[0m';
const YELLOW = '\u001B[33m'; // Trace Start/End
const CYAN = '\u001B[36m'; // State Changes
const MAGENTA = '\u001B[35m'; // Span Matches
const GREEN = '\u001B[32m'; // Success/Complete
const RED = '\u001B[31m'; // Error/Interrupted
const GRAY = '\u001B[90m'; // Timestamps, Verbose details
const MAX_SERIALIZED_OBJECT_LENGTH = 500;
/**
* Format timestamp to readable time
*/
const formatTimestamp = (timestamp) => new Date(timestamp).toISOString().split('T')[1].slice(0, -1);
/**
* Check if two objects are different by comparing their JSON representation
*/
const objectsAreDifferent = (obj1, obj2) => {
if (!obj1 && !obj2)
return false;
if (!obj1 || !obj2)
return true;
// Simple comparison, consider deep equality for complex cases if needed
try {
return JSON.stringify(obj1) !== JSON.stringify(obj2);
}
catch {
// Handle circular references or other stringify errors
return true; // Assume different if stringify fails
}
};
/**
* A utility for logging trace information
*/
function createConsoleTraceLogger(traceManager, optionsInput = {}) {
// Use a mutable options object internally
let options = {
logger: console, // Default to global console
verbose: false,
prefix: '[retrace]',
maxObjectStringLength: MAX_SERIALIZED_OBJECT_LENGTH,
enableGrouping: true,
enableColors: true,
...optionsInput, // Apply initial user options
};
// Determine logger type and capabilities based on current options
let isConsoleLike = typeof options.logger !== 'function' && options.logger.group;
let canGroup = isConsoleLike && options.enableGrouping;
let canColor = isConsoleLike && options.enableColors;
// Keep track of active traces (Map allows multiple concurrent traces)
const activeTraces = new Map();
// Store subscriptions for cleanup
const subscriptions = [];
// --- Helper Functions ---
/** Apply color codes if enabled */
const colorize = (str, color) => canColor ? `${color}${str}${RESET}` : str;
/** Truncate objects to prevent huge logs */
const truncateObject = (obj) => {
if (!obj)
return '{}'; // Represent undefined/null as empty object string
try {
const str = JSON.stringify(obj);
if (str.length <= options.maxObjectStringLength)
return str;
return `${str.slice(0, Math.max(0, options.maxObjectStringLength - 3))}...`;
}
catch {
return '{...}'; // Indicate truncation due to error
}
};
/** Log a message using the configured logger */
const log = (message, level = 'log', ...args) => {
const fullMessage = `${options.prefix} ${message}`;
if (isConsoleLike) {
const consoleLogger = options.logger;
switch (level) {
case 'group':
if (canGroup)
consoleLogger.group(fullMessage, ...args);
else
consoleLogger.log(fullMessage, ...args);
break;
case 'groupCollapsed':
if (canGroup)
consoleLogger.groupCollapsed(fullMessage, ...args);
else
consoleLogger.log(fullMessage, ...args);
break;
case 'groupEnd':
if (canGroup)
consoleLogger.groupEnd();
// No equivalent log message needed for simple loggers
break;
default: // 'log'
consoleLogger.log(fullMessage, ...args);
break;
}
}
else {
// Simple function logger
const simpleLogger = options.logger;
// Don't log the main message for groupEnd in simple mode
if (!message.trim()) {
// Avoid logging empty "[END GROUP]" lines
return;
}
simpleLogger(`${fullMessage}`);
}
};
/** Format time relative to trace start */
const formatRelativeTime = (offset) => {
if (offset === undefined)
return '';
const formatted = `+${offset.toFixed(2)}ms`;
return colorize(formatted, GRAY);
};
/** Get string representation of required spans count */
const getRequiredSpansCount = (traceInfo) => {
const matched = traceInfo.requiredSpans.filter((span) => span.isMatched).length;
const total = traceInfo.requiredSpans.length;
return `${matched}/${total}`;
};
/** Create a required span entry from a matcher function */
const createRequiredSpanEntry = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
matcher, index) => {
// Use the utility if available, otherwise fallback
const name = (0, debugUtils_1.formatMatcher)(matcher, index);
return { name, matcher, isMatched: false };
};
/** Handle changes in attributes, relatedTo, or requiredSpans */
const handleStateChanges = (trace, traceInfo) => {
const currentAttributes = trace.input.attributes;
if (objectsAreDifferent(currentAttributes, traceInfo.attributes)) {
log(` Attributes changed: ${colorize(truncateObject(currentAttributes), GRAY)}`);
// eslint-disable-next-line no-param-reassign
traceInfo.attributes = currentAttributes
? { ...currentAttributes }
: undefined;
}
const currentRelatedTo = trace.input.relatedTo;
if (objectsAreDifferent(currentRelatedTo, traceInfo.relatedTo)) {
log(` Related to changed: ${colorize(truncateObject(currentRelatedTo), GRAY)}`);
// eslint-disable-next-line no-param-reassign
traceInfo.relatedTo = currentRelatedTo
? { ...currentRelatedTo }
: undefined;
}
// Note: Required spans list doesn't change after trace start in current model
// If variants could change requiredSpans, this would need updating.
};
/** Log timing information for a terminal state */
const logTimingInfo = (transition, traceInfo) => {
const { lastRequiredSpanOffset, completeSpanOffset, cpuIdleSpanOffset } = (0, debugUtils_1.extractTimingOffsets)(transition);
const duration = completeSpanOffset ?? lastRequiredSpanOffset; // Best guess at total duration
if (duration !== undefined) {
log(` Duration: ${formatRelativeTime(duration)}`);
}
if (lastRequiredSpanOffset !== undefined) {
log(` Last required span: ${formatRelativeTime(lastRequiredSpanOffset)}`);
}
if (completeSpanOffset !== undefined &&
completeSpanOffset !== lastRequiredSpanOffset) {
// Only log if different from LRS
log(` Complete span: ${formatRelativeTime(completeSpanOffset)}`);
}
if (cpuIdleSpanOffset !== undefined) {
log(` CPU idle: ${formatRelativeTime(cpuIdleSpanOffset)}`);
}
log(` Required spans: ${getRequiredSpansCount(traceInfo)} spans matched`);
};
// Event handlers
// --------------
/**
* Handle trace start event
*/
const handleTraceStart = (event) => {
const trace = event.traceContext;
const traceName = trace.definition.name;
const traceVariant = trace.input.variant;
const traceId = trace.input.id;
const requiredSpans = trace.definition.requiredSpans.map((matcher, index) => createRequiredSpanEntry(matcher, index));
const traceInfo = {
id: traceId,
name: traceName,
variant: traceVariant,
startTime: trace.input.startTime.epoch,
attributes: trace.input.attributes
? { ...trace.input.attributes }
: undefined,
relatedTo: trace.input.relatedTo
? { ...trace.input.relatedTo }
: undefined,
requiredSpans,
parentTraceId: trace.input.parentTraceId,
liveDuration: 0,
totalSpanCount: 0,
hasErrorSpan: false,
hasSuppressedErrorSpan: false,
definitionModifications: [],
};
// Store the trace info
activeTraces.set(traceId, traceInfo);
const startTimeStr = formatTimestamp(traceInfo.startTime);
const isChild = !!traceInfo.parentTraceId;
const tracePrefix = isChild ? '↳ Child trace started:' : '⏳ Trace started:';
log(`${colorize(tracePrefix, YELLOW)} ${traceName} (${traceVariant}) [${colorize(traceId, GRAY)}]${isChild ? ` parent: ${colorize(traceInfo.parentTraceId, GRAY)}` : ''}`, 'groupCollapsed');
log(` Started at: ${colorize(startTimeStr, GRAY)}`);
if (traceInfo.attributes && Object.keys(traceInfo.attributes).length > 0) {
log(` Attributes: ${colorize(truncateObject(traceInfo.attributes), GRAY)}`);
}
if (traceInfo.relatedTo && Object.keys(traceInfo.relatedTo).length > 0) {
log(` Related to: ${colorize(truncateObject(traceInfo.relatedTo), GRAY)}`);
}
log(` Required spans: ${requiredSpans.length}`);
// Log config summary
const { timeout, debounce, interactive } = (0, debugUtils_1.getConfigSummary)(trace);
log(` Config: Timeout=${timeout}ms, Debounce=${debounce}ms${interactive ? `, Interactive=${interactive}ms` : ''}`);
// Log computed definitions
const computedSpans = Object.keys(trace.definition.computedSpanDefinitions ?? {});
const computedValues = Object.keys(trace.definition.computedValueDefinitions ?? {});
if (computedSpans.length > 0)
log(` Computed Spans: ${computedSpans.join(', ')}`);
if (computedValues.length > 0)
log(` Computed Values: ${computedValues.join(', ')}`);
log('', 'groupEnd'); // Close the initial group immediately
};
/**
* Handle state transition event
*/
const handleStateTransition = (event) => {
const { traceContext: trace, stateTransition: transition } = event;
const traceId = trace.input.id;
const traceInfo = activeTraces.get(traceId);
if (!traceInfo)
return; // Should not happen if trace started
const traceName = traceInfo.name;
const previousState = transition.transitionFromState;
const traceState = transition.transitionToState;
const groupLevel = options.verbose ? 'group' : 'groupCollapsed';
// Add live duration, span count, and error indicator
const liveInfo = [
traceInfo.liveDuration ? `(+${traceInfo.liveDuration}ms elapsed)` : '',
traceInfo.totalSpanCount ? `(Spans: ${traceInfo.totalSpanCount})` : '',
traceInfo.hasErrorSpan
? colorize('❗', RED)
: traceInfo.hasSuppressedErrorSpan
? colorize('❗(suppressed)', RED)
: '',
traceInfo.definitionModifications.length > 0
? colorize('🔧', MAGENTA)
: '',
]
.filter(Boolean)
.join(' ');
log(`${colorize('↪️ Trace', CYAN)} ${traceName} state changed: ${previousState} → ${colorize(traceState, CYAN)} ${liveInfo}`, groupLevel);
// Log changes that occurred *during* this state
handleStateChanges(trace, traceInfo);
if ((0, Trace_1.isTerminalState)(traceState)) {
// Create full trace recording to get comprehensive results
let traceRecording;
try {
traceRecording = (0, recordingComputeUtils_1.createTraceRecording)(trace, transition);
}
catch (error) {
log(` Failed to create trace recording: ${error}`);
}
if (traceState === 'complete') {
log(colorize(`${traceRecording?.status === 'error' ? '❗' : '✅'} Trace ${traceName} complete`, traceRecording?.status === 'error' ? RED : GREEN));
// Show error if present
if (traceRecording?.error) {
log(` ${colorize('Error:', RED)} %o`, 'log', traceRecording.error);
}
logTimingInfo(transition, traceInfo);
// Log computed results from trace recording
if (traceRecording) {
const { computedValues, computedSpans, computedRenderBeaconSpans } = traceRecording;
if (computedValues && Object.keys(computedValues).length > 0) {
log(` Computed Values: ${Object.entries(computedValues)
.map(([k, v]) => `${k}=${v}`)
.join(', ')}`);
}
if (computedSpans && Object.keys(computedSpans).length > 0) {
log(` Computed Spans: ${Object.entries(computedSpans)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(', ')}`);
}
if (computedRenderBeaconSpans &&
Object.keys(computedRenderBeaconSpans).length > 0) {
log(` Render Beacon Spans:`);
Object.entries(computedRenderBeaconSpans).forEach(([name, data]) => {
log(` ${name}: ${data.renderCount} renders, ${data.firstRenderTillContent.toFixed(2)}ms till content`);
});
}
}
}
else {
// interrupted
const { interruption: { reason: interruptionReason, ...interruptionMeta }, } = transition;
const statusIndicator = traceRecording?.status === 'error'
? colorize('❌❗', RED) // Double error indicator
: colorize('❌', RED);
log(`${statusIndicator} Trace ${traceName} interrupted (${colorize(interruptionReason, RED)}, %o)`, 'log', interruptionMeta);
// Show trace error if present
if (traceRecording?.error) {
log(` ${colorize('Trace Error:', RED)} %o`, 'log', traceRecording.error);
}
logTimingInfo(transition, traceInfo);
// Log computed results from trace recording (even for interrupted traces)
if (traceRecording) {
const { computedValues, computedSpans, computedRenderBeaconSpans } = traceRecording;
if (computedValues && Object.keys(computedValues).length > 0) {
log(` Computed Values: ${Object.entries(computedValues)
.map(([k, v]) => `${k}=${v}`)
.join(', ')}`);
}
if (computedSpans && Object.keys(computedSpans).length > 0) {
log(` Computed Spans: ${Object.entries(computedSpans)
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join(', ')}`);
}
if (computedRenderBeaconSpans &&
Object.keys(computedRenderBeaconSpans).length > 0) {
log(` Render Beacon Spans:`);
Object.entries(computedRenderBeaconSpans).forEach(([name, data]) => {
log(` ${name}: ${data.renderCount} renders, ${data.firstRenderTillContent.toFixed(2)}ms till content`);
});
}
}
}
// Remove trace from active map on terminal state
activeTraces.delete(traceId);
}
log('', 'groupEnd'); // End the group for this transition
};
/**
* Handle required span seen event
*/
const handleRequiredSpanSeen = (event) => {
const { traceContext: trace, spanAndAnnotation: matchedSpan, matcher, } = event;
const traceId = trace.input.id;
const traceInfo = activeTraces.get(traceId);
if (!traceInfo)
return;
// Find and update the matched span in our info
const requiredSpanEntry = traceInfo.requiredSpans.find((s) => s.matcher === matcher);
if (requiredSpanEntry && !requiredSpanEntry.isMatched) {
requiredSpanEntry.isMatched = true;
}
const name = requiredSpanEntry?.name ?? (0, debugUtils_1.formatMatcher)(matcher); // Use formatted name
const groupLevel = options.verbose ? 'group' : 'log'; // Only group if verbose
log(`${colorize('🔹 Matched required span:', MAGENTA)} ${name} (${getRequiredSpansCount(traceInfo)} spans matched)`, groupLevel);
if (options.verbose) {
const relativeTime = formatRelativeTime(matchedSpan.annotation.operationRelativeStartTime);
log(` At time: ${relativeTime}`);
if (matchedSpan.span.name) {
log(` Span name / type: ${matchedSpan.span.name} / ${matchedSpan.span.type}`);
}
if (matchedSpan.span.attributes &&
Object.keys(matchedSpan.span.attributes).length > 0) {
log(` Span Attributes: ${colorize(truncateObject(matchedSpan.span.attributes), GRAY)}`);
}
if (matchedSpan.span.relatedTo &&
Object.keys(matchedSpan.span.relatedTo).length > 0) {
log(` Span RelatedTo: ${colorize(truncateObject(matchedSpan.span.relatedTo), GRAY)}`);
}
if (groupLevel === 'group')
log('', 'groupEnd'); // Close group if we opened one
}
};
// Set up event subscriptions
// --------------------------
// Subscribe to trace start events
const traceStartSubscription = traceManager
.when('trace-start')
.subscribe(handleTraceStart);
subscriptions.push(traceStartSubscription);
// Subscribe to state transition events
const stateTransitionSubscription = traceManager
.when('state-transition')
.subscribe(handleStateTransition);
subscriptions.push(stateTransitionSubscription);
// Subscribe to required span seen events
const spanSeenSubscription = traceManager
.when('required-span-seen')
.subscribe(handleRequiredSpanSeen);
subscriptions.push(spanSeenSubscription);
// Subscribe to add-span-to-recording events for live updates
const addSpanSub = traceManager
.when('add-span-to-recording')
.subscribe((event) => {
const trace = event.traceContext;
const traceId = trace.input.id;
const traceInfo = activeTraces.get(traceId);
if (!traceInfo)
return;
// Calculate live info from traceContext
const entries = [...trace.recordedItems.values()];
traceInfo.liveDuration =
entries.length > 0
? Math.round(Math.max(...entries.map((e) => e.span.startTime.epoch + e.span.duration)) - trace.input.startTime.epoch)
: 0;
traceInfo.totalSpanCount = entries.length;
traceInfo.hasErrorSpan = entries.some((e) => e.span.status === 'error' && !(0, debugUtils_1.isSuppressedError)(trace, e));
traceInfo.hasSuppressedErrorSpan = entries.some((e) => e.span.status === 'error' && (0, debugUtils_1.isSuppressedError)(trace, e));
// Log error if this span is error
if (event.spanAndAnnotation.span.status === 'error') {
const suppressed = (0, debugUtils_1.isSuppressedError)(trace, event.spanAndAnnotation);
log(`${colorize('❗ Error span', RED)} '${event.spanAndAnnotation.span.name}' seen${suppressed ? ' (suppressed)' : ''} %o`, 'log', event.spanAndAnnotation);
}
});
subscriptions.push(addSpanSub);
// Subscribe to definition-modified events
const defModSub = traceManager
.when('definition-modified')
.subscribe((event) => {
const trace = event.traceContext;
const traceId = trace.input.id;
const traceInfo = activeTraces.get(traceId);
if (!traceInfo)
return;
traceInfo.definitionModifications.push(event.modifications);
log(`${colorize('🔧 Definition modified', MAGENTA)}: ${Object.keys(event.modifications).join(', ')}`);
});
subscriptions.push(defModSub);
// Return API for the logger
return {
getActiveTraces: () => new Map(activeTraces), // Return copy of active traces
// Allow changing options
setOptions: (newOptions) => {
// Update the internal mutable options object
options = { ...options, ...newOptions };
// Re-evaluate derived flags after updating options
isConsoleLike =
typeof options.logger !== 'function' && options.logger.group;
canGroup = isConsoleLike && options.enableGrouping;
canColor = isConsoleLike && options.enableColors;
},
// Cleanup method to unsubscribe from all trace events
cleanup: () => {
subscriptions.forEach((subscription) => void subscription.unsubscribe());
subscriptions.length = 0; // Clear the array
activeTraces.clear(); // Clear all active traces
// Optional: Log cleanup only if verbose or specifically configured?
// log('ConsoleTraceLogger unsubscribed from all events')
},
};
}
//# sourceMappingURL=ConsoleTraceLogger.js.map