Integrating Performance Budgets With Datadog
A performance budget is only as useful as the surface that watches it. This guide, part of the Dashboarding & Team Adoption reference, shows how to route two very different signals — real-user Web Vitals from the browser and synthetic gate results from CI — into a single Datadog account, then convert your budget ceilings into monitors, SLOs, and dashboards that page a human when the field regresses. The goal is not "more graphs"; it is a small number of alerting objects that fire on a genuine P75 breach and stay quiet the rest of the time.
Datadog gives you three ingestion doors — Browser RUM for field vitals, the metrics API and DogStatsD for CI-computed numbers, and the events API for deploy markers. This page treats each door as a stage in one pipeline and closes with the two things teams get wrong: cost control through sampling, and keeping monitor thresholds anchored to the same percentiles your budget document already states.
Datadog RUM for Web Vitals
Datadog Browser RUM already collects Largest Contentful Paint, Interaction to Next Paint, and Cumulative Layout Shift as first-class view attributes, so the fastest path to field data is the RUM SDK rather than a hand-rolled beacon. Install the browser SDK, initialise it with your application id and client token, and enable the vitals it captures automatically per view. The values arrive attached to @view.largest_contentful_paint, @view.interaction_to_next_paint, and @view.cumulative_layout_shift, which means you query them the same way you query any other RUM facet.
import { datadogRum } from '@datadog/browser-rum'
datadogRum.init({
applicationId: 'YOUR_APPLICATION_ID',
clientToken: 'YOUR_CLIENT_TOKEN',
site: 'datadoghq.com',
service: 'storefront-web',
env: 'production',
version: '2026.07.24-1',
sessionSampleRate: 100,
trackResources: true,
trackLongTasks: true,
defaultPrivacyLevel: 'mask-user-input'
})
The version field is the load-bearing one for budget work: set it to the exact build or release tag you deploy, because every regression investigation later depends on being able to split P75 by version. Datadog aggregates the raw per-view vitals into percentiles at query time, so you never store a P75 — you store millions of individual view events and ask for p75:@view.largest_contentful_paint over a rolling window. That distinction matters for cost, which we return to at the end.
If you already run a custom beacon pipeline described in Custom Performance Beacons & RUM, you do not have to abandon it. You can forward the same PerformanceObserver measurements into Datadog as custom RUM actions or as custom metrics, keeping one collection layer and two sinks. Most teams standardise on the Datadog SDK for field vitals and reserve the metrics API strictly for numbers CI computes, because mixing the two in one namespace makes dashboards ambiguous.
Custom Metrics From CI: DogStatsD and the API
Synthetic gate results do not come from a browser session, so RUM is the wrong pipe for them. Instead, emit a custom metric at the end of each CI run. You have two mechanisms: DogStatsD, a UDP protocol served by the Datadog Agent, and the HTTP metrics API for environments where no Agent runs. On ephemeral GitHub Actions runners there is usually no Agent, so the API is the pragmatic choice. Post the Lighthouse median LCP, INP, and total blocking time as gauge points tagged with the branch, the commit, and the environment.
#!/usr/bin/env bash
set -euo pipefail
LCP_MS="$(jq '.audits."largest-contentful-paint".numericValue' lhr.json)"
INP_MS="$(jq '.audits."interaction-to-next-paint".numericValue // 0' lhr.json)"
NOW="$(date +%s)"
curl -sS -X POST "https://api.datadoghq.com/api/v2/series" \
-H "DD-API-KEY: ${DD_API_KEY}" \
-H "Content-Type: application/json" \
-d "{
\"series\": [
{ \"metric\": \"ci.lighthouse.lcp\", \"type\": 3,
\"points\": [ { \"timestamp\": ${NOW}, \"value\": ${LCP_MS} } ],
\"tags\": [\"env:ci\", \"branch:${GITHUB_REF_NAME}\", \"sha:${GITHUB_SHA}\"] },
{ \"metric\": \"ci.lighthouse.inp\", \"type\": 3,
\"points\": [ { \"timestamp\": ${NOW}, \"value\": ${INP_MS} } ],
\"tags\": [\"env:ci\", \"branch:${GITHUB_REF_NAME}\", \"sha:${GITHUB_SHA}\"] }
]
}"
Type 3 is a gauge in the v2 series API, which is what you want for a point-in-time measurement rather than a counter. Tag every point with branch and sha so a dashboard can distinguish a main-branch trend line from noisy feature-branch runs. If you do run the Datadog Agent inside a self-hosted runner, DogStatsD is a one-liner and avoids the API rate limit entirely.
echo "ci.lighthouse.lcp:${LCP_MS}|g|#env:ci,branch:${GITHUB_REF_NAME}" | nc -u -w1 127.0.0.1 8125
Keep the CI namespace (ci.lighthouse.*) distinct from the field namespace (@view.*) so nobody accidentally builds a monitor that averages a lab number and a field number together. Lab and field measure different populations: a Lighthouse run on a throttled mid-range mobile at Fast 3G models a cold worst case, while RUM P75 reflects your actual visitor mix. They belong on the same dashboard but never in the same series.
There is a subtle correctness trap in emitting CI numbers. A single Lighthouse run is noisy — median LCP can swing 300–400 ms between runs on the same commit for a mid-range mobile profile at Fast 3G — so a metric built from one run per commit produces a jagged trend that fires false alerts. Run Lighthouse three to five times per commit, take the median of medians, and emit that one aggregated value. Store the run count as a tag so a dashboard can show confidence at a glance. The alternative — emitting every individual run — inflates your custom-metric point count and still leaves you eyeballing the median by hand.
Monitors on a P75 Breach
With field vitals flowing, build the monitor that actually pages someone. The rule that keeps alerts trustworthy is to evaluate the same percentile your budget document commits to. If your budget states that LCP P75 must stay at or under 2500 ms for mid-range mobile visitors on a typical 4G connection, then the monitor evaluates p75:@view.largest_contentful_paint, filtered to @device.type:mobile, over a rolling window — never an average, because averages hide the tail your users feel.
A RUM metric monitor in Datadog reads cleanly. The query below computes rolling P75 LCP for mobile production traffic over the last 30 minutes and alerts when it crosses the ceiling, with a recovery threshold set below the alert line so a metric hovering on the boundary does not flap.
rum("@type:view @device.type:mobile env:production")
.rollup("pc75", "@view.largest_contentful_paint")
.last("30m") > 2500
Two thresholds prevent alert fatigue. Set the critical threshold at the budget ceiling — 2500 ms for LCP P75 on mid-range mobile at 4G — and the warning threshold about ten percent below it, at 2250 ms, so the on-call sees drift before it becomes a breach. Add an evaluation window long enough to smooth per-minute noise: 30 minutes of mobile traffic on a busy storefront usually contains enough views to make P75 stable, whereas a five-minute window on a low-traffic locale can swing purely from sample size. If your locales differ wildly in volume, create per-locale monitors rather than one global monitor whose P75 is dominated by your largest market.
The same monitor pattern applies to INP and CLS. For INP, evaluate p75:@view.interaction_to_next_paint against a 200 ms ceiling for mid-range mobile at 4G; for CLS, evaluate p75:@view.cumulative_layout_shift against a 0.10 ceiling for the same class. Keep the device and connection context explicit in the monitor name — for example, "LCP P75 mobile 4G over 2500 ms" — so the person woken at 3 a.m. knows the population without opening the query. For a deeper treatment of tuning these alerts against noisy signals, see Alerting on Performance Budget Regressions, and the sibling page on Datadog Monitors for Budget Regressions walks through the full monitor JSON.
Wrapping Budgets as SLOs
A monitor tells you the field is bad right now. An SLO tells you how much badness you have already spent this month, which is the language leadership understands. Datadog lets you build a metric-based SLO on top of a Web Vitals monitor, so your budget becomes a target such as "99% of the time, LCP P75 for mid-range mobile at 4G stays at or below 2500 ms over a rolling 30-day window." The complement — the one percent — is your error budget, and burning it fast is what should trigger escalation.
Model the SLO as a ratio of good time to total time. Define a "good" state as the monitor being in its OK status and a "bad" state as it being in alert. Datadog computes the uptime-style percentage for you and shows the remaining error budget as a countdown. When a regression lands, the burn rate — how quickly you are consuming the remaining budget — becomes the signal that separates a cosmetic wobble from an incident. A page that would exhaust a 30-day error budget in under two days is worth waking someone for; one that would take 40 days to exhaust at the current rate can wait for business hours.
{
"type": "monitor",
"name": "LCP P75 mobile 4G <= 2500ms",
"description": "99% of 30-min windows keep mobile LCP P75 within budget",
"monitor_ids": [ 12345678 ],
"thresholds": [
{ "timeframe": "30d", "target": 99.0, "warning": 99.5 }
],
"tags": [ "team:web-perf", "budget:lcp", "env:production" ]
}
Anchoring the SLO to the same percentile and device context as the budget keeps the whole system coherent: the budget document, the monitor query, and the SLO target all say "LCP P75, mobile, 4G, 2500 ms." When those three drift apart, teams stop trusting the alert because it no longer matches the number they committed to. Two burn-rate alerts per SLO is a common pattern — a fast one at, say, a 14× burn over one hour for genuine incidents, and a slow one at 3× over six hours for gradual erosion.
Choosing the right target percentage is where teams over-reach. A 99.9% target over 30 days leaves an error budget of roughly 43 minutes of out-of-budget time per month, which for a Web Vitals SLO is almost impossible to hold on a real site where traffic mix and CDN behaviour fluctuate. Start looser — 99% attainment gives you around seven hours of monthly budget for LCP P75 on mobile 4G — and tighten only once you have watched a few months of burn and know the budget is realistic. An SLO that is red every week trains people to ignore it, which is worse than having no SLO at all. Report the remaining budget in your weekly review so the number stays visible to the people who prioritise the work, not just to the on-call engineer.
Correlating Deploys via Datadog Events
Most performance regressions are shipped, not spontaneous. If you cannot see which deploy moved the line, every investigation starts from zero. Post a Datadog event at the moment each release goes live, tagged with the same version string you pass to the RUM SDK, and Datadog will draw a vertical marker on every graph. Overlay that marker on the P75 LCP line and the culprit deploy is usually obvious within one glance.
curl -sS -X POST "https://api.datadoghq.com/api/v1/events" \
-H "DD-API-KEY: ${DD_API_KEY}" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"Deploy storefront-web ${RELEASE_TAG}\",
\"text\": \"Released ${GITHUB_SHA} to production\",
\"tags\": [\"service:storefront-web\", \"env:production\", \"version:${RELEASE_TAG}\"],
\"alert_type\": \"info\"
}"
Because the event carries version:${RELEASE_TAG} and your RUM views carry the same version, you can go one step further than a visual overlay: split P75 by version and compare the release before and after directly. If the pre-release build held LCP P75 at 2300 ms for mobile 4G and the new version sits at 2700 ms for the same population, you have quantified the regression and identified its owner in a single query. The companion page Correlating Deploys With Datadog Events covers automating this from CI so no release ever ships without a marker.
Dashboards
A budget dashboard should answer three questions on one screen: are we within budget right now, are we trending toward a breach, and what changed. Build it as a Datadog dashboard with a top row of query-value tiles — current LCP P75, INP P75, and CLS P75 for mobile production — each with a conditional colour that turns red when it crosses the budget ceiling. Beneath them, place timeseries widgets for the same three vitals with the budget ceiling drawn as a static marker line, and enable the deploy event overlay so release markers appear automatically.
{
"title": "Web Vitals Budget - mobile production",
"widgets": [
{
"definition": {
"type": "query_value",
"title": "LCP P75 (mobile 4G)",
"requests": [
{ "response_format": "scalar",
"queries": [ { "data_source": "rum",
"name": "lcp",
"search": { "query": "@type:view @device.type:mobile env:production" },
"compute": { "aggregation": "pc75", "metric": "@view.largest_contentful_paint" } } ] }
],
"custom_links": [],
"precision": 0
}
}
]
}
Keep the CI lab numbers on a separate, clearly labelled section of the same dashboard so viewers never confuse a throttled Lighthouse LCP with field P75. A useful layout is field vitals on the left, lab gate results on the right, and a shared deploy-event overlay across both, so a green CI gate that still produced a field regression is immediately visible — a pattern that catches budget gaps synthetic tests alone miss. If your organisation standardises on Grafana instead, the approach in Visualizing Budget Trends With Grafana mirrors this layout against a different backend.
| Widget | Query source | Metric | Budget ceiling (mobile 4G) |
|---|---|---|---|
| LCP P75 tile | RUM field | pc75 @view.largest_contentful_paint | 2500 ms |
| INP P75 tile | RUM field | pc75 @view.interaction_to_next_paint | 200 ms |
| CLS P75 tile | RUM field | pc75 @view.cumulative_layout_shift | 0.10 |
| Lab LCP trend | CI metric | ci.lighthouse.lcp | 3000 ms |
| SLO status | SLO widget | 30-day attainment | 99.0% |
Cost and Sampling Control
RUM is billed by session, so an unbounded sessionSampleRate of 100 on a high-traffic site can become the most expensive line on your Datadog bill. The instinct to sample everything is understandable — you want the truest possible P75 — but a well-chosen sample rate barely moves the percentile while cutting cost dramatically. On a page serving hundreds of thousands of mobile sessions a day, sampling 20 percent still yields a P75 whose 95% confidence interval is within a few milliseconds of the full population for LCP on mid-range mobile at 4G.
Set sessionSampleRate to the smallest value that keeps your smallest important segment statistically stable. If a minor locale sees only a few thousand mobile sessions a day, a 20 percent global rate might leave that locale too sparse for a trustworthy P75, so consider a higher rate there or a separate application. Datadog also distinguishes session sampling from sessionReplaySampleRate; replay is far more expensive, so keep it low or zero for pure budget monitoring where you only need the numeric vitals.
datadogRum.init({
applicationId: 'YOUR_APPLICATION_ID',
clientToken: 'YOUR_CLIENT_TOKEN',
site: 'datadoghq.com',
service: 'storefront-web',
env: 'production',
version: '2026.07.24-1',
sessionSampleRate: 20,
sessionReplaySampleRate: 0,
trackResources: false,
trackLongTasks: true
})
On the CI side, custom metrics are cheap per point but multiply with cardinality. Tagging every metric with a unique sha creates one time series per commit, which can explode custom-metric counts on a busy monorepo. Keep high-cardinality tags like sha on events (which are not billed by cardinality the same way) and reserve metric tags for low-cardinality dimensions such as branch, env, and device.type. The techniques in the sibling page Sending Web Vitals to Datadog RUM go deeper on trimming payloads and choosing sample rates per segment. The net rule: sample the field aggressively enough to control cost, but validate that each budgeted segment still produces a stable P75 before you trust an alert built on it.
Frequently Asked Questions
Should I send Web Vitals through Datadog RUM or as custom metrics?
Use Browser RUM for field vitals, because Datadog captures LCP, INP, and CLS per view automatically and computes P75 at query time. Reserve the custom metrics API for numbers CI computes, such as Lighthouse lab results, and keep the two namespaces separate so no monitor accidentally averages a lab value with a field value.
What percentile should a Datadog Web Vitals monitor evaluate?
Evaluate the same percentile your budget document commits to, which for Core Web Vitals is almost always P75, not an average. A monitor on p75:@view.largest_contentful_paint filtered to mobile 4G traffic matches the population your budget describes; averages hide the slow tail that real users experience.
How do I attribute a regression to a specific deploy in Datadog?
Post a Datadog event from CI at release time tagged with the same version string you pass to the RUM SDK. Datadog draws a marker on every graph, and because RUM views share that version tag you can split P75 by version to compare the release before and after directly.
How does sampling affect the accuracy of my P75?
On high-traffic pages a 20 percent session sample keeps P75 within a few milliseconds of the full population for LCP on mid-range mobile at 4G, while cutting cost sharply. The risk is low-volume segments: a minor locale may become too sparse to produce a stable P75, so raise the rate there or split it into its own application.
What is the difference between a monitor and an SLO for a budget?
A monitor tells you the field is over budget right now; an SLO tells you how much of your error budget you have already spent over a rolling window, such as 99% of 30-minute windows staying within the LCP P75 ceiling for mobile 4G. Burn-rate alerts on the SLO separate a cosmetic wobble from an incident that would exhaust the budget in days.