hexis / error-digest-bundle
Symfony bundle that captures Monolog errors, deduplicates them by fingerprint, emails a daily digest of new/spiking/top issues to admins, and provides a triage UI.
Package info
github.com/hexis-hr/error-digest-bundle
Type:symfony-bundle
pkg:composer/hexis/error-digest-bundle
Requires
- php: >=8.2
- doctrine/dbal: ^3 || ^4
- doctrine/doctrine-bundle: ^2.8 || ^3.0
- doctrine/doctrine-migrations-bundle: ^3.0 || ^4.0
- doctrine/orm: ^2.14 || ^3.5 || ^4
- monolog/monolog: ^3.0
- psr/cache: ^2.0 || ^3.0
- symfony/config: ^6.4 || ^7.0 || ^8.0
- symfony/console: ^6.4 || ^7.0 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.0 || ^8.0
- symfony/event-dispatcher: ^6.4 || ^7.0 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/http-foundation: ^6.4 || ^7.0 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.0 || ^8.0
- symfony/monolog-bundle: ^3.0 || ^4.0
- symfony/routing: ^6.4 || ^7.0 || ^8.0
- twig/twig: ^3.0
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^12.0
- symfony/mailer: ^6.4 || ^7.0 || ^8.0
- symfony/messenger: ^6.4 || ^7.0 || ^8.0
- symfony/security-bundle: ^6.4 || ^7.0 || ^8.0
- symfony/twig-bundle: ^6.4 || ^7.0 || ^8.0
Suggests
- symfony/mailer: Required for the default MailerDigestSender — emails the daily digest to admins.
- symfony/messenger: Dispatches SendDailyDigest via bus; route to an async transport to keep digest work off the request path.
- symfony/notifier: Enables the NotifierDigestSender — chat-channel summaries via Slack, Teams, Discord, etc.
- symfony/ux-live-component: Unlocks the optional live-tail component in the triage UI.
- symfony/ux-turbo: Smoother in-page status/assignee updates without full reloads.
Provides
None
Conflicts
None
Replaces
None
README
Captures application errors via a Monolog handler, deduplicates them by fingerprint into its own Doctrine-managed tables, sends a daily digest to admins, and provides a triage UI.
Built for when logs are a haystack and real errors go under the radar.
What it does
- Captures every Monolog record at or above a configurable level (default:
warning) from any channel. - Deduplicates by fingerprint (exception class + file + line + normalized message) so the same error fires once, no matter how many times it occurs.
- Rate-limits within one process — 1000 occurrences of the same error in a second become a single occurrence row with accurate counters.
- Stores in its own
err_fingerprint+err_occurrencetables on whichever Doctrine connection you point it at. - Scrubs PII (passwords, tokens, bearer headers, JWTs, credit-card patterns, e-mail addresses, URL query strings) before writing.
- Digests daily via Mailer + optional Slack/Teams via Notifier. Sections: new, spiking, top, stale.
- UI at a configurable route prefix: list with filters, detail with occurrence timeline, resolve/mute/assign.
Requirements
- PHP 8.2+ (8.4+ on Symfony 8)
- Symfony 6.4, 7.x or 8.x
- Doctrine ORM 2.14+ / 3, DBAL 3 / 4
- Monolog 3 +
symfony/monolog-bundle3 or 4 - Messenger + Mailer (host probably already has these)
Optional:
symfony/notifier+ a chat transport (Slack, Teams, Discord, Rocket.Chat) for chat pingssymfony/schedulerfor cron-less scheduling (or use OS cron + the console command)
Install
1. Require the package
composer require hexis/error-digest-bundle
If running inside a monorepo with a path repository, add this to root composer.json:
"repositories": [ { "type": "path", "url": "packages/error-digest-bundle" } ], "require": { "hexis/error-digest-bundle": "@dev" }
2. Register the bundle
// config/bundles.php return [ // ... Hexis\ErrorDigestBundle\ErrorDigestBundle::class => ['all' => true], ];
3. Configure Doctrine migrations
# config/packages/doctrine_migrations.yaml doctrine_migrations: migrations_paths: 'DoctrineMigrations': '%kernel.project_dir%/migrations' 'Hexis\ErrorDigestBundle\Migrations': '%kernel.project_dir%/vendor/hexis/error-digest-bundle/src/Resources/migrations'
(path-repo installs symlink, so that path resolves via the vendor symlink)
4. Configure routing
The bundle ships two route groups — the admin UI and the JS ingest endpoint — under separate controller subdirectories so you can host-constrain or auth-gate them independently:
# config/routes/error_digest.yaml error_digest_admin: resource: '@ErrorDigestBundle/src/Controller/Admin/' type: attribute prefix: /_errors # Optional: restrict to a host, e.g. for a superadmin subdomain # host: 'superadmin.{_domain}' # defaults: { _domain: '%app.base_domain%' } # requirements: { _domain: '.+' } error_digest_ingest: resource: '@ErrorDigestBundle/src/Controller/Ingest/' type: attribute prefix: /_errors/ingest # Public POST endpoint for browser error reports — leave host-unconstrained # so any subdomain in your app can send to it.
Upgrading from v0.1.x? The admin controllers moved from
Controller/toController/Admin/. Update yourresource:path accordingly. Route names (error_digest_dashboard, etc.) are unchanged.
5. Configure the bundle
# config/packages/error_digest.yaml error_digest: enabled: true minimum_level: warning # debug | info | notice | warning | error | critical | alert | emergency channels: ~ # null/empty = all channels environments: [prod, dev] storage: connection: default # doctrine connection name table_prefix: err_ occurrence_retention_days: 30 ignore: - { class: Symfony\Component\HttpKernel\Exception\NotFoundHttpException } - { class: Symfony\Component\Security\Core\Exception\AccessDeniedException } - { channel: deprecation, level: notice } digest: enabled: true schedule: '0 8 * * *' # informational; bundle doesn't schedule itself recipients: ['%env(ADMIN_EMAIL)%'] from: 'noreply@%env(APP_DOMAIN)%' senders: [mailer] # + 'notifier' to enable chat pings window: '24 hours' sections: [new, spiking, top, stale] top_limit: 10 stale_days: 7 spike_multiplier: 3.0 # window vs prior window ratio to count as "spiking" notifier_transports: [] # named Notifier transports, empty = default ui: enabled: true route_prefix: /_errors role: ROLE_ADMIN rate_limit: per_fingerprint_seconds: 1 # within this window, one occurrence row per fingerprint js: enabled: true allowed_origins: ['https://%env(APP_DOMAIN)%', 'https://*.%env(APP_DOMAIN)%'] max_payload_bytes: 16384 max_stack_lines: 50 rate_limit_per_minute: 30 # per-IP cap client_max_per_page: 50 # browser-side cap per page-load client_dedup_window_ms: 5000 # browser-side dedup release: '%env(default::APP_RELEASE)%' # optional, included in fingerprint context
6. Run migrations
bin/console doctrine:migrations:migrate --no-interaction
Two tables are created: err_fingerprint (lifetime state per error signature) and err_occurrence (rolling event log).
Upgrading from v0.3.x
No PHP API changed (PiiScrubber, Writer and the service constructors are as they were), but these behaviour changes can break a host:
- The Doctrine mapping moved from
src/Resources/config/doctrinetoconfig/doctrineat the package root, wheredoctrine.orm.auto_mappingfinds it, so a host on the Symfony recipe's Doctrine config builds as is. Removemappings: { ErrorDigestBundle: false }if you added it only to get the container to build (keep it on another entity manager that auto-maps, see Multi-tenant), and point any config of yours that names the old directory at the new one. - Your config wins over the bundle's. The Monolog, Doctrine and migrations config the bundle adds is prepended, so where your config sets the same key, your value is used now. The
error_digestMonolog handler now runs before the handlers of your config (it ran after them), so a handler of yours withbubble: falseno longer hides records from it. - What is stored is scrubbed: the message, trace preview, request URI and context of server errors, and every text of browser errors, with URLs cut at their query string.
DefaultPiiScrubberalso masks e-mail addresses, URL query strings, fragments and credentials, and card-number integers, keeps objects by class name only, and fails closed. Rows stored before the upgrade are not rewritten. - A resolved fingerprint that occurs again is open again (a muted one stays muted).
Trigger the digest
The bundle does not schedule itself. You wire the trigger via OS cron, a Symfony Scheduler recipe of your own, or whatever orchestrator you already use. Example cron:
0 8 * * * docker compose exec -T php php bin/console error-digest:send-digest
Console commands
| Command | Purpose |
|---|---|
error-digest:send-digest [--dry-run] [--as-of=Y-m-d H:i:s] |
Build the digest and dispatch to configured senders. |
error-digest:fingerprint-test [--count=N] [--message=…] [--level=…] [--ignored] |
Emit a synthetic exception so you can verify the capture pipeline end-to-end. |
error-digest:prune [--older-than=30d] [--dry-run] |
Delete occurrence rows older than the threshold. Fingerprint rows and lifetime counters are preserved. |
error-digest:list [--status=open] [--since=7d] [--limit=50] |
Print fingerprints as JSON for triage (by developers, scripts or an AI agent). Read-only. |
Listing errors as JSON
error-digest:list prints the fingerprints matching the filters, most recently seen first, to stdout as JSON (errors go to stderr, exit code 2 on an invalid option):
--status:open(default),resolved,mutedorall--since: only fingerprints last seen since7d,12h,90m,Y-m-dor"Y-m-d H:i:s"--limit: maximum number of items (default 50);totalstill counts every match
bin/console error-digest:list --since=1d --limit=20 > open-errors.json
{
"generated_at": "2026-10-15T09:00:00+02:00",
"filters": { "status": "open", "since": "2026-10-14T09:00:00+02:00", "limit": 20 },
"total": 1,
"items": [
{
"id": 3,
"fingerprint": "d8412648e6c7b2d88468944bd582e88bdd9090f8efe6f8702ece99579bd75301",
"status": "open",
"level": "critical",
"channel": "request",
"environment": "prod",
"exception_class": "LogicException",
"message": "Partner 4711 could not be saved, Bearer [REDACTED] rejected",
"file": "/app/src/Partner/PartnerService.php",
"line": 63,
"occurrence_count": 2,
"first_seen_at": "2026-10-15T08:59:31+02:00",
"last_seen_at": "2026-10-15T08:59:32+02:00",
"latest_occurrence": {
"occurred_at": "2026-10-15T08:59:32+02:00",
"method": "POST",
"request_uri": "/partners/4711",
"release": null,
"trace_preview": "LogicException: Partner 4711 could not be saved, Bearer [REDACTED] rejected\n at /app/src/Partner/PartnerService.php:63\n #0 ..."
}
}
]
}
latest_occurrence is the most recent occurrence row (null once pruned); release is set for browser errors. message, trace_preview and request_uri are stored scrubbed and pass through the configured PiiScrubber again (rows stored by older versions are not), query strings and fragments are cut from request_uri, and occurrence context and user references are left out. The export is scrubbed, not anonymized: the default scrubber masks only the patterns listed under PII scrubbing, so names, phone numbers, postal addresses and other personal data in free text are printed as stored. Review an export before sharing it, or configure a stricter PiiScrubber.
How capture works
Handler registration
The bundle auto-registers its Monolog handler at boot via prependExtension, so you don't edit monolog.yaml manually. The Monolog, Doctrine and migrations config the bundle adds goes in front of yours, so your own config wins: monolog.handlers.error_digest.id can point at a service of yours (one that decorates ErrorDigestHandler, say), and doctrine.orm.mappings.ErrorDigest or doctrine_migrations.migrations_paths can replace the bundle's directories. The handler:
- Filters by
minimum_level,channels,environments, andignorerules. - Fingerprints the record via
Hexis\ErrorDigestBundle\Domain\Fingerprinter. - Buffers in memory, keyed by fingerprint, tracking count + first/last-seen timestamps.
- Rate-limits occurrence-row writes: one write per fingerprint per
per_fingerprint_secondswindow. - Flushes to the database on
kernel.terminateandconsole.terminatevia a single DBAL transaction.
Fingerprinting
The default fingerprinter hashes a stable tuple:
- With exception:
class | file | line | normalized_message - Without exception:
"log" | channel | level | normalized_message
Message normalization strips UUIDs → UUID, long hex hashes → HASH, bare integers → 0 — so "user 42 not found" and "user 4711 not found" fingerprint identically.
To plug your own, implement Hexis\ErrorDigestBundle\Domain\Fingerprinter and wire it:
error_digest: dedup: fingerprinter: my.custom.fingerprinter.service.id
PII scrubbing
Everything the bundle stores passes the configured scrubber first: the message, the trace preview, the request URI and the context of every occurrence (_extra included), and for browser errors the message, type, source and page URL, stack and user reference. URLs (the request URI, the referrer, the page and source URL of a browser error) lose their query string and fragment before that, and objects in the context are stored by class name only, whatever the scrubber returns. A text the scrubber drops or turns into a non-string is stored as [REDACTED].
DefaultPiiScrubber, the scrubber unless error_digest.scrubber names another, redacts:
- Keys matching (case-insensitive, substring):
password,token,authorization,cookie,session,csrf,jwt,api_key,secret, etc. - Credit-card-shaped number sequences anywhere in values, and integers of 13 to 19 digits.
- Bearer tokens (
Bearer xxx→Bearer [REDACTED]). - JWT-shaped strings (three base64url segments joined by
.). - E-mail addresses.
- Query strings and fragments of absolute and relative URLs (
https://example.com/reset?[REDACTED],/reset?[REDACTED]) and credentials in URLs (mysql://[REDACTED]@db/app); an apostrophe does not end either.
Objects are kept by class name only ([object App\Entity\Partner]), never with their properties. If a pattern fails (the PCRE backtrack limit, say), the whole value is replaced with [REDACTED]: the scrubber fails closed.
Override by implementing Hexis\ErrorDigestBundle\Domain\PiiScrubber and setting error_digest.scrubber to your service id.
Safety properties
- Handler is non-blocking on error. Anything thrown during capture or flush is caught and sent to
error_log()— never re-entered into Monolog, so no capture storm. - Direct DBAL writes, not ORM. Handler's transaction is independent of the host's EntityManager; logging inside a rollback is safe.
- Handler stays lazy. Buffer lives only for the request/command lifetime. No background state.
UI
Once wired, the dashboard is at {route_prefix}/ (default /_errors/). Requires the role configured in error_digest.ui.role (default ROLE_ADMIN).
| Page | URL | What you can do |
|---|---|---|
| Dashboard | GET /_errors/ |
Filter by status / level / channel, search in message & class & fingerprint, paginate |
| Detail | GET /_errors/fingerprint/{id} |
Inspect message, stack trace, occurrence timeline, scrubbed context per occurrence |
| Status | POST /_errors/fingerprint/{id}/status |
Mark resolved / muted / re-open |
| Assign | POST /_errors/fingerprint/{id}/assign |
Set an assignee (free-form string ref — email or user id) |
A resolved fingerprint that occurs again (server or browser) is open again, with its assignee kept; a muted one stays muted.
CSRF tokens are required on both POST endpoints and are baked into the templates via csrf_token().
The templates use Bootstrap 5 via CDN by default. Override with your own by placing files in templates/bundles/ErrorDigestBundle/.
Digest sections
| Section | Definition |
|---|---|
| new | Fingerprints first seen within digest.window AND with notified_in_digest_at IS NULL. Strongest signal for "something just broke." |
| spiking | Window occurrence count > spike_multiplier × max(1, prior-window occurrence count). |
| top | Open fingerprints with the most occurrences in the window. Limited by digest.top_limit. |
| stale | Status=open, first seen more than stale_days ago. Reminders that unresolved issues are still alive. |
After each successful send, fingerprints included in the digest get notified_in_digest_at stamped so "new" stays new.
Messenger integration
SendDailyDigest is dispatched via the default message bus when you run error-digest:send-digest. To push actual digest building off the web request path, route it to an async transport:
# config/packages/messenger.yaml framework: messenger: routing: 'Hexis\ErrorDigestBundle\Message\SendDailyDigest': async
The digest handler never runs in the request path by itself — it's only dispatched by the console command or your scheduler.
Browser error capture (since v0.2.0)
JS errors that fire in users' browsers go undetected by default — Monolog only sees server-side. The bundle ships a vanilla-JS client + ingest endpoint so browser errors land in the same err_fingerprint table, channel = js, alongside server-side captures.
Wire it in
The ingest controller registers a public POST route under Controller/Ingest/. Once your routing splits Admin/ from Ingest/ (see install step 4), the endpoint is live at the prefix you configured.
Then publish the JS file:
bin/console assets:install
This copies error-digest.js to public/bundles/errordigest/error-digest.js. Include it in your base layout via the Twig helper:
<!-- in base.html.twig, before </body> --> {{ error_digest_script() }} <!-- with optional release + user id baked in --> {{ error_digest_script({release: app_version, user: app.user.id}) }}
The helper emits a <script defer> tag pointing at the bundled JS file with data-endpoint set to the ingest route URL (so URL generation handles your subdomain config automatically).
What gets captured
window.errorevents — synchronous JS errors + resource load failuresunhandledrejectionevents — async / Promise rejections- Anything you forward manually via
window.errorDigest.capture(error, {extra})
Each report carries: message, exception type, source URL, line, column, stack trace, page URL, user agent, optional release + user.
Safety properties
- Client-side dedup — same fingerprint within
client_dedup_window_msis sent once - Page-load cap — at most
client_max_per_pagereports per page (kills runaway loops) - Server-side per-IP rate limit —
rate_limit_per_minutecap, configurable, 0 disables - Origin allowlist —
allowed_originscontrols which sites' browsers can POST. Supports wildcards (https://*.example.com). - Payload caps — request body capped at
max_payload_bytes, stack trimmed tomax_stack_lines - Always 204 — endpoint never leaks validation/policy info to clients
navigator.sendBeacon— flushes onpagehideso errors right before navigation aren't lost
Manual API
<script> try { riskyOperation(); } catch (e) { window.errorDigest.capture(e, {source: 'checkout-form'}); } </script>
What's deliberately NOT captured
console.errorcalls — too noisy (third-party scripts, devtools warnings)- Network errors / failed fetches — separate concern; use the manual API if you need it
- Source-map resolution — line/col stays as minified positions; resolve from devtools
Multi-tenant / separate-connection setups
If your default connection is tenant-scoped (e.g., via a DBAL wrapper that filters by tenant id), you probably want errors to flow into a single cross-tenant connection. Two settings control the routing:
error_digest: storage: connection: superadmin # DBAL writes go through this connection entity_manager: superadmin # Doctrine mapping attaches to this EM
connection— which DBAL connection the handler and digest builder use for reads/writes. Defaults todefault.entity_manager(since v0.1.1) — which EntityManager gets theErrorDigestmapping. Defaults tonullwhich lands on the default EM. Set this to the EM that owns your non-default connection sodoctrine:schema:update --em=<name>anddoctrine:migrations:migrate --em=<name>operate on the right schema.
If another EntityManager has auto_mapping: true, Doctrine maps the bundle there as well. Exclude it on that EM with mappings: { ErrorDigestBundle: false }.
For a typical default (tenant) + superadmin (global) two-EM setup, point both settings at superadmin. Then run the bundle's migration against that EM once:
php bin/console doctrine:migrations:migrate --em=superadmin --no-interaction
Add the same invocation to your deploy pipeline alongside your regular doctrine:migrations:migrate (which runs on the default EM).
Development
Bundle ships with a phpunit suite covering fingerprinter, scrubber, rate-limit buffer, handler filtering, DBAL writer (SQLite in-memory), and digest builder (SQLite in-memory):
cd packages/error-digest-bundle
../../vendor/bin/phpunit
Roadmap
- LiveComponent-based live-tail view
- Fuzzy stack-trace clustering beyond fingerprint
- Threshold-based paging / alert rules engine
- Auto-issue creation (GitHub / Linear)
- Multi-app aggregator (N apps report into one central instance over HTTP)
License
Proprietary — Hexis.