@zendesk/retrace
Version:
define and capture Product Operation Traces along with computed metrics with an optional friendly React beacon API
539 lines (485 loc) • 16.6 kB
text/typescript
/* eslint-disable no-continue */
/* eslint-disable @typescript-eslint/prefer-for-of */
export interface TimelineOptions {
scale?: number // time units per character
width?: number // maximum width of the timeline in characters
startTime?: number // forced start time for the timeline
gapThreshold?: number // fraction of total time to consider a gap as long (default 0.2 for 20%)
}
const EVENTS = 'events'
const TIMELINE = 'timeline'
const TIME = 'time (ms)'
const DEFAULT_MAX_WIDTH = 80
const DEFAULT_GAP_THRESHOLD = 0.2
// Function to format durations nicely
function formatDuration(duration: number): string {
// Display in milliseconds
return `${Math.round(duration)}`
}
// Function to generate the gap marker string based on the duration
function generateGapMarker(duration: number): string {
const durationStr = formatDuration(duration)
return `-<⋯ +${durationStr} ⋯>-`
}
export function generateAsciiTimeline(
entries: PerformanceEntry[],
options: TimelineOptions = {},
): string {
const maxWidth = options.width ?? DEFAULT_MAX_WIDTH
const gapThreshold = options.gapThreshold ?? DEFAULT_GAP_THRESHOLD
if (entries.length === 0) {
return ''
}
// Determine the time range
const minTime =
options.startTime ?? Math.min(...entries.map((e) => e.startTime))
const maxTime = Math.max(...entries.map((e) => e.startTime + e.duration))
const totalTime = maxTime - minTime
// Sort entries by startTime
const sortedEntries = [...entries].sort((a, b) => a.startTime - b.startTime)
// Merge overlapping events to find active periods
interface TimePeriod {
start: number
end: number
}
const activePeriods: TimePeriod[] = []
for (const entry of sortedEntries) {
const entryEnd = entry.startTime + entry.duration
if (activePeriods.length === 0) {
activePeriods.push({ start: entry.startTime, end: entryEnd })
} else {
const last = activePeriods[activePeriods.length - 1]!
if (entry.startTime <= last.end) {
// Overlapping, extend the last period
last.end = Math.max(last.end, entryEnd)
} else {
// Non-overlapping, add new period
activePeriods.push({ start: entry.startTime, end: entryEnd })
}
}
}
// Find gaps between active periods
interface Segment {
type: 'active' | 'gap'
start: number
end: number
}
const segments: Segment[] = []
let current = minTime
for (const period of activePeriods) {
if (period.start > current) {
// Gap exists
segments.push({ type: 'gap', start: current, end: period.start })
}
// Active period
segments.push({ type: 'active', start: period.start, end: period.end })
current = period.end
}
// Check for gap after the last active period
if (current < maxTime) {
segments.push({ type: 'gap', start: current, end: maxTime })
}
// Determine which gaps to compress based on threshold
const compressedSegments: Segment[] = []
let numCompressedGaps = 0
for (const segment of segments) {
if (segment.type === 'gap') {
const gapDuration = segment.end - segment.start
if (gapDuration / totalTime > gapThreshold) {
// Compress this gap
compressedSegments.push({
type: 'gap',
start: segment.start,
end: segment.end,
})
numCompressedGaps += 1
} else {
// Treat as active to maintain scale
compressedSegments.push({
type: 'active',
start: segment.start,
end: segment.end,
})
}
} else {
compressedSegments.push(segment)
}
}
// Calculate the total active time
const totalActiveTime = compressedSegments
.filter((seg) => seg.type === 'active')
.reduce((sum, seg) => sum + (seg.end - seg.start), 0)
// Calculate the scale
const availableWidth = maxWidth - numCompressedGaps * 10 // Estimate average gap marker length
const effectiveAvailableWidth = availableWidth > 0 ? availableWidth : maxWidth
const scale =
options.scale ??
Math.max(1, Math.ceil(totalActiveTime / effectiveAvailableWidth))
// Build the timeline segments with their character lengths
interface TimelineSegment {
type: 'active' | 'gap'
start: number
end: number
charLength: number
gapMarker?: string // Only for gap segments
}
const timelineSegments: TimelineSegment[] = []
for (const segment of compressedSegments) {
if (segment.type === 'active') {
const duration = segment.end - segment.start
const length = Math.max(1, Math.floor(duration / scale))
timelineSegments.push({
type: 'active',
start: segment.start,
end: segment.end,
charLength: length,
})
} else if (segment.type === 'gap') {
const gapDuration = segment.end - segment.start
const gapMarker = generateGapMarker(gapDuration)
const charLength = gapMarker.length
timelineSegments.push({
type: 'gap',
start: segment.start,
end: segment.end,
charLength,
gapMarker, // Store the gap marker string
})
}
}
// Calculate the total timeline length
const timelineLength = timelineSegments.reduce(
(sum, seg) => sum + seg.charLength,
0,
)
// Map time ranges to character ranges
type CumulativeSegment = TimelineSegment & {
charStart: number
charEnd: number
}
const cumulativeSegments: CumulativeSegment[] = []
let cumulativeChar = 0
for (const seg of timelineSegments) {
const charStart = cumulativeChar
const charEnd = cumulativeChar + seg.charLength
cumulativeSegments.push({
...seg,
charStart,
charEnd,
})
cumulativeChar += seg.charLength
}
// Function to map a given time to character position
const mapTimeToChar = (time: number, round: 'down' | 'up' = 'up'): number => {
for (const seg of cumulativeSegments) {
if (seg.type === 'active') {
if (time >= seg.start && time < seg.end) {
const relativeTime = time - seg.start
const relativeChar =
round === 'down'
? Math.floor(relativeTime / scale)
: Math.ceil(relativeTime / scale)
return seg.charStart + Math.min(relativeChar, seg.charLength - 1)
}
if (time === seg.end) {
// Map to the last character of active segment
return seg.charEnd - 1
}
} else if (seg.type === 'gap' && time >= seg.start && time < seg.end) {
// Map to the start of the gap marker
return seg.charStart
}
}
// If time is exactly at maxTime
return timelineLength - 1
}
// Assign events to rows based on event overlaps
const eventRows: PerformanceEntry[][] = []
const isOverlap = (
row: PerformanceEntry[],
entry: PerformanceEntry,
): boolean => {
for (const e of row) {
if (
!(
entry.startTime >= e.startTime + e.duration ||
entry.startTime + entry.duration <= e.startTime
)
) {
return true
}
}
return false
}
// Assign events to rows
for (const entry of sortedEntries) {
let placed = false
for (const row of eventRows) {
if (!isOverlap(row, entry)) {
row.push(entry)
placed = true
break
}
}
if (!placed) {
eventRows.push([entry])
}
}
// Determine the maximum prefix length
const prefixes = [EVENTS, TIMELINE, TIME]
const maxPrefixLength = prefixes.reduce(
(max, prefix) => Math.max(max, prefix.length),
0,
)
// Pad all prefixes to the maximum length
const padPrefix = (prefix: string) => prefix.padEnd(maxPrefixLength, ' ')
// Initialize label rows for event labels
const labelRows: string[][] = []
for (let i = 0; i < eventRows.length; i++) {
labelRows.push([])
}
// Function to check if label can be placed in a row without overlapping
const canPlaceLabel = (
row: string[],
start: number,
length: number,
): boolean => {
for (let i = start; i < start + length; i++) {
if (i >= row.length) continue // Allow extending
if (row[i] !== ' ') return false
}
return true
}
// Function to place label in the first available row
const placeLabel = (label: string, start: number) => {
for (let rowIndex = 0; rowIndex < labelRows.length; rowIndex++) {
const row = labelRows[rowIndex]!
// Ensure the row is long enough
if (start + label.length > row.length) {
row.length = start + label.length
for (let i = 0; i < row.length; i++) {
if (row[i] === undefined) row[i] = ' '
}
}
if (canPlaceLabel(row, start, label.length)) {
for (let i = 0; i < label.length; i++) {
row[start + i] = label[i]!
}
return
}
}
// If no existing row can accommodate, add a new one
const newRow: string[] = []
newRow.length = start + label.length
for (let i = 0; i < newRow.length; i++) {
newRow[i] = ' '
}
for (let i = 0; i < label.length; i++) {
newRow[start + i] = label[i]!
}
labelRows.push(newRow)
}
// Place event labels in label rows considering label length
for (let rowIndex = 0; rowIndex < eventRows.length; rowIndex++) {
const row = eventRows[rowIndex]!
for (const entry of row) {
const label =
(entry.name !== 'self' ? entry.name : entry.entryType) +
(entry.duration > 0 ? `(${entry.duration})` : '')
const labelStartChar = mapTimeToChar(entry.startTime)
// Place the label starting at labelStartChar
placeLabel(label, labelStartChar)
}
}
// Generate timeline lines
const timelineLines: string[] = []
for (let rowIndex = 0; rowIndex < eventRows.length; rowIndex++) {
const row = eventRows[rowIndex]!
const timelineRowArray = Array.from({ length: timelineLength }, () => '-')
// Insert event markers
for (const entry of row) {
const startChar = mapTimeToChar(entry.startTime)
// const endChar = mapTimeToChar(entry.startTime + entry.duration)
// const durationChars = Math.max(1, endChar - startChar)
if (entry.duration > 0) {
const durationChars = Math.max(1, Math.floor(entry.duration / scale))
if (durationChars === 1) {
// Represent as '|'
if (startChar < timelineRowArray.length) {
timelineRowArray[startChar] = '|'
}
} else if (durationChars === 2) {
// Represent as '[]'
if (startChar < timelineRowArray.length) {
timelineRowArray[startChar] = '['
}
if (startChar + 1 < timelineRowArray.length) {
timelineRowArray[startChar + 1] = ']'
}
} else {
// Represent as '[+...+]'
if (startChar < timelineRowArray.length) {
timelineRowArray[startChar] = '['
}
for (let d = 1; d < durationChars - 1; d++) {
if (startChar + d < timelineRowArray.length) {
timelineRowArray[startChar + d] = '+'
}
}
if (startChar + durationChars - 1 < timelineRowArray.length) {
timelineRowArray[startChar + durationChars - 1] = ']'
}
}
} else if (startChar < timelineRowArray.length) {
// Instantaneous event
timelineRowArray[startChar] = '|'
}
}
// Calculate active periods for the current row
const rowActivePeriods: TimePeriod[] = []
for (const entry of row) {
const entryEnd = entry.startTime + entry.duration
if (rowActivePeriods.length === 0) {
rowActivePeriods.push({ start: entry.startTime, end: entryEnd })
} else {
const last = rowActivePeriods[rowActivePeriods.length - 1]!
if (entry.startTime <= last.end) {
// Overlapping, extend the last period
last.end = Math.max(last.end, entryEnd)
} else {
// Non-overlapping, add new period
rowActivePeriods.push({ start: entry.startTime, end: entryEnd })
}
}
}
// Find gaps in the current row
const rowGaps: TimePeriod[] = []
let currentTime = minTime
for (const period of rowActivePeriods) {
if (period.start > currentTime) {
// Gap exists
rowGaps.push({ start: currentTime, end: period.start })
}
currentTime = period.end
}
// Check for gap after the last active period
if (currentTime < maxTime) {
rowGaps.push({ start: currentTime, end: maxTime })
}
// Insert gap markers specific to the current row
for (const gap of rowGaps) {
const gapDuration = gap.end - gap.start
if (gapDuration / totalTime > gapThreshold) {
// Compress this gap
const gapMarker = generateGapMarker(gapDuration).slice(0, -1)
let startChar = mapTimeToChar(gap.start, 'down')
while (
timelineRowArray[startChar] &&
timelineRowArray[startChar] !== '-'
) {
startChar += 1
}
// Insert gapMarker into timelineRowArray at startChar
for (let i = 0; i < gapMarker.length; i++) {
if (
startChar + i < timelineRowArray.length &&
timelineRowArray[startChar + i] === '-'
) {
timelineRowArray[startChar + i] = gapMarker[i]!
}
}
}
}
timelineLines.push(`${timelineRowArray.join('').trimEnd()}`)
}
// Generate time label rows
const timeEntries = sortedEntries.map((e) => ({
time: e.startTime,
label: e.startTime.toString(),
}))
// Remove duplicate time entries
const uniqueTimeEntriesMap: Record<number, string> = {}
for (const entry of timeEntries) {
uniqueTimeEntriesMap[entry.time] = entry.label
}
const uniqueTimeEntries = Object.keys(uniqueTimeEntriesMap)
.map((k) => Number.parseInt(k, 10))
.sort((a, b) => a - b)
.map((k) => ({ time: k, label: uniqueTimeEntriesMap[k]! }))
// Assign time labels to rows to ensure at least 1 space between labels
const timeLabelRows: string[][] = []
for (const entry of uniqueTimeEntries) {
const pos = mapTimeToChar(entry.time)
const { label } = entry
const startPos = pos
const endPos = pos + label.length - 1
// Attempt to place the label in existing rows
let placed = false
for (const row of timeLabelRows) {
let canPlace = true
for (let i = 0; i < label.length; i++) {
const currentPos = startPos + i
if (row[currentPos] && row[currentPos] !== ' ') {
canPlace = false
break
}
}
// Check for at least one space before and after
if (canPlace) {
if (startPos > 0 && row[startPos - 1] && row[startPos - 1] !== ' ') {
canPlace = false
}
if (
endPos < row.length - 1 &&
row[endPos + 1] &&
row[endPos + 1] !== ' '
) {
canPlace = false
}
}
if (canPlace) {
// Extend the row if necessary
if (startPos + label.length > row.length) {
row.length = startPos + label.length
for (let i = 0; i < row.length; i++) {
if (row[i] === undefined) row[i] = ' '
}
}
for (let i = 0; i < label.length; i++) {
row[startPos + i] = label[i]!
}
placed = true
break
}
}
if (!placed) {
// Create a new row for the label
const newRow: string[] = []
newRow.length = startPos + label.length
for (let i = 0; i < newRow.length; i++) {
newRow[i] = ' '
}
for (let i = 0; i < label.length; i++) {
newRow[startPos + i] = label[i]!
}
timeLabelRows.push(newRow)
}
}
// Generate event label lines with padded prefixes
const eventLabelLines = labelRows.map((row) =>
`${padPrefix(EVENTS)} | ${row.join('')}`.trimEnd(),
)
// Generate timeline label lines with padded prefixes
const timelineLabelLines = timelineLines.map(
(line) => `${padPrefix(TIMELINE)} | ${line}`,
)
// Generate time label lines with padded prefixes
const timeLabelLines = timeLabelRows.map((row) =>
`${padPrefix(TIME)} | ${row.join('')}`.trimEnd(),
)
// Combine all parts
const allLabelLines = eventLabelLines.reverse().join('\n')
const allTimelineLines = timelineLabelLines.join('\n')
const allTimeLabelLines = timeLabelLines.join('\n')
return `${allLabelLines}\n${allTimelineLines}\n${allTimeLabelLines}`
}