UNPKG

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