Inside the ingest pipeline
A deep dive into how Tracely turns beacon POSTs into stored pageviews — rate limiters, queued jobs, the batch endpoint, and one deliberate foot-gun.
I promised the first proper deep dive would be the ingest pipeline, so here it is. This is the part of Tracely that has to work when nothing else does: the dashboard can be slow on a bad day and you'll forgive it, but if ingest drops events during the traffic spike you built the analytics for, the product has failed at its one job.
The design goal is easy to state: the HTTP request does validation, and a queue does the writing. Everything else in this post is a consequence of that sentence.
The request path
The tracker posts JSON to deliberately opaque paths — /api/i/p for a single pageview, /api/i/b for a batch. IngestController handles them all, and on the request path it does the work that has to happen synchronously:
- Validation. Malformed payloads are rejected immediately.
- Hostname checks. With
TRACELY_INGEST_ENFORCE_HOSTNAMEon (the default), a payload must report a URL whose host matches the site's domain — any subdomain, plus the www/apex swap. This stops a copied snippet from polluting your data from someone else's domain. - Privacy filters. Custom event properties run through
App\Support\IngestPrivacy— blocked key fragments, nesting limits, string caps. The tracker applies the same filtering client-side, but the server never trusts the client to have done it. - Bot filtering and quota. Known bots are dropped, and the pageview quota check stays on the request so an over-quota site gets a fast answer instead of a queued surprise.
What the request path does not do is write pageview rows. Persistence is dispatched to the queue and the HTTP worker returns. Under load, the request cost is parse-validate-dispatch, which is the difference between a spike backing up your queue (recoverable, invisible to visitors) and a spike backing up your web workers (visible to everyone).
Two caches keep this hot path honest. User-agent parsing uses matomo/device-detector, which is accurate and CPU-hungry, so parse results are cached by UA hash — ua_parse_cache_seconds, default one day. And the site lookup by tracking_id is cached for a couple of minutes (site_lookup_cache_seconds, default 120, invalidated when the site is saved) so a busy site isn't a database read per beacon.
One honest asterisk: performance metrics and heatmap data are still written from the request path today, not from the ingest queue jobs. They're lower-volume than pageviews and it hasn't hurt yet, but it's on the list.
Rate limiting that survives an office NAT
There are two named rate limiters, ingest and ingest_batch, registered in AppServiceProvider and configured in the ingest section of config/tracely.php.
The interesting decision is the throttle key. The naive choice — limit per IP — breaks in a way that's invisible until it isn't: put a few hundred real readers behind one office or carrier-grade NAT and they all share a single per-IP bucket. The limiter starts returning 429s to legitimate visitors and you lose data precisely when a site is popular. That's the failure mode analytics exists to observe, not to cause.
So when the payload includes a sid (the site's tracking ID), limits are keyed by a hash of IP + sid. Many visitors behind one NAT hitting one site still share a bucket, but the defaults are sized for it:
TRACELY_INGEST_REQUESTS_PER_MINUTE_PER_IP_SITE=24000 # general ingest
TRACELY_INGEST_BATCH_REQUESTS_PER_MINUTE=12000 # POST /api/i/b only
The batch limiter runs after the general one and can be disabled by setting it to 0, in which case the general limit still applies. Twenty-four thousand requests a minute from one IP-site pair is far beyond anything organic, so in practice these limits exist to stop abuse and runaway scripts, not to shape real traffic.
The jobs
Single-item endpoints dispatch one queued job per row: RecordIngestPageviewJob for pageviews, RecordIngestEventJob for custom events. Jobs go to a dedicated queue — ingest by default, configurable via TRACELY_INGEST_QUEUE — so a backlog of analytics writes can never starve password-reset emails, and vice versa.
There's also an optional dedicated queue connection, TRACELY_INGEST_QUEUE_CONNECTION. Leave it null and jobs use the default connection with the ingest queue name; set it to redis-ingest and they use a separate connection defined in config/queue.php with a shorter retry_after. Ingest jobs are small and fast, so if one is stuck, you want it retried in seconds rather than waiting out a retry_after tuned for slow report generation.
Workers drain the queue with plain queue:work or Horizon. The Docker development stack runs a dedicated queue container for exactly this, so local development exercises the same shape as production.
The batch endpoint
One job per pageview is fine until the tracker starts batching — and it does, because a reader who triggers a pageview, two scroll-depth milestones, and an outbound click shouldn't cost four HTTP requests.
Client-side, the tracker queues items and flushes to POST /api/i/b when one of three things happens: a quiet period passes with no new items (batchMs, default 5000 ms, debounced), the queue has been held too long during continuous activity (batchMaxWaitMs, default 30000 ms), or it fills up (batchMax, default 40 items). Page exit — pagehide and friends — flushes with keepalive so nothing is left in memory when the tab closes. All three knobs are tunable per site via data-track-batch-ms, data-track-batch-max-wait-ms, and data-track-batch-max on the script tag.
Server-side is where the earlier design would have gone wrong. Unpacking a 40-item batch into 40 individual jobs means 40 round trips to Redis to enqueue, 40 dequeues, 40 framework job lifecycles — the per-job overhead starts to rival the actual work. Instead, each successful POST /api/i/b dispatches one RecordIngestBatchJob carrying every validated pageview and event from that request, and the job persists them with bulk multi-row inserts. One enqueue, one dequeue, a handful of multi-row statements. The batch job also stamps rows missing a timestamp with a single batch-level created_at, so every row from one flush lands in the same hourly bucket for the rollups.
The validation story doesn't change: batch items go through the same per-item checks as the single endpoints, and the payload assembly is shared code, so the field set and sanitization can't drift between the single and batch paths. (It drifted once — a duration cap that existed only on the single path — which is why it's shared code now.)
Knowing it's healthy
Queues fail quietly. A misconfigured worker doesn't throw errors at visitors; it just lets jobs pile up while your dashboard serenely reports yesterday's numbers. So there's a command for the operator's 2 a.m. question:
php artisan tracely:ingest-status
It reports how ingest is configured — which queue and connection jobs are going to, and crucially whether synchronous dispatch is enabled — so you can verify the pipeline's shape without spelunking through env files.
For load, the Docker stack includes a simulator: composer run docker:ingest seeds a manifest and replays traffic against the app, and composer run docker:ingest:1k tunes it for roughly a thousand concurrent virtual users. Watching the queue depth breathe under that load is how I've been finding the sharp edges before real traffic does.
The foot-gun, labeled
There's one escape hatch in the config that deserves its warning label:
TRACELY_INGEST_DISPATCH_SYNC=true
This forces ingest persistence to run synchronously inside the HTTP request — even when QUEUE_CONNECTION=redis. It exists for local debugging, where "beacon in, row visible, no worker required" is genuinely useful: you can step through the whole write path in one request. (The test suite gets this behavior for free since PHPUnit uses sync queues by default.)
In production it's a foot-gun, and a sneaky one. Everything works with sync dispatch on — events arrive, dashboards populate, no errors. You've just silently reattached the database write to the request path, so every beacon now costs a web worker the full insert time, and the first real spike saturates your workers and starts shedding the very traffic you wanted to measure. A slow failure mode that looks like success until load arrives is the worst kind, which is why tracely:ingest-status exists largely to answer one question: is sync dispatch off in production? Check it in your deploy checklist and forget the flag exists.
The shape that falls out
None of this is exotic — named rate limiters, a dedicated queue, a batch job with bulk inserts, rows in Postgres. That's the point. The ingest pipeline is the layer of Tracely that should be the most boring, because boring is what you want holding the line between a traffic spike and your data. The rest of the product gets to be interesting because this part isn't.
If you want the wider context — what the tracker sends and what the dashboards do with it — start at /features.
Try Tracely on your site
One snippet, cookieless, first-party. See your traffic in minutes without handing it to an ad network.
Start freeGet new posts by email
Short product notes twice a week plus the occasional deep dive. No tracking pixels in the emails, and you can unsubscribe anytime.
Your address is used only to send new posts. No open tracking.