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
queueandbatchmode, where hits leave out of band.syncmode 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.channel—reportroutes through Laravel's exception handler, so it reaches whatever error tracker you have wired up, and also logs.logonly logs.silentdoes 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.
batchmode therefore needs a persistent cache store — with thearraystore 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
batchmode 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
queuemode 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'sfailed_jobstable, wherephp artisan queue:retryreaches 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_TOKENconfigured, 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 happens | What 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.