Service Updates

Cairn 1.3.0: Slowest Routes, Broken Pages and a Stricter API

PN
Paige Newsom
8 min read
3 views

Cairn 1.3.0 opens the dashboard on Today, ranks routes by server response time, lists the pages that fail, including links to URLs with no route, and fixes the JSON API so a limit returns the top rows and a bad request is refused.

Cairn 1.3.0 is out. Like 1.2.0, most of it surfaces numbers our privacy-first analytics package for Laravel was already recording and never showed. There are two new panels, the dashboard now opens on a different default, and the JSON API has been fixed in the places a real client tripped over. The full record is in the changelog on GitHub.

The short version:

  • The dashboard opens on Today, charted hour by hour, instead of the last 30 days.
  • Slowest routes ranks routes by how long the server takes to answer them.
  • Broken pages lists the requests that failed, including links to URLs that have no route at all.
  • The JSON API returns the top rows when you ask for a limit, stays hidden when it is switched off, and refuses a request it cannot read instead of quietly answering a different one.

There is no migration in this release. Upgrading is one Composer constraint change:

composer require divoto/cairn:^1.3

The dashboard opens on Today

It used to open on the last 30 days. That is a good range for spotting a trend and a poor one for the question most people bring to an analytics page, which is how today is going. With no range in the URL, every dashboard driver now shows the current day hour by hour. The other ranges are still one click away, and a link or bookmark that names a range keeps opening on it.

Slowest routes

Every pageview has carried its server response time since 1.0, and the rollup has stored it per route, but no panel read it. The new panel ranks routes by average response time and sits next to Core Web Vitals. "The server is slow" and "the page is slow" are different problems with different fixes, and now they read as two separate answers.

Routes with fewer than five pageviews in the period are left out. An average over one request is mostly noise: a single slow hit on a route visited once would otherwise sit at the top of the table, above the route everyone is actually waiting on. That needed a floor applied before the limit, so the table still fills from the routes that qualify. It is available to your own reports as Report::minimum().

Nothing new is recorded for this panel, so it has history from the moment you upgrade.

Broken pages

The second panel lists the requests answered with a 4xx or 5xx status. Each row is a status, a URL and the site that sent the visitor there:

  • A 404 with a referrer is a dead link that someone else is hosting. It is worth an email to them, or a redirect on your side.
  • A 404 without one is usually a typo, an old bookmark or a scanner.
  • A 5xx names the page with the bug.

Links to URLs with no route

The most common broken link is one that matches no route at all, and Cairn never saw those. The pageview middleware sits on the web group, and Laravel only runs a group once a route has matched. 1.3.0 adds a small global middleware that records exactly that case and nothing else: a GET answered 404 that is not asking for JSON. That is a person following a link. A POST to a missing path, or an API client calling an endpoint that is not there, is not a broken page and is left alone.

These are stored as a new kind of entry, not as pageviews. They start no session, count toward no visitor figure and carry no route. A scanner working through a list of paths moves no number on the dashboard except Broken pages. The privacy gate applies exactly as it does to a pageview, covering Do Not Track, Global Privacy Control, opt-out, consent, bots and sampling, and so does any path you have told Cairn to ignore.

Their URLs are scrubbed harder than a pageview's. A route vouches for the shape of a URL it matched. Nothing vouches for a path no route matched, and a password reset link with one character missing is a 404 whose token is still a credential. Numeric ids, UUIDs, ULIDs and any segment shaped like a token become {id}, and the whole query string is dropped. The rollup also keeps only the hundred most frequent broken pages in each bucket, because the URLs come from whatever a stranger types, and an unbounded list would grow the table without limit.

If you would rather not record them, one setting turns it off:

// config/cairn.php, under recorders.PageViews::class
'not_found' => false,

The JSON API, as a real client found it

Cairn ships a read-only JSON API at /cairn/api/report. It is off by default, because it can read everything the dashboard can. Pointing a real client at it turned up a handful of problems, and all of them are fixed in 1.3.0.

A limit now keeps the top rows

This was the worst of them, because it gave a plausible answer that was wrong. A grouped report was never ranked, so group_by=language&limit=5 returned the first five rows storage happened to hand back. In our test that included a language with 423 pageviews and left out two with over a thousand. Grouped rows are now ranked by the first metric you ask for, highest first, before the limit applies.

A disabled API is a 404, whatever sits in front of it

When the API is switched off it is meant to be indistinguishable from an endpoint that was never built. The check ran after the configured middleware, though, and the default is auth:sanctum. In an application without Sanctum installed, that produced a 500. In one with Sanctum, an anonymous caller got a 401, which tells them something is there. The availability check now runs first.

A request it cannot read is refused

Typing period=7d instead of range=7d used to return Today. Asking for group_by=bogus returned the site-wide total. Both came back as a 200, and nothing in the response said the question had changed. Now an unknown parameter, or a value the API does not recognise, is a 422 that names the parameter and lists what it accepts:

{
    "errors": [{
        "status": "422",
        "title": "Invalid parameter",
        "detail": "Unknown parameter \"period\". Accepted: range, compare, interval, metrics, group_by, limit, timeseries, route, country, channel, language, screen_class, entry_url.",
        "source": { "parameter": "period" }
    }]
}

Every problem in a request is reported at once, so fixing a hand-written URL takes one round trip rather than one per mistake.

Filters work, and a broken page is an object

The API read the dashboard's filter parameters, such as country=GB, and then never applied them. They now narrow the report the way they narrow the dashboard, so a dashboard link's query string works against the API unchanged. Two filters at once describe an intersection Cairn never rolled up, and get the same 422 as any other combination it cannot answer.

A row from Broken pages is stored under a single key holding its status, URL and referrer. The API used to return that key as a JSON string inside the JSON, leaving every client to parse it a second time. It now returns an object:

"error_page": { "status": 404, "url": "/does-not-exist", "referrer": "blog.example.com" }

Upgrading to 1.3.0

There is no migration to run. Two steps are worth taking.

Give Broken pages some history. A routed page answered 4xx or 5xx has been stored with its status since 1.0, so re-running the rollup over your retained raw entries fills the panel back as far as your retention allows:

php artisan cairn:rollup --from=2026-09-24 --to=2026-10-24 --period=all

Links to URLs with no route were never recorded before 1.3.0, so that part of the panel fills from the upgrade onward either way.

Add the panels if you list your widgets. If you have published config/cairn.php with a widgets list, Slowest routes and Broken pages will not appear until you add SlowestRoutes::class and BrokenPages::class to it. That list is yours and Cairn will not add to it. If you run config:cache, run it again afterwards.

If you call the JSON API, check what your client sends. A parameter the API never read, such as a cache-busting _=…, used to be ignored and is now refused.

What has not changed

No IP address is stored anywhere. Nothing is written to a visitor's device in the default configuration. The visitor hash rotates its salt every 24 hours and the old salt is destroyed. Broken pages is the first panel in a while to record something new, so it was held to the same rules as everything else: the privacy gate applies to it unchanged, and its URLs are scrubbed more aggressively than a pageview's rather than less.

The test suite now covers every line of the package's source. That figure says nothing about whether the right things are tested, but it does mean no branch shipped in 1.3.0 without at least one test running through it.

This site runs Cairn, so the Slowest routes and Broken pages panels described above are ones we look at ourselves. If something does not behave as this post describes, the issue tracker is the place to tell us.

Share this article

PN

Paige Newsom

Author at IfHighLow

Related Articles

Mack's Magnificent Nest

New Story: Mack's Magnificent Nest

In the warm, sandy land of Australia, there lived Mack the Malleefowl. With his speckled feathers sparkling in the sun, Mack loved to build. Today, he was excited to begin his biggest nest mound ever....

CC
Clara Chronicle
Kwame's Courageous Heart

New Story: Kwame's Courageous Heart

In a lively Nigerian village, Kwame, a proud boy with dark skin and a bright smile, stood tall. He loved his colorful dashiki shirt and the stories he shared. His eyes sparkled with confidence, but in...

BW
Brooke Webley
Ferris and the Sparkly River

New Story: Ferris and the Sparkly River

Ferris the Kingfisher loved his sparkly river. Every morning, he flew over the water, watching the fish swim and the frogs jump. The river was like a ribbon of silver in the sunshine, and it was Ferri...

PW
Paige Webley

Stay Updated

Get the latest insights, tutorials, and industry news delivered to your inbox.

We respect your privacy. Unsubscribe at any time.