@zendesk/react-measure-timing-hooks
Version:
react hooks for measuring time to interactive and time to render of components
246 lines (230 loc) • 6.33 kB
text/typescript
import { adjustTimestampBy } from '../ensureTimestamp'
import type { SupportedSpanTypes } from './constants'
import type { MappedSpanAndAnnotation, RecordingInputFile } from './types'
const orderArray = [
'longtask',
'long-animation-frame',
'computed-span',
'component-render',
'measure',
'resource',
'resource-ember',
'asset',
'iframe',
] satisfies SupportedSpanTypes[]
const ASSET_EXTENSIONS = [
'.mp3',
'.mp4',
'.webm',
'.wav',
'.ogg',
'.flac',
'.aac',
'.aiff',
'.wma',
'.m4a',
'.flv',
'.avi',
'.mov',
'.wmv',
'.mpg',
'.mpeg',
'.mkv',
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
'.bmp',
'.tiff',
'.svg',
'.ico',
'.css',
'.scss',
'.less',
'.styl',
'.html',
'.htm',
'.xml',
'.js',
]
const order: Record<string, number> = Object.fromEntries(
orderArray.map((type, idx) => [type, idx]),
)
export interface MappedOperation {
name: string
spanEvents: MappedSpanAndAnnotation[]
spanTypes: Set<SupportedSpanTypes>
spansWithDuration: MappedSpanAndAnnotation[]
uniqueGroups: string[]
duration: number
}
export const mapOperationForVisualization = (
traceRecording: RecordingInputFile,
{
collapseRenders = true,
collapseAssets = true,
collapseEmberResources = false,
collapseIframes = false,
displayResources = true,
displayMeasures = true,
} = {},
): MappedOperation | null => {
const allEntries = traceRecording.entries
if (!allEntries) return null
const preMappedEntries = allEntries.flatMap<
MappedSpanAndAnnotation & { overrideGroupName?: string }
>((entry) => {
if (entry.span.type === 'component-render-start') {
return []
}
const mapped: MappedSpanAndAnnotation = {
span: entry.span,
annotation: entry.annotation,
groupName: entry.span.name,
type: entry.span.type,
}
let overrideGroupName: string | undefined
let { type } = mapped
if (type === 'resource') {
const filename = (mapped.span.performanceEntry?.name ?? mapped.span.name)
.split('/')
.at(-1)
?.split('?')
.at(0)
const extension = filename?.split('.').at(-1)
if (
filename === '$file' ||
(extension && ASSET_EXTENSIONS.includes(`.${extension}`))
) {
overrideGroupName = overrideGroupName ?? extension
type = 'asset'
}
}
if (collapseRenders && type === 'component-render') {
overrideGroupName = 'renders'
}
if (collapseAssets && type === 'asset') {
overrideGroupName = 'assets'
}
if (collapseIframes && type === 'iframe') {
overrideGroupName = 'iframes'
}
if (type === 'asset' || type === 'iframe') {
overrideGroupName =
overrideGroupName ?? mapped.groupName.split('/').at(-1)
}
if (
type === 'measure' &&
(entry.span.name.endsWith('/tti') || entry.span.name.endsWith('/ttr'))
) {
// remove suffix from measure name
overrideGroupName = entry.span.name.split('/').slice(0, -1).join('/')
}
if (entry.span.name.startsWith('https://')) {
const shortenedName = entry.span.name.split('zendesk.com').at(-1)
if (mapped.span.attributes?.initiatorType === 'xmlhttprequest') {
overrideGroupName = collapseEmberResources
? 'ember-resource'
: overrideGroupName ?? shortenedName
type = 'resource-ember'
}
if (type === 'resource') {
overrideGroupName = overrideGroupName ?? shortenedName
}
}
return {
...mapped,
overrideGroupName,
type,
}
})
const mappedEntries = preMappedEntries.map<MappedSpanAndAnnotation>(
(mapped, idx) => {
if (mapped.groupName.startsWith('graphql/')) {
const clientName = mapped.span.attributes?.apolloClientName
const commonName = mapped.overrideGroupName ?? mapped.groupName
if (clientName === 'local' && mapped.span.attributes?.feature) {
const { feature } = mapped.span.attributes
const matchingResourceTask = preMappedEntries.find(
(t) =>
t.span.attributes?.feature === feature && t.type === 'resource',
)
if (matchingResourceTask) {
matchingResourceTask.groupName = commonName
}
return {
...mapped,
groupName: commonName,
type: 'resource',
}
}
return {
...mapped,
groupName: commonName,
type: 'resource',
}
}
return {
...mapped,
groupName: mapped.overrideGroupName ?? mapped.groupName,
}
},
)
const entriesFromComputedSpans = Object.entries(
traceRecording.computedSpans,
).map<MappedSpanAndAnnotation>(([name, computedSpan]) => ({
groupName: name,
type: 'computed-span',
span: {
type: 'computed-span',
duration: computedSpan.duration,
name,
startTime: adjustTimestampBy(
traceRecording.startTime,
computedSpan.startOffset,
),
relatedTo: traceRecording.relatedTo,
},
annotation: {
id: traceRecording.id,
occurrence: 1,
operationRelativeStartTime: computedSpan.startOffset,
operationRelativeEndTime:
computedSpan.startOffset + computedSpan.duration,
},
}))
const entriesWithComputedSpans = [
...mappedEntries,
...entriesFromComputedSpans,
].sort((a, b) => {
const orderA = order[a.type] ?? 100
const orderB = order[b.type] ?? 100
return orderA - orderB
})
const spansWithDuration = entriesWithComputedSpans
.filter((task) => task.span.duration > 0)
.filter(
(task) =>
(displayResources || task.type !== 'resource') &&
(displayMeasures || task.type !== 'measure'),
)
const spanEvents = entriesWithComputedSpans.filter(
(entry) => entry.span.duration === 0,
)
const spanTypes = new Set(spansWithDuration.map((entry) => entry.type))
const uniqueGroups = [
...new Set(spansWithDuration.map((task) => task.groupName)),
]
return {
name: traceRecording.name,
spansWithDuration,
uniqueGroups,
spanEvents,
spanTypes,
duration:
traceRecording.duration ??
traceRecording.entries.at(-1)?.annotation.operationRelativeEndTime ??
0,
}
}