@zendesk/retrace
Version:
define and capture Product Operation Traces along with computed metrics with an optional friendly React beacon API
770 lines (696 loc) • 24.5 kB
text/typescript
import type {
AllPossibleRequiredSpanSeenEvents,
AllPossibleStateTransitionEvents,
AllPossibleTraceStartEvents,
} from './debugTypes'
import {
extractTimingOffsets,
formatMatcher,
getConfigSummary,
isSuppressedError,
} from './debugUtils'
import type { SpanMatcherFn } from './matchSpan'
import { createTraceRecording } from './recordingComputeUtils'
import type { FinalTransition, OnEnterStatePayload } from './Trace'
import { isTerminalState } from './Trace'
import type { TraceManager } from './TraceManager'
import type { DraftTraceContext, RelationSchemasBase } from './types'
// --- 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
type SimpleLoggerFn = (message: string) => void
// Define a basic ConsoleLike interface for type checking
interface ConsoleLike {
log: (...args: any[]) => void
group: (...args: any[]) => void
groupCollapsed: (...args: any[]) => void
groupEnd: () => void
// Add other console methods if needed (warn, error, etc.)
}
/**
* Options for configuring the ConsoleTraceLogger
*/
export interface ConsoleTraceLoggerOptions {
/**
* The logging mechanism. Can be a console-like object (supporting .log, .group, etc.)
* or a simple function that accepts a string message.
* Defaults to the global `console` object.
*/
logger?: ConsoleLike | SimpleLoggerFn
/**
* Whether to enable verbose logging with more details.
* Defaults to false.
*/
verbose?: boolean
/**
* Prefix added to all log messages.
* Defaults to '[retrace]'.
*/
prefix?: string
/**
* Maximum string length for attributes/relatedTo objects before truncation.
* Defaults to 500.
*/
maxObjectStringLength?: number
/**
* Enable console grouping (.group, .groupCollapsed, .groupEnd).
* Only effective if the logger is a console-like object.
* Defaults to true.
*/
enableGrouping?: boolean
/**
* Enable ANSI color codes in the output.
* Only effective if the logger is a console-like object.
* Defaults to true.
*/
enableColors?: boolean
}
const MAX_SERIALIZED_OBJECT_LENGTH = 500
/**
* Information about an active or completed trace
*/
interface TraceInfo<_RelationSchemasT> {
id: string
name: string
variant: string
startTime: number
attributes?: Record<string, unknown>
relatedTo?: Record<string, unknown>
requiredSpans: { name: string; isMatched: boolean; matcher: Function }[]
parentTraceId?: string
liveDuration: number
totalSpanCount: number
hasErrorSpan: boolean
hasSuppressedErrorSpan: boolean
definitionModifications: unknown[]
}
/**
* Format timestamp to readable time
*/
const formatTimestamp = (timestamp: number): string =>
new Date(timestamp).toISOString().split('T')[1]!.slice(0, -1)
/**
* Check if two objects are different by comparing their JSON representation
*/
const objectsAreDifferent = (
obj1?: Record<string, unknown>,
obj2?: Record<string, unknown>,
): boolean => {
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
*/
export function createConsoleTraceLogger<
RelationSchemasT extends RelationSchemasBase<RelationSchemasT>,
>(
traceManager: TraceManager<RelationSchemasT>,
optionsInput: ConsoleTraceLoggerOptions = {},
) {
// Use a mutable options object internally
let options: Required<ConsoleTraceLoggerOptions> = {
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<string, TraceInfo<RelationSchemasT>>()
// Store subscriptions for cleanup
const subscriptions: { unsubscribe: () => void }[] = []
// --- Helper Functions ---
/** Apply color codes if enabled */
const colorize = (str: string, color: string): string =>
canColor ? `${color}${str}${RESET}` : str
/** Truncate objects to prevent huge logs */
const truncateObject = (obj?: Record<string, unknown>): string => {
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: string,
level: 'log' | 'group' | 'groupCollapsed' | 'groupEnd' = 'log',
...args: unknown[]
) => {
const fullMessage = `${options.prefix} ${message}`
if (isConsoleLike) {
const consoleLogger = options.logger as ConsoleLike
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 as SimpleLoggerFn
// 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?: number): string => {
if (offset === undefined) return ''
const formatted = `+${offset.toFixed(2)}ms`
return colorize(formatted, GRAY)
}
/** Get string representation of required spans count */
const getRequiredSpansCount = (
traceInfo: TraceInfo<RelationSchemasT>,
): string => {
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: SpanMatcherFn<any, RelationSchemasT, any>,
index: number,
): TraceInfo<RelationSchemasT>['requiredSpans'][0] => {
// Use the utility if available, otherwise fallback
const name = formatMatcher(matcher, index)
return { name, matcher, isMatched: false }
}
/** Handle changes in attributes, relatedTo, or requiredSpans */
const handleStateChanges = <K extends keyof RelationSchemasT>(
trace: DraftTraceContext<K, RelationSchemasT, string>,
traceInfo: TraceInfo<RelationSchemasT>,
): void => {
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: OnEnterStatePayload<RelationSchemasT>,
traceInfo: TraceInfo<RelationSchemasT>,
) => {
const { lastRequiredSpanOffset, completeSpanOffset, cpuIdleSpanOffset } =
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: AllPossibleTraceStartEvents<RelationSchemasT>,
) => {
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: TraceInfo<RelationSchemasT> = {
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', // Start collapsed for tidiness
)
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 } = 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: AllPossibleStateTransitionEvents<RelationSchemasT>,
) => {
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 (isTerminalState(traceState)) {
// Create full trace recording to get comprehensive results
let traceRecording
try {
traceRecording = createTraceRecording(
trace,
transition as FinalTransition<RelationSchemasT>,
)
} 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 as Extract<typeof transition, { interruption: unknown }>
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: AllPossibleRequiredSpanSeenEvents<RelationSchemasT>,
) => {
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 ?? 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' && !isSuppressedError(trace, e),
)
traceInfo.hasSuppressedErrorSpan = entries.some(
(e) => e.span.status === 'error' && isSuppressedError(trace, e),
)
// Log error if this span is error
if (event.spanAndAnnotation.span.status === 'error') {
const suppressed = 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: Partial<ConsoleTraceLoggerOptions>) => {
// 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')
},
}
}