A set of hooks designed to help you measure TTI and arbitrary timing of your components.
It can be valuable to track durations in which the application is in undesirable states. This includes states like fetching data, React rendering, DOM painting, and external reasons for slow, jittery or unresponsive components (e.g. user's browser extension or account's Apps). Time-to-interactive is an industry standard metric that captures the amount of time it takes the application to reach a state in which the user can successfully interact with it. It is usually applied to first page loads. Such timing data can often be used as SLIs, help with debugging issues, understanding the level of performance across different cohorts of customers, and even serve as a helpful additional indicator of possible incidents.
The hooks available in this package, are made to capture a TTI-like metric that can be measured on the much more granular level of a React component. It can be used to capture the time it takes a component to become interactive, but also user initiated actions, or actions that go through multiple stages, and time those stages individually.
npm install @zendesk/react-measure-timing-hooks
Check out storybook in this repo for live examples.
First, create a file that generates and exports the hooks for your metric ID prefix by using
generateTimingHooks
.
For example:
import {
generateTimingHooks,
generateReport,
} from '@zendesk/react-measure-timing-hooks'
export const {
// the name of the hook(s) are generated based on the `name` and the placement names
useConversationTimingInConversationPane,
useConversationTimingInConversation,
} = generateTimingHooks(
{
name: 'Conversation',
idPrefix: 'ticket/conversation',
reportFn: (reportArguments) => {
const defaultReport = generateReport(reportArguments)
// do something with the report, e.g. send select data to Datadog RUM or other analytics
},
},
// name of the first component this hook will be placed in:
'ConversationPane',
// any further arguments are name(s) of subsequent beacon placement components:
'Conversation',
)
Then use the hooks in your component(s):
import {
useConversationTimingInConversationPane,
useConversationTimingInConversation,
} from '@zendesk/react-measure-timing-hooks'
const ConversationPane = ({ conversationId }) => {
useConversationTimingInConversationPane({
metadata: {
// any metadata you want to pass along to the reportFn
conversationId,
},
})
return (
<>
<h2>This is a conversation no. ${conversationId}!</h2>
<Conversation conversationId={conversationId} />
</>
)
}
import {
useConversationTimingInConversation,
useConversationTimingInConversation,
} from '@zendesk/react-measure-timing-hooks'
const Conversation = ({ conversationId }) => {
const messages = useMessages(conversationId)
const conversationType = useConversationType(conversationId)
useConversationTimingInConversation({
metadata: {
// any additional metadata you want to pass along to the reportFn
conversationType,
},
})
return <div>{messages}</div>
}
An idSuffix
is required when the component is not expected to be a singleton. If you're uncertain, the following heuristic should help you make the decision whether to add one:
- When the same component is rendered multiple times on a page.
- We expect the component will vary its content (re-render) based on some object variable. For example, its contents change based on another item's selection/visibility, or an action, like opening.
Each metric provided in the report is captured in the report, in milliseconds. The two key, summary metrics are:
tti
— time-to-interactive - captures the time from the moment of activation (which is either the beginning of the rendering, or based on the state provided to the hook - see below) until the component has completed rendering (and re-rendering), and until a specific stage has been reached (optional), and finally after all the UI-blocking work has completed. This will not be reported in all browsers due to limited support of the'longtask'
type performance monitoring (only Chromium-based as of June 2021).ttr
— time-to-render - the subset of the time captured above, from the beginning of the first render, to the end of the last render (of the desired final stage), after which no more re-renders had occured during the specified debounce period.
The full report includes individual stage timings and re-render counts. See the
Report
's interface to see what data is available.
The reportFn
may also be overriden at the call-site of the hook itself (i.e. in the component
render function).
You may also create your own, custom generateTimingHooksWithCustomReporting
function that
abstracts away the reporting logic for your specific scenario, by for example adding the capability
to provide metadata, local to the call-site.
The simplest usage only requires one to define the placement
of the hook. Every hook that comes
from a single set of generated timings must have a different placement for the hook to work
properly.
import { generateTimingHooks } from '@zendesk/react-measure-timing-hooks'
export const {
// the name of the hook(s) are generated based on the `name` and the placement names
useSomethingLoadingTimingInSomeComponentName,
} = generateTimingHooks(
{
name: 'SomethingLoading',
idPrefix: 'some/identifier',
finalStages: [DEFAULT_STAGES.READY],
reportFn: myCustomReportFunction,
},
// name of the first placement
// usually the component that mounts first, from which timing should start
'SomeComponentName',
)
There will be cases when you don't want to start timing until some condition has been satisfied (e.g. until a dropdown is actually open).
If isActive
is present in at least one of the beacons, timing will start from the moment
isActive
turns true, until either of these situations occur:
MyComponent
goes into the final stage- in case no final stages were defined, finishes rendering and its UI becomes interactive
isActive
switches tofalse
import { generateTimingHooks } from '@zendesk/react-measure-timing-hooks'
export const { useSomethingLoadingTimingInMyComponent } = generateTimingHooks(
{
name: 'SomethingLoading',
idPrefix: 'some/identifier',
reportFn: myCustomReportFunction,
},
'MyComponent',
)
const MyComponent = () => {
useSomethingLoadingTimingInMyComponent({
isActive: MY_CONDITION === true,
})
return <div>Hello!</div>
}
To make use of the measurement stages, both finalStages
in the hook generator and stage
in the component need to be specified.
import {
generateTimingHooks,
DEFAULT_STAGES,
} from '@zendesk/react-measure-timing-hooks'
export const { useSomethingLoadingTimingInMyComponent } = generateTimingHooks(
{
name: 'SomethingLoading',
idPrefix: 'some/identifier',
// when one of these stages is reached, we wait until the rendering settles, and send the metrics
finalStages: [DEFAULT_STAGES.READY],
reportFn: myCustomReportFunction,
},
'MyComponent',
)
const MyComponent = () => {
const { data, loading } = useExternalData()
useSomethingLoadingTimingInMyComponent({
isActive: MY_CONDITION === true,
// e.g. DEFAULT_STAGES.LOADING - or a custom-named stage 'searching':
stage: loading ? DEFAULT_STAGES.LOADING : DEFAULT_STAGES.READY,
})
return <div>Hello!</div>
}
If you have more than 2 stages to handle, instead of writing complex ternary expressions, you may use the provided "functional switch" helper, like in the example below:
import {
generateTimingHooks,
DEFAULT_STAGES,
switchFn,
} from '@zendesk/react-measure-timing-hooks'
export const { useSomethingLoadingTimingInMyComponent } = generateTimingHooks(
{
name: 'SomethingLoading',
idPrefix: 'some/identifier',
// when one of these stages is reached, we wait until the rendering settles, and send the metrics
finalStages: [DEFAULT_STAGES.READY],
reportFn: myCustomReportFunction,
},
'MyComponent',
)
const MyComponent = () => {
useSomethingLoadingTimingInMyComponent({
isActive: MY_CONDITION === true,
stage: switchFn(
{ case: !isOpen, return: DEFAULT_STAGES.INACTIVE },
// custom stage:
{ case: isSearching, return: 'searching' },
{ case: isLoading, return: DEFAULT_STAGES.LOADING },
{ return: DEFAULT_STAGES.READY },
),
// we want generateReport to count both of these stages as part of the "loading" process
loadingStages: [DEFAULT_STAGES.LOADING, 'searching'],
})
return <div>Hello!</div>
}
Placing beacon hooks in additional components allows you to start/continue the timer and change stages in a different component. It is also useful in situations where the state of the component we're interested in tracking may completely be destroyed, and shortly after recreated (which would mean the state of our hook would also have been destroyed).
You may add however many beacons you like. The primary hook (first placement) also serves the role of a beacon. Note that if not all beacons are mounted when the final stage is reached (and it is not an ERROR stage), an error will additionally be reported to RUM.
Note that if you set stage
in multiple beacons, only the latest change will be taken into
account, so a re-render of a component containing a beacon with a stale stage
will not cause the
stage
to switch back (otherwise that would be a race condition).
import {
generateTimingHooks,
DEFAULT_STAGES,
switchFn,
} from '@zendesk/react-measure-timing-hooks'
export const {
// the name of the hook(s) are generated based on the `name` and the placement names
useSomethingLoadingTimingInMyComponent,
useSomethingLoadingTimingInMyChildComponent,
useSomethingLoadingTimingInMyOtherComponent,
} = generateTimingHooks(
{
name: 'SomethingLoading',
idPrefix: 'some/identifier',
finalStages: [DEFAULT_STAGES.READY],
// custom debounce (optional)
debounceMs: 500,
// custom timeout (optional)
timeoutMs: 10000,
reportFn: myCustomReportFunction,
},
// names of the placements:
'MyComponent',
'MyChildComponent',
'MyOtherComponent',
)
const MyChildComponent = () => {
useSomethingLoadingTimingInMyChildComponent({
// isActive can be set by both the beacon AND the managing component.
// both have to be active for the tracking to start!
isActive: MY_CONDITION === true,
// e.g. DEFAULT_STAGES.LOADING - or a custom stage 'searching':
stage: CURRENT_STAGE,
})
return 'Hi!'
}
const MyOtherComponent = () => {
useSomethingLoadingTimingInMyOtherComponent()
return 'Hi!'
}
const MyComponent = () => {
useSomethingLoadingTimingInMyComponent({
isActive: MY_CONDITION === true,
stage: switchFn(
{ case: !isOpen, return: DEFAULT_STAGES.INACTIVE },
// custom stage:
{ case: isSearching, return: 'searching' },
{ case: isLoading, return: DEFAULT_STAGES.LOADING },
{ return: DEFAULT_STAGES.READY },
),
})
return (
<div>
<SomethingThatRendersMyChildComponent />
<MyOtherComponent />
</div>
)
}
The hook supports passing in a list of dependencies as the second argument. If any of the dependencies change, the timer will scrap the data it collected and restart from zero.
The dependency list utilizes React's mechanism, so remember that it is a shallow comparison.
It can be used on any of the beacons, including the manager - change of either will cause the timer to reset.
import { generateTimingHooks } from '@zendesk/react-measure-timing-hooks'
export const { useSomethingLoadingTimingInSomeComponentName } =
generateTimingHooks(
{
name: 'SomethingLoading',
idPrefix: 'some/identifier',
finalStages: [DEFAULT_STAGES.READY],
reportFn: myCustomReportFunction,
},
// name of the first placement
// usually the component that mounts first, from which timing should start
'SomeComponentName',
)
const MyComponent = () => {
useSomethingLoadingTimingInSomeComponentName(
{},
// a change of any dependency (here ticketId) will restart the timer:
[ticketId],
)
return <div>Hello!</div>
}
Sometimes you only want to start the timer once a specific component starts rendering. This can be
done by passing in a waitForBeaconActivation
option, and listing which beacons must be rendering
in order to start the timer.
This can be useful for example in components that open conditionally, for example dropdown menus. In this case, the timer should only start once the dropdown starts opening.
import { generateTimingHooks } from '@zendesk/react-measure-timing-hooks'
export const {
useAssigneeDropdownOpeningTimingInAssignee,
useAssigneeDropdownOpeningTimingInMenu,
} = generateTimingHooks(
{
name: 'AssigneeDropdownOpening',
idPrefix: 'components/assignee_dropdown',
waitForBeaconActivation: ['Menu'],
reportFn: myCustomReportFunction,
},
// the component that starts the timing is listed first, typically the component that mounts first
'Assignee',
'Menu',
)
const Menu = () => {
useAssigneeDropdownOpeningTimingInMenu()
// ...
return <div>Hello!</div>
}
const Assignee = () => {
useAssigneeDropdownOpeningTimingInAssignee()
// ...
return isOpen ? <Menu /> : null
}
There might be times, perhaps due to legacy reasons, when you want the timer to continue, even when
a dependency has changed (e.g. ticketId
flipping from -1
to the actual ID). You may provide a
shouldResetOnDependencyChangeFn
option to any of the beacons to mitigate this.
import { generateTimingHooks } from '@zendesk/react-measure-timing-hooks'
export const { useSomethingLoadingTimingInSomeComponentName } =
generateTimingHooks(
{
name: 'SomethingLoading',
idPrefix: 'some/identifier',
finalStages: [DEFAULT_STAGES.READY],
reportFn: myCustomReportFunction,
},
'SomeComponentName',
)
const MyComponent = () => {
useSomethingLoadingTimingInSomeComponentName(
{
shouldResetOnDependencyChangeFn: (
[previousTicketId],
[currentTicketId],
) => {
// some magical logic here, e.g.
return previousTicketId !== '-1'
// meaning — only reset the timer if the previous ticketId wasn't '-1'
},
},
// a change of any dependency (here ticketId) will restart the timer:
[ticketId],
)
return <div>Hello!</div>
}
The hook allows you to track the ratio of successful to unsuccesful loads of a given component.
Passing in an error
property to either the main hook or one of the beacons will cause that load to
end immediately with the lastStage: 'error'
and send out a report.
import { generateTimingHooks } from '@zendesk/react-measure-timing-hooks'
export const { useSomethingLoadingTimingInSomeComponentName } =
generateTimingHooks(
{
name: 'SomethingLoading',
idPrefix: 'some/identifier',
finalStages: [DEFAULT_STAGES.READY],
reportFn: myCustomReportFunction,
},
'SomeComponentName',
)
const MyComponent = () => {
const { data, loading, error } = useQuery(myQuery)
// (...)
useSomethingLoadingTimingInSomeComponentName({
stage: loading ? DEFAULT_STAGES.LOADING : DEFAULT_STAGES.READY,
// passthrough any Error value (null/undefined treated as 'no error'):
error,
})
// (...)
return <div>Hello!</div>
}
Note that manually setting the stage to DEFAULT_STAGES.ERROR
will also trigger this behavior
(although no metadata will be recorded).
By using the ErrorBoundary
component provided by this package, any error caught by it will be
tracked by all the mounted timing hooks, and a relevant report containing the error will be sent.
If you wish to add custom functionality to the ErrorBoundary, feel free to extend the provided component or re-implement it's logic (see its source code for details).
If you have a component that is outside of a React component's lifecycle, you can still use this utility to report timing. The function that generates hooks, also generates an imperative API that can be used to manually trigger lifecycle events.
Here's an example that combines both React and imperative usage. The parent React component will kick off rendering of the non-React child. This relationship can of course be reversed, React does not even need to be used at all; any combination of imperative API and React hooks usage is possible.
import { generateTimingHooks } from '@zendesk/react-measure-timing-hooks'
export const { useOpeningPopupTimingInPanel, imperativePopupTimingApi } =
generateTimingHooks(
{
name: 'OpeningPopup',
idPrefix: 'some/identifier',
finalStages: [DEFAULT_STAGES.READY],
// only start timing when the component starts mounting:
waitForBeaconActivation: ['Popup'],
reportFn: myCustomReportFunction,
},
// the component that starts the timing is listed first, typically the component that mounts first
'Panel',
'Popup',
)
const myCustomPopupRenderer = () => {
imperativePopupTimingApi.markRenderStart(tabId)
// render (...)
imperativePopupTimingApi.markRenderEnd(tabId)
imperativePopupTimingApi.markStage(tabId, 'loading')
// fetch data (...)
imperativePopupTimingApi.markRenderStart(tabId)
// render data (...)
imperativePopupTimingApi.markStage(tabId, DEFAULT_STAGES.READY)
imperativePopupTimingApi.markRenderEnd(tabId)
}
// our React parent component
const Panel = () => {
useOpeningPopupTimingInPanel()
return <Button onClick={myCustomPopupRenderer}>Open Popup!</Button>
}
While hook execution shouldn't be computationally heavy, it's probably a good idea to place both the
manager and the beacon hooks as close to the return
statement as possible in your component.
There may be situations when you want to track the same metric of multiple objects at the same time.
For example when loading is starting or finishing in the background. In this case, you may provide
an idSuffix
to both hooks, however, if using the beacon, you must ensure that it has the same
value in both the manager hook and the beacon. The idSuffix
needs to be unique to each instantiation; e.g. the id
of some object.
Any numeric parts of the id will be stripped out from the metric name, before being reported to your
provided reportFn
.
import {
generateTimingHooks,
switchFn,
DEFAULT_STAGES,
} from '@zendesk/react-measure-timing-hooks'
export const {
useSomethingLoadingTimingInMyComponent,
useSomethingLoadingTimingInMyChildComponent,
} = generateTimingHooks(
{
name: 'SomethingLoading',
idPrefix: 'some/identifier',
finalStages: [DEFAULT_STAGES.READY],
reportFn: myCustomReportFunction,
},
'MyComponent',
'MyChildComponent',
)
const MyChildComponent = ({ ticketId }) => {
useSomethingLoadingTimingInMyChildComponent({
idSuffix: ticketId,
stage: loading ? DEFAULT_STAGES.LOADING : DEFAULT_STAGES.READY,
})
return 'Hi!'
}
const MyComponent = ({ ticketId }) => {
useSomethingLoadingTimingInMyComponent({
// idSuffix *must* be the same in both component!
idSuffix: ticketId,
isActive: MY_CONDITION === true,
stage: loading ? DEFAULT_STAGES.LOADING : DEFAULT_STAGES.READY,
})
return <SomethingThatRendersMyChildComponent />
}