A frontend performance library for composing metrics from real user experiences.
In every heavily trafficked frontend application exists a means for monitoring user experience and customer success. This library is designed to allow developers to compose metrics based on the behaviors their of end users and the performance they experience.
npm i -S @figliolia/metrics
# or
yarn add @figliolia/metrics
Metrics can wrap any experience pertinent to your end users. This can include user-onboarding, core features initializing, UI rendering with resolved API data, and more. Tracking these metrics in production environments will help assess real customer performance, catch regressions and bugs, and assist with identifying pain points within your application.
import { Metric } from "@figliolia/metrics";
const MyMetric = new Metric("Initial Render");
MyMetric.on("start" | "stop" | "reset", metric => {
// Listen for events fired!
});
async function fetchData(query: any) {
MyMetric.start();
const response = await fetch({
url: "/data",
data: JSON.stringify(query)
});
const data = await response.json();
// format response data for your UI framework
await renderUI(data);
// Stop the metric once the UI renders with its data
MyMetric.stop();
}
Interaction Metrics add reliability indicators to typical performance metrics. When using Interaction Metrics, you have the option to fail
and succeed
the metric based on the outcome of the interaction.
import { InteractionMetric } from "@figliolia/metrics";
const SignUpMetric = new InteractionMetric("Sign Up");
SignUpMetric.on("success" | "failure", metric => {
// Listen for events fired!
});
async function signUp(username: string, password: string) {
SignUpMetric.start();
try {
const response = await fetch({
url: "/sign-up",
data: JSON.stringify({ username, password })
});
const data = await response.json();
// Redirect the user to the Home Page
void redirectToHome(data);
// Succeed the metric
SignUpMetric.succeed();
} catch(error: unknown) {
// Fail the metric
SignUpMetric.fail({ error });
}
}
The Metric
interface is designed for tracking all kinds of performance indicators. The Metric
class operates as an event emitter, tracking start and stop times for a given user experience.
On top of tracking durations for various user-scenarios, your metrics can be subscribed to from anywhere in your application. This means you can execute any logic you wish that will be deferred entirely behind the successful execution of your Metric
.
Let's look at a working example:
// HomePageMetric.ts
import { Metric } from "@figliolia/metrics";
export const HomePageInteractivity = new Metric("Home Page Interactivity");
HomePageInteractivity.on("stop", async (metric) => {
// On stop, let's post our Home Page Interactivity metric to an analytics service!
void fetch("/analytics", {
method: "POST",
body: JSON.stringify(metric)
});
// Let's also preload a secondary experience once our Home Page
// becomes fully interactive
ExpensiveOffScreenComponent.preload();
});
Next up, let's implement the the metric above in our UI code:
I'm going to use React for spinning up some example UI, but the same principals can apply to any UI framework you wish
import { useState, useEffect } from "react";
import { HomePageInteractivity } from "./HomePageMetric";
export const HomePage = () => {
const [state, setState] = useState<{
username: string,
friends: string[]
}>(undefined);
useEffect(() => {
// Let's start the metric immediately on mount!
HomePageInteractivity.start();
fetch("/user-data").then(data => {
setState(data);
// Lets stop the metric once all data required
// for interactivity has loaded successfully
HomePageInteractivity.stop();
});
}, []);
if(!data) {
return <Loader />
}
return (
<section>
<h1>{state.username}</h1>
<ol>
{state.data.map(friend => <li key={friend}>{friend}</li>)}
</ol>
</section>
);
}
With less than 10 lines of code, we've implemented a metric that times the interactivity of our Home Screen, preloads secondary content, and sends the results to a backend server!
But let's take this one step further. The metric above effectively records the duration of the /user-data
request and the content rendering on the screen. Let's instead, allow the metric to begin recording as soon as the browser navigates to the HomePage
.
To do this, we need to add two lines of code to our metric's declaration:
// First let's import the PageLoadPlugin
import { Metric, PageLoadPlugin } from "@figliolia/metrics";
// Enable the plugin to record a timestamp on each
// pushstate event
PageLoadPlugin.enable();
export const HomePageInteractivity = new Metric("Home Page Interactivity", {
// Next, let's add the plugin to our Metric!
pageLoad: new PageLoadPlugin(true)
// `True` indicates the usage of the browser's History API
});
Now our "Home Page Interactive" metric will record the time between the browser navigating to the Home Page and our data-populated UI rendering - giving us a real measurement of the page's interactivity.
These metrics combine the functionality of the core Metric
interface with success/failure
indicators. They're designed for tracking not only performance, but feature-reliability as well.
Let's take a look at a working example:
import { useState, type Dispatch, type SetStateAction } from "react";
import { InteractionMetric } from "@figliolia/metrics":
const SignUpMetric = new InteractionMetric("Sign Up Reliability");
export const SignUpUI = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const onChange = (func: Dispatch<SetStateAction<string>>) => {
return (e: KeyboardEvent<HTMLInputElement>) => {
func(e.target.value);
}
}
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
// Start the metric on submit
SignUpMetric.start();
try {
await fetch("/sign-up", {
method: "POST",
body: JSON.stringify({ email, password })
});
void redirectToHome();
// Succeed the metric after successfully redirecting
SignUpMetric.succeed();
} catch(error) {
// Fail the metric with an attached error
SignUpMetric.fail({ error });
}
};
return (
<form onSubmit={onSubmit}>
<input
name="Email"
type="text"
value={email}
onChange={onChange(setEmail)} />
<input
name="Password"
type="password"
value={password}
onChange={onChange(setPassword)} />
<button type="submit" value="Sign Up" />
</form>
);
}
When our form is submitted, our SignUpMetric
is going to track the duration of our form submission as well as its rate of success and failure. Similar to our first example, we can subscribe to our metric's events and
- Post our metrics to a remote service
- Run reactionary logic to our Metric succeeding or failing
import type { Metric } from "@figliolia/metrics";
const SignUpMetric = new InteractionMetric("Sign Up Reliability");
SignUpMetric.on("stop", (metric) => {
if(metric.succeeded) {
redirectToHomeScreen();
} else {
showErrorModal();
}
void fetch("/analytics", {
method: "POST",
body: JSON.stringify(metric)
});
});
Experience Metrics are designed to allow developers to compose metrics from one or more sub-metrics.
The ExperienceMetric
derives it's duration using the earliest start-time and the latest stop-time across all of its child-metrics. This metric is great for complex interfaces with numerous moving parts. Some strong use-cases for Experience Metrics are:
- Complex interactions with several trackable sub-processes
- UI routes with multiple core features delivered to the browser asynchronously
Let's create a working example:
import { Metric, ExperienceMetric } from "@figliolia/metrics";
// Metrics for HomeScreen components
export const HeaderMetric = new Metric("Header Performance");
export const FooterMetric = new Metric("Footer Performance");
export const DashboardMetric = new Metric("Dashboard Performance");
// An Experience Metric for the HomeScreen
export const HomeScreenMetric = new ExperienceMetric({
name: "Home Screen Performance",
metrics: [
HeaderMetric,
FooterMetric,
DashboardMetric
]
});
// Post the metric to your analytics service on "stop"
HomeScreenMetric.on("stop", (metric) => {
void fetch("/analytics", {
method: "POST",
body: JSON.stringify(metric)
});
});
In the example above, the HomeScreenMetric
will have a startTime
equal to the earliest start()
out of each of the sub-metrics. Similarly, the HomeScreenMetric
will have a stopTime
equal to the last stop()
of each of the sub-metrics. The duration will be computed upon the startTime
and stopTime
to compose an overarching metric for the Home Screen.
ExperienceMetrics
can accept any combination of Metrics
and InteractionMetrics
. If you'd like to have a metric recording not only render performance, but the success-rate of a certain interaction, simply compose your ExperienceMetric
using a combination of Metric
and InteractionMetric
instances.
Plugins are a developer API designed to enhance your metrics with any extra data or functionality your wish to add. This library comes out of the box with a few Plugins
designed to assist with:
- Sending your metrics to the backend service of your choosing (
ReporterPlugin
) - Tracking your metrics in relation to the most recent browser navigation (
PageLoadPlugin
) - Tracking cumulative layout shift for metrics associated with UI features (
CLSPlugin
) - Tracking the total weight resources required to deliver a feature or metric (
CriticalResourcePlugin
) - Tracking the cache-rate of resources required to deliver a feature or metric (
CriticalResourcePlugin
)
Let's dive into each plugin, then build one of our own!
In several of the prior examples, we've subscribed to our Metric
's stop
event in order to send our metrics to a backend server. Using the ReporterPlugin
, we can actually handle all of our metric reporting without writing any individual subscriptions on each metric.
import { ReporterPlugin, ProcessingQueue } from "@figliolia/metrics";
// This queue will batch requests to the destination specified
const Queue = new ProcessingQueue("https://my-analytics-service.com", metrics => {
/*
Format outgoing metrics in any way you wish
and append any extra data to your request. The
returned value will be passed directly to HTTP
calls as the body parameter
*/
return JSON.stringify(metrics)
});
Now, let's pass our ProcessingQueue
to our Metrics
using the ReporterPlugin
!
import {
Metric,
InteractionMetric,
ExperienceMetric,
ReporterPlugin
} from "@figliolia/metrics";
import { Queue } from "./MyQueue";
const MyMetric = new Metric("My Metric", {
reporter: new ReporterPlugin(Queue)
});
const MyInteraction = new MyInteraction("My Interaction", {
reporter: new ReporterPlugin(Queue)
});
const MyExperience = new ExperienceMetric({
name: "My Experience",
metrics: [MyMetric, MyInteraction],
plugins: { reporter: new ReporterPlugin(Queue) },
});
Each of the metrics above will now add their results to the ProcessingQueue
when their stop
events are called. The Queue will then make batched post requests to the specified endpoint containing each metric's results.
The ReporterPlugin
will also reliably send out all metrics in its Queue
if a browser session is terminated or moved to the background unexpectedly.
The PageLoadPlugin
allows for measuring Metric
durations using the latest browser navigation. This allows for measuring the duration of a feature's first paint (or TTI) relative to moment your application reaches the browser or transitions between routes.
import { Metric, PageLoadPlugin } from "@figliolia/metrics";
const ProfilePageMetric = new Metric("Profile Page", {
pageLoad: new PageLoadPlugin()
});
When calling ProfilePageMetric.start()
, the Metric
's startTime
is set to the time of the last navigation. The duration of the Metric
is equal to the time between the last navigation and when ProfilePageMetric.stop()
is called.
Cumulative Layout Shift is a visual stability metric designed to measure the propensity for elements on the page to suddenly change positions. CLS occurs most commonly between a page's first-paint and subsequent paints where data begins populating the page. A common strategy for minimizing CLS is to render data-hydrated pages on the server - however, some UI features require the client to fully function. For features such as these, this library provides the CLSPlugin
.
This plugin allows for tracking the layout position of a UI element between a Metric's start()
and stop()
calls. On start()
the plugin will capture the target element's absolute position. On stop()
, the absolute position will be captured again and compared to the prior position:
import type { FC } from "react";
import { useState, useEffect } from "react";
import { Metric, CLSPlugin } from "@figliolia/metrics";
const UserAvatar: FC<{ userID: string, nodeID: string }> = ({ userID, nodeID }) => {
const metricRef = useRef(new Metric("Avatar", {
CLS: new CLSPlugin(`#${nodeID}`) // any dom selector
}));
const [user, setUser] = useState<{
url: string,
name: string
} | null>(null);
useEffect(() => {
const metric = metricRef.current;
// Start the metric on mount
metric.start();
fetch(`/user/${userID}`).then((user) => {
setUser(user);
// Stop the metric after rendering the element with new data
metric.stop();
});
return () => {
metric.reset();
}
}, [userID])
return (
<div
id={nodeID}
className="user-avatar">
{
!user ?
<Loading />
: (
<>
<img src={user.url} />
<span>{user.name}</span>
</>
)
}
</div>
);
}
The Metric found in the example above might look something like this when stop()
is called:
const result = {
"name": "Avatar",
"startTime": 1000,
"stopTime": 1200,
"duration": 200,
"status": "complete",
"plugins": {
"CLS": {
"selector": "your node's css selector",
// The Avatar instance's bounding client rect
"initialLayout": {
"x": 800,
"y": 200,
"top": 200,
"right": 800,
"left": 200,
"bottom": 163,
"height": 50,
"width": 50
},
// A list of layout shifts that took place on the element between
// metric.start() and metric.stop()
"layoutShifts": [{
"time": 1200,
"layoutShift": {
"right": 65,
"width": 65
}
}]
}
}
}
There's one more cool feature found under-the-hood of the CLSPlugin
. When using the plugin, you can record the position of an element any number of times between calls to Metric.start()
and Metric.stop()
. If you happen to have more rendering conditions than the example found above, you can run the following as many times as you wish:
const AvatarMetric = new Metric("Avatar", {
CLS: new CLSPlugin(".user-avatar")
});
AvatarMetric.plugins.CLS.inspect();
// The `inspect()` method will calculate the elements current
// position and create an entry in the `layoutShifts` array
// if a shift is detected!
This plugin is designed to track resources contributing to a feature's Critical Path. The plugin will calculate the total weight of JavaScript and CSS required to deliver your feature to the browser as well as the cache-rate of those resources. By default, all JavaScript
and CSS
resources served to the browser will be accounted for, but developers may opt in to tracking any file extensions they wish.
Let's dive into an example using our ExperienceMetric
from a previous example:
import { Metric, PageLoadPlugin, CriticalResourcePlugin } from "@figliolia/metrics";
// Enable using the browser's navigation as the startTime
PageLoadPlugin.enable();
// Home Screen sub-metrics
export const HeaderMetric = new Metric("Header TTI");
export const FooterMetric = new Metric("Footer TTI");
export const DashboardMetric = new Metric("Dashboard TTI");
// Home Screen Experience
export const HomeScreenMetric = new ExperienceMetric({
name: "Home Screen",
metrics: [
HeaderMetric,
FooterMetric,
DashboardMetric
],
plugins: {
// Let's enable the `PageLoadPlugin` to track durations from
// the browser's most recent navigation
pageLoad: new PageLoadPlugin(),
// Let's add our `CriticalResourcePlugin` to track Critical
// Path and cache rate for JavaScript, CSS, and SVG's
resources: new CriticalResourcePlugin(["js", "css", "svg"])
}
});
HomeScreenMetric.on("stop", (metric) => {
/*
HomeScreenMetric {
"name": "Home Screen",
"startTime": 0,
"stopTime": 2500,
"duration": 2500,
"status": "complete",
"metrics": [HeaderMetric, FooterMetric, DashboardMetric],
"plugins": {
// The resource-weight and cache reate of your Home Screen
"resources": {
"criticalSize": 200000 // (bytes),
"cacheRate": 75 // (%),
"extensions": ["js", "css", "svg"]
}
}
}
*/
});
This plugin allows developers to access their metrics using the native Performance API. When the PerformanceMeasurePlugin
is enabled, your Metric
will create a performance.measure()
each time its stop()
event is reached:
import { Metric, PerformanceMeasurePlugin } from "@figliolia/metrics";
const MyMetric = new Metric("My Metric", {
measure: new PerformanceMeasurePlugin()
});
MyMetric.start();
MyMetric.stop();
const nativeMetric = performance.getEntriesByName("My Metric");
/*
[{
name: "My Metric",
start: 123,
end: 124,
duration: 1
}]
*/
In the past few examples, we've added plugins on an adhoc basis to the Metrics we create. Let's now look at creating Metrics with a default set of enabled plugins to save time and developer effort:
import {
MetricFactory,
LoggerPlugin,
ReporterPlugin,
ProcessingQueue,
} from "@figliolia/metrics";
let Queue: ProcessingQueue | undefined;
const Plugins = {
// Specify any Plugins you wish
logger: LoggerPlugin,
reporter: ReporterPlugin
}
if(process.env.NODE_ENV === "production") {
// Remove logging in production
delete Plugins.logger
// initialize the ProcessingQueue to report metrics
// to your server
Queue = new ProcessingQueue("/analytics");
} else {
// Remove reporting during development and testing
delete Plugins.reporter
}
export const Factory = new MetricFactory(Plugins, Queue);
Next, let's create metrics using our Factory
:
import { Factory } from "./MyFactory";
const MyMetric = Factory.createMetric("My Metric");
const MyInteraction = Factory.createInteraction("My Metric");
const MyExperience = Factory.createExperience({
name: "My Metric",
metrics: [MyMetric, MyInteraction]
});
// In production, each metric will have the `ReporterPlugin` enabled
// During development and testing, each metric will have the `LoggerPlugin` enabled
Creating factories can save time and effort when creating metrics. In a real world application we might have a Metric
for each route we support - and each of these Metrics will likely need the PageLoadPlugin
. To relieve the need to instantiate a PageLoadPlugin
on each and every metric, we can create another MetricFactory
.
import { MetricFactory, PageLoadPlugin } from "@figliolia/metrics";
const RouteMetricFactory = new MetricFactory({
pageLoad: PageLoadPlugin
});
// Metrics for each Route in our application
export const HomeMetric = RouteMetricFactory.createMetric("Home Page");
export const ContactPage = RouteMetricFactory.createMetric("Contact Page");
export const ProfileMetric = RouteMetricFactory.createMetric("Profile Page");
Now that we've gone through built-in plugins and applying them using Factories, let's talk about building plugins of our own.
Plugins are designed to be a simple API for attaching functionality to your metrics. Metrics
by default, emit events for start
, stop
, reset
, and for InteractionMetrics
, success
and failure
. Each of these events can be used to run customized logic through plugins:
import { Plugin, Metric } from "@figliolia/metrics";
export class MyLogger extends Plugin {
public myAttribute = true;
// To subscribe to a Metric's events, simply override its
// corresponding method:
protected override start(metric: Metric) {
console.log(metric.name, "Started!");
}
protected override stop(metric: Metric) {
console.log(metric.name, "Stopped!");
}
protected override reset(metric: Metric) {
console.log(metric.name, "Reset!");
}
public method() {
console.log("Called my method!")
}
}
// Let's add this logging plugin to a Metric!
const MyMetric = new Metric("My Metric", {
logger: new MyLogger()
});
// Run publicly exposed methods
MyMetric.plugins.logger.method();
// Access the current state of your plugin
MyMetric.plugins.logger.myAttribute = true;
Now, let's build something that may be helpful in catching performance regressions before they reach production.
Let's build a profiler for staging and development environments that log warnings when a Metric exceeds a certain threshold for duration:
import { Plugin, Metric } from "@figliolia/metrics";
export class ProfilerPlugin extends Plugin {
public threshold: number;
private static enabled = process.env.NODE_ENV !== "production";
constructor(threshold: number) {
this.threshold = threshold;
}
protected override stop(metric) {
if(ProfilerPlugin.enabled && metric.duration > this.threshold) {
console.warn(
`${metric.name} exceeded the threshold of ${this.threshold} milliseconds.`
);
}
}
}
export const MyMetric = new Metric("My Metric", {
profiler: new ProfilerPlugin(1000)
});
Using our new plugin, MyMetric
will log a warning to the console each time its duration exceeds 1000ms
.
To find some recipes in an example application, please reference our Demo App