cofa / performance-tracking-times
Per-request timing breakdown for Laravel (calculating, network out, delay, service, network return) with a dashboard and a waterfall diagram for every request.
Package info
github.com/Cofa12/performance-tracking-times
pkg:composer/cofa/performance-tracking-times
Requires
- php: ^8.1
- laravel/framework: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Laravel package that breaks every request's response time into its parts and shows each one as a waterfall diagram:
| Segment | What it is | Measured by |
|---|---|---|
| Calculating | Time the client spends preparing the request (building the payload, serialising, signing) | Client |
| Network out | The request travelling to the server, including connection setup (browser queue, DNS, TCP, TLS) | Client + server |
| Delay | Time the request waits in the web server or PHP-FPM queue before a worker picks it up | Web server + PHP |
| Service | Time the server works on the request: framework boot, middleware, controller, and the database queries inside it | Server |
| Network return | The response travelling back and downloading | Client + server |
| Response time | The sum of the segments above: from the client starting the work to the last byte arriving. Browser rendering afterwards is not included. |
The dashboard at /performance-tracking lists recent requests with a stacked bar for each one, plus server-side and end-to-end percentiles. Open a request to see its waterfall, a table of the values, and the SQL of its queries placed on the timeline.
Requirements
| Laravel | PHP |
|---|---|
| 10 | 8.1 – 8.4 |
| 11, 12 | 8.2 – 8.4 |
| 13 | 8.3 – 8.4 |
Tested on SQLite, MySQL 5.7 and 8.0, and PostgreSQL 16.
Installation
composer require cofa/performance-tracking-times php artisan migrate
The service provider is auto-discovered. It prepends the recording middleware to the global middleware stack, so it wraps every other middleware and route.
Recording is on in the local environment only. To record elsewhere, turn it on and sample:
PERFORMANCE_TRACKING_ENABLED=true PERFORMANCE_TRACKING_SAMPLE_RATE=0.05 # 5% of requests
Every recorded request is one row (plus up to 50 query texts), so keep the sample rate low on busy servers and keep the timings in their own database if you like (PERFORMANCE_TRACKING_DB_CONNECTION).
To publish the config:
php artisan vendor:publish --tag=performance-tracking-config
1. Server side: delay and service time
This part works as soon as recording is on. Service time (framework boot plus middleware and controller) and database time are measured for every recorded request.
To measure delay (queue time), the web server has to stamp the time it received the request, from a source the client cannot forge. With nginx in front of PHP-FPM, use a FastCGI param (clients can only set HTTP_* params):
location ~ \.php$ { fastcgi_param REQUEST_START_TIME "t=${msec}"; # ... }
If nginx is a reverse proxy instead (Octane, another host), use the header:
proxy_set_header X-Request-Start "t=${msec}";
Because any client can send X-Request-Start itself, the header is honoured only on requests that came through a proxy your application trusts (TrustProxies). Set request_start_header_trust to always if you cannot configure trusted proxies and your proxy always overwrites the header. With Apache, use RequestHeader set X-Request-Start "%t".
Stamps more than max_delay_ms (60 s) old are ignored. Without a stamp the dashboard shows delay as "not measured", and any queueing time is counted in network out instead.
Every recorded response carries a header the client script reads:
X-Request-Timing: id=<uuid>;t1=<server received, epoch ms>;t2=<response ready, epoch ms>;n=<node>
n identifies the server (a hash of the hostname, or PERFORMANCE_TRACKING_NODE), so clients keep a separate clock offset for each server behind a load balancer.
In local, responses also carry Server-Timing, which browser devtools show in the Network tab:
Server-Timing: delay;dur=8.7;desc="Queue", boot;dur=17.5;desc="Framework boot", app;dur=238;desc="Middleware + controller", db;dur=4.0;desc="8 queries", service;dur=255.5
Elsewhere it is off, because it tells anyone how long each request took on the server and how many queries it ran, which makes timing attacks (user enumeration on a login form, for example) far easier. Turn it on with PERFORMANCE_TRACKING_SERVER_TIMING=true only where that is acceptable.
2. Client side: calculating and network time
The server cannot measure the network legs on its own. Only the client knows when the request left and when the response finished arriving, so the client reports those timestamps back.
Browser (Blade, Inertia, Livewire, any JS frontend)
Add the script to your layout's <head>, before any other script:
@performanceTrackingScript
It wraps fetch and XMLHttpRequest (so axios and jQuery work too), reads precise timestamps from the Resource Timing API, and sends them in batches with navigator.sendBeacon. It does not change your requests or their responses. The tag is a small synchronous script (so it is in place before your own scripts send requests), versioned so browsers cache it until the package changes.
On a server-rendered page, the tag also carries that page's timing id, so the page load itself gets network times (from the Navigation Timing API), not only the AJAX calls made after it. The first page of a visit splits network time evenly; once the browser has made an AJAX call to the same server it knows the clock offset and splits it precisely.
Pages and responses served from a cache (a CDN, a full-page cache, Cache-Control: public) carry the timing id of the request that produced them. The server rejects reports whose send time is far from that request when it can tell, but send tracked pages with Cache-Control: private if they could be cached.
For a frontend on another origin (React, Vue, …), load it directly instead, and set PERFORMANCE_TRACKING_ALLOW_ORIGIN to that origin so the browser exposes the timing header:
<script>window.RequestTimingConfig = { endpoint: 'https://api.example.com/performance-tracking/api/client' };</script> <script src="https://api.example.com/performance-tracking/client.js"></script>
To also measure calculating time, wrap the work that comes before a request:
await RequestTiming.measure(() => { const body = buildExpensivePayload(); // counted as "calculating" return fetch('/api/orders', { method: 'POST', body }); }); // or mark the start yourself; the next request within a minute picks it up RequestTiming.markCalculating();
When the browser provides no Resource Timing entry for a fetch (some cross-origin setups), the script falls back to the moment the response headers arrived; such requests are marked and their network return does not include downloading the body.
Mobile apps and other clients
Read the X-Request-Timing header from the response and POST your timestamps to /{path}/api/client (/performance-tracking/api/client by default):
{
"timings": [{
"id": "<id from X-Request-Timing>",
"t0": 1791807203600.12,
"t3": 1791807203910.55,
"calc_ms": 12.5,
"connect_ms": 3.1,
"offset_ms": -4.2,
"headers_only": false
}]
}
t0is when the request was sent andt3is when the response finished arriving, both in epoch milliseconds on the client clock. Both are required.calc_ms,connect_ms,offset_msandheaders_onlyare optional. Setheaders_onlywhent3is when the headers arrived rather than the whole body.offset_msis the server clock minus the client clock. Estimate it from the request with the lowest round trip:((t1 - t0) + (t2 - t3)) / 2, usingt1andt2from the header, and keep one pern(server node). Without it, the network time is split evenly between the two directions.- Send up to 50 timings per call, within 10 minutes of the request. Each request accepts one report. Invalid or implausible entries are skipped without affecting the rest; the response says how many were
acceptedandrejected. - Reports are rate limited to 120 per minute per IP address and 10 000 per minute in total (
client_rate_limit). Behind a load balancer, configure TrustProxies, or every client shares the balancer's address.
How the network split works
client t0 ──── network out ────▶ ◀──── network return ──── t3
server t1 ─ delay ─ boot ─ app ─ t2
(t3 − t0) − (t2 − t1)is the total time on the wire. It does not depend on the client and server clocks agreeing.- Splitting that total into the out and return legs does depend on the clocks. The client keeps, per server, the clock offset measured on its fastest exchange (the NTP offset formula) and sends it with each report. The server uses it to place
t1andt2on the client's clock. The estimate is off by half the difference between the two legs of that fastest exchange. - If the offset drifts, a leg is clamped at zero so the total stays correct.
t2is when the response was ready to send. For streamed and file responses the body is produced while it is sent, so that time falls inside network return; the waterfall shows it separately.
Dashboard access
In the local environment the dashboard is open to anyone. Everywhere else, define a gate:
// App\Providers\AppServiceProvider::boot() Gate::define('viewPerformanceTracking', fn ($user) => $user->is_admin);
Stored data and privacy
Each row holds the method, the path, the route name, the status, the timings and, unless disabled, the authenticated user's id and the query SQL.
- Paths are stored as the matched route's URI (
/reset/{token}), so tokens and ids in URLs never reach the table. Requests that matched no route keep their raw path. Setpath_modetorawto store paths as requested. - IP addresses are not stored unless
store_ipis true. - User ids are stored when someone is already signed in (
store_user_id); the package never triggers authentication to find out. - SQL is stored as Laravel sent it: with placeholders, never the bindings. Values written into the SQL itself (raw expressions,
whereIntegerInRaw) are stored as written. Setrecord_queriesto false to keep only the totals.
Configuration highlights
| Key | Default | |
|---|---|---|
enabled |
true in local only |
PERFORMANCE_TRACKING_ENABLED |
sample_rate |
1.0 |
Record this fraction of requests; lower it on busy servers |
path |
performance-tracking |
Dashboard URL prefix |
expose_server_timing |
true in local only |
PERFORMANCE_TRACKING_SERVER_TIMING |
request_start_server_param / request_start_header |
REQUEST_START_TIME / X-Request-Start |
Where the web server's receive stamp comes from |
request_start_header_trust |
proxies |
proxies, always or never |
ignore_paths / ignore_methods |
telescope, horizon, … / OPTIONS, HEAD |
|
path_mode / store_ip / store_user_id |
route / false / true |
See above |
record_queries / query_limit |
true / 50 |
|
timing_allow_origin |
null |
Set it when the frontend runs on another origin, so the browser exposes the header |
client_rate_limit |
120 per IP, 10 000 total, per minute | |
connection / table |
default / performance_timings |
Keep timings in a separate database if you like |
retention_hours / prune_schedule |
48 / 17 * * * * |
Pruning
The prune command is scheduled hourly (prune_schedule), so old rows are deleted as long as your application runs php artisan schedule:run. It deletes in batches of 5 000 rows to keep each statement short. Set prune_schedule to null to schedule it yourself:
// Laravel 11+: routes/console.php Schedule::command('performance-tracking:prune')->hourly(); // Laravel 10: app/Console/Kernel.php, in schedule() $schedule->command('performance-tracking:prune')->hourly();
Notes
- Timings are written in the middleware's
terminatestep. Under PHP-FPM and Octane that runs after the response has been sent, so storing them does not delay the response, although the worker is busy until the insert finishes. Under other SAPIs (Apache mod_php,php artisan serve) the connection may stay open until the insert is done. - Under Octane the recorder is scoped per request and peak memory is reset per request. The PHP start time is the later of
REQUEST_TIME_FLOATandLARAVEL_START, so a long-lived worker's boot time is not counted against a request. - Full-page navigations are reported only for pages that include
@performanceTrackingScript(it carries the page's timing id). A frontend on another origin reports itsfetch/XHR calls.
Upgrading
From 1.x:
- Recording and
Server-Timingare now off outsidelocalby default; setPERFORMANCE_TRACKING_ENABLED=trueto keep recording. - Run
php artisan migratefor the new columns and indexes. X-Request-Startis only trusted behind a trusted proxy. With nginx and PHP-FPM, switch tofastcgi_param REQUEST_START_TIME "t=${msec}";, or setrequest_start_header_trusttoalways.- Paths are stored as route templates and IP addresses are no longer stored; see "Stored data and privacy" to change that.
- The client report endpoint's throttle is now the
performance-trackingrate limiter. If you published the config, changeclient_middlewareto['throttle:performance-tracking'].
Testing
composer test # PHPUnit; TEST_DB=mysql or TEST_DB=pgsql with DB_HOST, DB_PORT, ... for other databases composer test:js # the browser client, with Node's built-in test runner
License
MIT