PHPackages                             hexis/error-digest-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. hexis/error-digest-bundle

ActiveSymfony-bundle[Database &amp; ORM](/categories/database)

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.

v0.2.1(1mo ago)0170↑55.6%1[1 PRs](https://github.com/hexis-hr/error-digest-bundle/pulls)MITPHPPHP &gt;=8.2

Since Apr 21Pushed 1mo agoCompare

[ Source](https://github.com/hexis-hr/error-digest-bundle)[ Packagist](https://packagist.org/packages/hexis/error-digest-bundle)[ Docs](https://github.com/hexis-hr/error-digest-bundle)[ RSS](/packages/hexis-error-digest-bundle/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (17)Versions (9)Used By (0)

hexis/error-digest-bundle
=========================

[](#hexiserror-digest-bundle)

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
------------

[](#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_occurrence` tables on whichever Doctrine connection you point it at.
- **Scrubs** PII (passwords, tokens, bearer headers, JWTs, credit-card patterns) 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
------------

[](#requirements)

- PHP 8.2+
- Symfony 7.3
- Doctrine ORM 3 / DBAL 3
- Monolog 3 + `symfony/monolog-bundle`
- Messenger + Mailer (host probably already has these)

Optional:

- `symfony/notifier` + a chat transport (Slack, Teams, Discord, Rocket.Chat) for chat pings
- `symfony/scheduler` for cron-less scheduling (or use OS cron + the console command)

---

Install
-------

[](#install)

### 1. Require the package

[](#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

[](#2-register-the-bundle)

```
// config/bundles.php
return [
    // ...
    Hexis\ErrorDigestBundle\ErrorDigestBundle::class => ['all' => true],
];
```

### 3. Configure Doctrine migrations

[](#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

[](#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/` to `Controller/Admin/`. Update your `resource:` path accordingly. Route names (`error_digest_dashboard`, etc.) are unchanged.

### 5. Configure the bundle

[](#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

[](#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).

---

Trigger the digest
------------------

[](#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

[](#console-commands)

CommandPurpose`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.---

How capture works
-----------------

[](#how-capture-works)

### Handler registration

[](#handler-registration)

The bundle auto-registers its Monolog handler at boot via `prependExtension`, so you don't edit `monolog.yaml` manually. The handler:

1. Filters by `minimum_level`, `channels`, `environments`, and `ignore` rules.
2. Fingerprints the record via `Hexis\ErrorDigestBundle\Domain\Fingerprinter`.
3. Buffers in memory, keyed by fingerprint, tracking count + first/last-seen timestamps.
4. Rate-limits occurrence-row writes: one write per fingerprint per `per_fingerprint_seconds` window.
5. Flushes to the database on `kernel.terminate` and `console.terminate` via a single DBAL transaction.

### Fingerprinting

[](#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

[](#pii-scrubbing)

`DefaultPiiScrubber` runs over every occurrence's context before it hits the database. It 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.
- Bearer tokens (`Bearer xxx` → `Bearer [REDACTED]`).
- JWT-shaped strings (three base64url segments joined by `.`).

Override by implementing `Hexis\ErrorDigestBundle\Domain\PiiScrubber` and setting `error_digest.scrubber` to your service id.

### Safety properties

[](#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
--

[](#ui)

Once wired, the dashboard is at `{route_prefix}/` (default `/_errors/`). Requires the role configured in `error_digest.ui.role` (default `ROLE_ADMIN`).

PageURLWhat you can doDashboard`GET /_errors/`Filter by status / level / channel, search in message &amp; class &amp; fingerprint, paginateDetail`GET /_errors/fingerprint/{id}`Inspect message, stack trace, occurrence timeline, scrubbed context per occurrenceStatus`POST /_errors/fingerprint/{id}/status`Mark resolved / muted / re-openAssign`POST /_errors/fingerprint/{id}/assign`Set an assignee (free-form string ref — email or user id)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
---------------

[](#digest-sections)

SectionDefinition**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 &gt; `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
---------------------

[](#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)
------------------------------------

[](#browser-error-capture-since-v020)

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

[](#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:

```

{{ error_digest_script() }}

{{ error_digest_script({release: app_version, user: app.user.id}) }}
```

The helper emits a `` 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

[](#what-gets-captured)

- `window.error` events — synchronous JS errors + resource load failures
- `unhandledrejection` events — 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

[](#safety-properties-1)

- **Client-side dedup** — same fingerprint within `client_dedup_window_ms` is sent once
- **Page-load cap** — at most `client_max_per_page` reports per page (kills runaway loops)
- **Server-side per-IP rate limit** — `rate_limit_per_minute` cap, configurable, 0 disables
- **Origin allowlist** — `allowed_origins` controls which sites' browsers can POST. Supports wildcards (`https://*.example.com`).
- **Payload caps** — request body capped at `max_payload_bytes`, stack trimmed to `max_stack_lines`
- **Always 204** — endpoint never leaks validation/policy info to clients
- **`navigator.sendBeacon`** — flushes on `pagehide` so errors right before navigation aren't lost

### Manual API

[](#manual-api)

```

    try {
        riskyOperation();
    } catch (e) {
        window.errorDigest.capture(e, {source: 'checkout-form'});
    }

```

### What's deliberately NOT captured

[](#whats-deliberately-not-captured)

- `console.error` calls — 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
-----------------------------------------

[](#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 to `default`.
- **`entity_manager`** (since v0.1.1) — which EntityManager gets the `ErrorDigest` mapping. Defaults to `null` which lands on the default EM. Set this to the EM that owns your non-default connection so `doctrine:schema:update --em=` and `doctrine:migrations:migrate --em=` operate on the right schema.

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
-----------

[](#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
-------

[](#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
-------

[](#license)

Proprietary — Hexis.

###  Health Score

41

—

FairBetter than 87% of packages

Maintenance90

Actively maintained with recent releases

Popularity16

Limited adoption so far

Community7

Small or concentrated contributor base

Maturity41

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

8

Last Release

47d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/1439958?v=4)[Sinisa Valentic](/maintainers/tic984)[@tic984](https://github.com/tic984)

---

Top Contributors

[![tic984](https://avatars.githubusercontent.com/u/1439958?v=4)](https://github.com/tic984 "tic984 (8 commits)")

---

Tags

symfonyloggingerror-reportingdoctrinedigestmonologSymfony Bundleobservabilityerror-tracking

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/hexis-error-digest-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/hexis-error-digest-bundle/health.svg)](https://phpackages.com/packages/hexis-error-digest-bundle)
```

###  Alternatives

[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k17.5M370](/packages/easycorp-easyadmin-bundle)[sulu/sulu

Core framework that implements the functionality of the Sulu content management system

1.3k1.4M195](/packages/sulu-sulu)[open-dxp/opendxp

Content &amp; Product Management Framework (CMS/PIM)

9017.2k55](/packages/open-dxp-opendxp)[shopware/core

Shopware platform is the core for all Shopware ecommerce products.

585.4M506](/packages/shopware-core)[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.5k5.8M710](/packages/sylius-sylius)[pimcore/pimcore

Content &amp; Product Management Framework (CMS/PIM/E-Commerce)

3.8k3.8M444](/packages/pimcore-pimcore)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
