Skip to main content

Reliability

One invariant holds everywhere in the package, and one holds in every mode but sync:

  • A tracking error never surfaces in your application. Always. Every entry point is wrapped, so a Matomo outage, a DNS failure or a malformed response cannot turn into a 500 for a visitor.
  • Tracking does not block a response in queue and batch mode, where hits leave out of band. sync mode sends inline, on purpose — it is the mode for a low-volume site with no worker, and it trades the request time for not needing one. See transmission modes.

Fail-safe does not mean fail-silent. Failures are recorded, escalated and, at the end of the line, kept for inspection.

The failure path

'resilience' => [
'never_throw' => true,
'connect_timeout' => 2,
'reporting' => [
'report_after_attempts' => 3,
'channel' => env('MATOMO_REPORT_CHANNEL', 'report'),
'level' => 'warning',
'transient_level' => null,
'throttle_minutes' => 15,
],
],

connect_timeout bounds how long a connection attempt may take — deliberately short, because a Matomo that is not answering should be given up on quickly rather than held onto. The overall request timeout is the separate top-level timeout.

Alerting that does not cry wolf

A single timeout is not an incident. Three things shape what your monitoring sees:

  • report_after_attempts — nothing is reported until a delivery has failed this many times. The first two failures of a hit that succeeds on the third attempt are invisible, which is correct.
  • channelreport routes through Laravel's exception handler, so it reaches whatever error tracker you have wired up, and also logs. log only logs. silent does neither.
  • throttle_minutes — reports are throttled per error signature. A Matomo outage producing the same failure thousands of times produces one alert per window, not thousands.

transient_level is off by default. Set it to a log level and each retry is logged too — useful while diagnosing an intermittent problem, noisy as a standing setting.

Retries in queue mode

A failed SendHitsJob retries with escalating backoff — 30 seconds, 2 minutes, 5 minutes, 15 minutes, then holding at that last step — for up to queue.tries attempts. A batch that spends its budget is moved to the dead-letter store, the same place batch mode puts an undeliverable batch, and matomo:replay reads it back. Both delivery modes now end a failed batch the same way.

The job absorbs a delivery failure instead of throwing it, and that is what keeps the second invariant above true for queued delivery. An exception that leaves a job is caught by Laravel's queue worker and handed to your application's own exception handler — a path this package cannot gate, throttle or silence. A routine network timeout would therefore reach your error tracker on every single attempt no matter what report_after_attempts, channel and throttle_minutes say. Alerting belongs to the reporter described above, where those three settings actually apply.

queue.retry_until_minutes stays as Laravel's own outer bound. With the default queue.tries of 5 a batch is dead-lettered around twenty minutes in, so the deadline is never reached; it only comes into play if you raise the attempt budget far above it, and a job that crosses it is failed by Laravel in the ordinary, loud way.

Analytics data that arrives a day late is of limited value, and a hit that has been failing for twenty minutes is usually failing for a reason retrying will not fix — which is why the attempt budget, not the deadline, is what ends the loop.

Poison and dead-lettering in batch mode

batch mode distinguishes two kinds of failure, because they need opposite handling.

A permanent rejection — Matomo answering with a client-error status, except the four listed below — means this batch will never be accepted. Retrying it forever would block every hit behind it, so it is moved to the dead-letter store at once and the drain continues past it.

A transient failure — a timeout, a server error, or one of 408 Request Timeout · 423 Locked · 425 Too Early · 429 Too Many Requests — means Matomo may recover. Those four are 4xx by number and back-pressure by meaning: dead-lettering a 429 on sight would drain a whole backlog into the dead-letter store the moment a rate limit is reached, which is exactly when the data is still good. The batch is released back into the buffer and the current pass stops, so a struggling Matomo is not hammered. Once such failures persist past batch.max_attempts consecutive flushes, the stuck batch is dead-lettered too.

Either way nothing is lost, and one bad batch never wedges the queue.

The consecutive-failure count lives in the cache. batch mode therefore needs a persistent cache store — with the array store the count resets every process, so the escalation to dead-letter never happens and a permanently stuck batch retries forever.

batch.dead_letter.enabled = false turns the dead-letter store off entirely. What that means depends on the delivery mode, and the two are not the same sentence:

  • In batch mode the failed batch stays in the buffer and keeps being retried. That is rarely what you want, because it reintroduces exactly the head-of-line blocking the dead-letter exists to prevent.
  • In queue mode there is no buffer for it to stay in. The job fails the ordinary way once it is out of attempts, and the batch lands in your application's failed_jobs table, where php artisan queue:retry reaches it.

Neither loses the hits, and both are visible — the difference is only where you go looking.

Working the dead-letter queue

php artisan matomo:replay --list # inspect without changing anything
php artisan matomo:replay # re-queue everything into the buffer
php artisan matomo:replay --limit=100 # re-queue the first 100 entries
php artisan matomo:replay --prune # discard the queue

Each entry records the hits, how many delivery attempts it took, the error, and when it failed — enough to tell a Matomo misconfiguration from a payload problem before you replay.

Replay pushes the hits back into the buffer, so they go out through the normal flush path. If the underlying cause is not fixed, they will come back — check with --list first.

Replayed hits carry their original data. Without MATOMO_TOKEN configured, Matomo timestamps them at replay time, which for a queue that sat for hours means the hits land at the wrong point in your reports. This is the strongest single argument for configuring a token — see installation.

How long dead letters are kept

Dead letters are deleted 30 days after they failed. A daily scheduled task does it, and batch.dead_letter.retention_days controls the window — set it to 0 to keep everything forever, or lower it if the queue is large.

This is on by default, which is worth explaining rather than just stating. Nothing else in the package ever deletes from this table: matomo:replay removes an entry when it re-queues it, and --prune empties the queue on demand, but both need someone to run them. An installation where nothing goes wrong runs neither, so the table only grows — and each row carries a full batch of hits.

There is also a reason not to keep them indefinitely even if disk were free. A hit's timestamp is stamped when the payload is built, so replaying a month-old dead letter sends a month-old timestamp. Matomo refuses a backdated hit older than about a day unless the request carries token_auth, and without one it records the hit at today's date instead — quietly moving old visits into the current report. A dead letter that has sat unexamined for a month is past the point where replaying it is the right thing to do.

Entries whose failure time was never recorded are never deleted, whatever the window: the whole decision rests on age, and a row with no age has not been shown to be old.

A HitsDeadLettered event fires whenever a batch is dead-lettered, carrying the hit count and the number of attempts. Listen for it if you want an alert the moment tracking data starts accumulating rather than at the next time someone runs --list. See events.

Durability of the buffer itself

The database, redis and file drivers all claim a batch before sending and remove it only on a confirmed 200. A process killed mid-send leaves the batch claimed but unacknowledged; it is reclaimed after batch.stale_after_minutes and delivered again.

Delivery is therefore at-least-once, not exactly-once. In practice a duplicate hit is a far better failure mode for analytics than a lost one, and the window is bounded by the stale timeout.

Three ways a hit can still be lost, and all three now say so

None of these is recoverable where it happens — that is why each one is a report rather than a retry. What changed is that they used to be silent, which is the difference between a hit that was lost and a hit that was lost without anyone knowing.

What happensWhat you see
A buffered payload no longer decodes. It cannot be sent to Matomo by anyone, so it is discarded to let the rest of the buffer move.A report through the alerting channel naming how many were discarded.
The buffer cannot be claimed from at all — a spool directory nobody can write to, a full disk, a read-only mount.A report, plus matomo:flush and matomo:work exiting non-zero with the reason. Previously this read as "the buffer is drained" and printed Flushed 0 Matomo hit(s). every minute, forever.
Matomo answers 200 and says in the body that it refused some of the batch.A report with the count Matomo states. The batch is still acknowledged: the response says how many were refused, not which, so re-sending would duplicate the ones that landed.

What "durable" does and does not cover

The claim-before-send contract is about a crashing PROCESS. It says nothing about the store itself, and three things about the store are worth knowing before you pick one.

A Redis restart loses the buffer unless Redis persists. Measured: 5,000 hits buffered, kill -9 on the server, restart — size() reports 0, the next flush delivers 0 and exits zero, which is what an idle minute also looks like. Redis persists only if you have told it to: appendonly yes writes every command, and the RDB defaults (save 3600 1) can lose up to an hour. Neither is on in a stock Herd or Docker Redis.

Nothing bounds the buffer's size. The keys carry no TTL, which is right — they are pending work rather than cache — but there is no LTRIM and no ceiling either. During a Matomo outage the buffer grows in the RAM of whatever instance it shares, and under the noeviction policy this page recommends, that instance eventually refuses all writes and takes your cache, your sessions and your queues with it. matomo:test reports the depth; watch it, or keep the buffer on a database.

The cleanup has an unstated precondition: something has to keep calling claim(). Stale processing lists are reclaimed at the head of every claim, so if you switch mode away from batch, change the driver, or never wire the scheduler, every key becomes immortal — including a buffer full of personal data.

The instance is holding personal data

A buffered hit is a whole Tracking-API payload: the visitor id, the user agent, the language and the full URL with its query string. On a shared Redis without requirepass, every other application on that instance can read it.

Treat batch.redis_connection as a decision about where personal data lives — an instance with authentication, ideally a database of its own, and not the one you hand to unrelated services. The database driver keeps it in a table your application already protects.

The redis driver has one precondition, and it is a server setting

The claim above holds for redis only while Redis is not allowed to evict the buffer's keys. The buffer sets no TTL on any of them — they are pending work, not cache — but under an allkeys-lru, allkeys-lfu or allkeys-random maxmemory-policy they are as evictable as anything else in the keyspace.

What that looks like is nothing at all. If matomo-analytics:buffer is evicted, LLEN returns 0, the claim comes back empty, the flush ends, and matomo:flush prints Flushed 0 Matomo hit(s). and exits zero — the same output an idle minute produces. Hits disappear and every signal stays green.

So if you run the redis driver, the instance it uses needs noeviction or a volatile-* policy:

redis-cli CONFIG GET maxmemory-policy

maxmemory-policy is instance-wide. Pointing batch.redis_connection at a different logical database on the same server does not help — a separate Redis instance does. php artisan matomo:test reads the policy and says so when the driver is in use.