asdrubalp9 / laravel-issue-reporter
Capture Laravel errors and report them as GitLab issues, with deduplication.
Package info
gitlab.com/asdrubalp9/laravel-issue-reporter
pkg:composer/asdrubalp9/laravel-issue-reporter
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0
- illuminate/http: ^11.0|^12.0
- illuminate/queue: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- monolog/monolog: ^3.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.0
README
Capture application errors and publish them as GitLab issues, with fingerprint-based deduplication.
What it does / what it doesn't
Does:
- Captures exceptions and log messages through a dedicated
issue-reporterlog channel, or through theIssueReporterfacade. - Fingerprints each error so recurring failures comment on the existing GitLab issue instead of creating a new one every time.
- Scrubs sensitive data (secret-looking keys, query strings, credit-card-shaped numbers) before anything leaves the application.
- Delivers through the queue by default, with a synchronous mode for local debugging.
- Ships a
issue-reporter:testcommand to verify the token, project and network path are correct.
Does not:
- Replace an APM like Sentry or Bugsnag: there is no dashboard, no performance monitoring, no breadcrumbs, no session tracking. Errors become issues, nothing more.
- Ship a working GitHub driver. GitLab, Bitbucket Cloud and Jira Cloud are implemented (see Bitbucket and Jira); anything else needs a custom driver.
- Guarantee zero-loss delivery under concurrency. The dedup logic uses a pessimistic lock on the fingerprint row to decide create-vs-comment, but a comment that arrives before its sibling create has finished can be dropped after a few retries (see Deduplication), and a create that exhausts its retries leaves that fingerprint without an issue until the row is fixed by hand. Losing a comment is an accepted trade-off; a duplicate issue is not.
Installation
composer require asdrubalp9/laravel-issue-reporter
php artisan vendor:publish --tag=issue-reporter-config
php artisan vendor:publish --tag=issue-reporter-migrations
php artisan migrate
Configuration
Add these to your .env:
ISSUE_REPORTER_GITLAB_TOKEN=glpat-xxxxxxxx
ISSUE_REPORTER_GITLAB_PROJECT=group/my-app
The token needs the api scope and at least the Reporter role on the
project — it must be able to create issues and notes.
See Configuration reference for every other key.
Enabling capture
Add the issue-reporter channel to your log stack in config/logging.php:
'stack' => [
'driver' => 'stack',
'channels' => ['single', 'issue-reporter'],
'ignore_exceptions' => false,
],
'issue-reporter' => [
'driver' => 'issue-reporter',
'level' => env('ISSUE_REPORTER_LOG_LEVEL', 'error'),
],
Anything logged at error level or above (through Laravel's default exception
handler, or your own Log::error(...) calls) now flows through the reporter.
Verifying
php artisan issue-reporter:test
This creates a throwaway issue titled [test] IssueReporter smoke test and
prints its URL. It bypasses deduplication entirely, so it always creates a
new issue — run it as many times as you like, then close the issues it made.
Pass --driver= to check a driver other than the configured default.
Manual usage
Beyond the log channel, the IssueReporter facade is available directly:
use Asdrubalp9\IssueReporter\Facades\IssueReporter;
// Report a caught exception, with extra context merged into the issue body.
IssueReporter::report($e, ['order_id' => 7]);
// Report a plain message instead of an exception.
IssueReporter::message('Disk almost full', [], 'critical');
// Force a specific fingerprint instead of the automatic one, so unrelated
// failures that should be tracked as "the same problem" collapse into one
// issue.
IssueReporter::report($e, ['fingerprint' => 'billing-failure']);
Deduplication
Every report is reduced to a fingerprint: by default it's a hash of the
exception class, the normalized file path and line, and the message with
paths, UUIDs, emails and numbers collapsed to placeholders (so Order 42 not
found and Order 917 not found hash the same). Passing a fingerprint key
in the context array (see above) overrides this and lets you group errors
manually.
The first time a fingerprint is seen, an issue is created. Every subsequent occurrence increments a counter and — subject to the throttle below — posts a comment on the existing issue instead of opening a new one.
throttle_seconds(default300): after a comment is posted for a fingerprint, further occurrences of the same fingerprint are silently counted but not re-reported until this many seconds have passed. This prevents a hot loop from flooding the issue with comments.reopen_closed(defaultfalse): if the tracked issue was closed on GitLab, a new occurrence does not reopen it by default — it's just counted, on the assumption that a closed issue was closed on purpose. Set this totrueto have a recurrence reopen the issue and comment on it.
Privacy
The Scrubber runs unconditionally on every report, before it is queued or
sent anywhere — there's no way to disable it.
It redacts:
- Any array key that looks sensitive (case-insensitive, and matching across
snake_case,kebab-caseand concatenated forms — soapi_key,Api-KeyandX-Api-Keyall match), including a built-in base list ofpassword,passwd,secret,token,api_key,apikey,authorization,auth,credit_card,card_number,cvv,ssn,private_key,session,cookie,access_token,refresh_token,remember_tokenand_token. - The same sensitive keys when they appear in the URL's query string.
- Any run of digits that is 13–19 digits long (optionally grouped with
spaces or hyphens, e.g.
4242 4242 4242 4242) and passes the Luhn checksum — regardless of the key it's under, since card numbers show up in free-text messages too.
The scrub_keys config value adds to the base list above; it cannot be
used to shrink or disable it. There is no config option to turn scrubbing
off.
Configuration reference
All keys live under config/issue-reporter.php.
| Key | Default | Description |
|---|---|---|
enabled | true | Master on/off switch for the whole package. |
default | 'gitlab' | The tracker driver used when none is passed explicitly. |
environments | ['production', 'staging'] | app.env values in which reporting is active; empty array means all. |
minimum_level | 'error' | Minimum Monolog level that triggers a report. |
drivers.gitlab.url | 'https://gitlab.com' | Base URL of the GitLab instance (self-hosted supported). |
drivers.gitlab.token | null | Personal/project access token with api scope. |
drivers.gitlab.project | null | Project path or numeric ID (group/project). |
drivers.gitlab.timeout | 10 | HTTP timeout in seconds for GitLab API calls. |
drivers.bitbucket.workspace | null | Bitbucket workspace slug. |
drivers.bitbucket.repository | null | Repository slug within the workspace. |
drivers.bitbucket.email | null | Atlassian account email, used as the Basic-auth username. |
drivers.bitbucket.api_token | null | Atlassian API token, used as the Basic-auth password. |
drivers.bitbucket.kind | 'bug' | Bitbucket issue kind applied to every created issue (Bitbucket has no free-form labels). |
drivers.bitbucket.priority | 'major' | Bitbucket issue priority applied to every created issue. |
drivers.bitbucket.timeout | 10 | HTTP timeout in seconds for Bitbucket API calls. |
drivers.jira.url | null | Full Jira Cloud site URL (e.g. https://acme.atlassian.net). |
drivers.jira.email | null | Atlassian account email, used as the Basic-auth username. |
drivers.jira.api_token | null | Atlassian API token, used as the Basic-auth password. |
drivers.jira.project | null | Jira project key (e.g. OPS), not the project name or numeric ID. |
drivers.jira.issue_type | 'Bug' | Jira issue type name used when creating issues. |
drivers.jira.reopen_transition | null | Transition name or id to force when reopening; null auto-picks the first transition to a non-Done status. |
drivers.jira.timeout | 10 | HTTP timeout in seconds for Jira API calls. |
drivers.null | [] | No configuration; the null driver discards everything. |
queue.enabled | true | Whether delivery is dispatched to the queue (true) or run synchronously (false). |
queue.connection | null | Queue connection used for the delivery job; null uses the app default. |
queue.name | 'default' | Queue name used for the delivery job. |
table | 'issue_reporter_errors' | Table name for the dedup/tracking model. |
throttle_seconds | 300 | Minimum seconds between two comments on the same fingerprint. |
reopen_closed | false | Whether a recurrence reopens a closed issue. |
stack_frames | 25 | Number of stack frames included in the issue body. |
labels | ['bug', 'issue-reporter'] | Labels applied to every created issue. |
label_with_environment | true | Whether the current app.env is added as an extra label. |
comment_includes_context | true | Whether recurrence comments include the report's context array. |
ignore_exceptions | list of framework exceptions (auth, validation, 404, 405, token mismatch) | Exception classes (and subclasses) that never get reported. |
scrub_keys | [] | Extra sensitive keys, added to the built-in base list (see Privacy). |
emergency_log_channel | 'single' | Log channel used to record failures of the reporter itself. |
Bitbucket
Bitbucket Cloud is a fully implemented driver. To use it, set
ISSUE_REPORTER_DRIVER=bitbucket and fill in these four env vars:
ISSUE_REPORTER_DRIVER=bitbucket
ISSUE_REPORTER_BITBUCKET_WORKSPACE=my-workspace
ISSUE_REPORTER_BITBUCKET_REPO=my-repo
ISSUE_REPORTER_BITBUCKET_EMAIL=me@example.com
ISSUE_REPORTER_BITBUCKET_API_TOKEN=xxxxxxxx
A few things that are easy to get wrong:
- Auth is an Atlassian API token, not an app password. Bitbucket app
passwords are deprecated and are being removed as of 2026-07-28, so this
driver never supports them. Authentication is HTTP Basic auth with your
Atlassian account email as the username and the API token as the
password — that's why the config keys are
email/api_token, notusername/password. - The repository's issue tracker must be enabled. If it isn't, Bitbucket's API returns a 404 on issue creation, the same as it would for a missing repository.
- Bitbucket has no free-form labels. The driver ignores whatever is in
the
labelsconfig for this driver and instead files every report with a configurablekind(defaultbug) andpriority(defaultmajor); seedrivers.bitbucket.kind/drivers.bitbucket.priorityin the configuration reference.
Jira
Jira Cloud is a fully implemented driver. To use it, set
ISSUE_REPORTER_DRIVER=jira and fill in these env vars:
ISSUE_REPORTER_DRIVER=jira
ISSUE_REPORTER_JIRA_URL=https://acme.atlassian.net
ISSUE_REPORTER_JIRA_EMAIL=me@example.com
ISSUE_REPORTER_JIRA_API_TOKEN=xxxxxxxx
ISSUE_REPORTER_JIRA_PROJECT=OPS
ISSUE_REPORTER_JIRA_URL is the full site domain, not just the workspace
name. ISSUE_REPORTER_JIRA_PROJECT is the Jira project key (e.g. OPS),
not the project's display name. One more setting is config-only (no dedicated
env var):
issue_type(defaultBug): the Jira issue type name used when creating issues.
Optionally, you can set an env var to customize the reopen transition:
ISSUE_REPORTER_JIRA_REOPEN_TRANSITION(defaultnull): transition name or id to force when reopening;nullauto-picks the first transition to a non-Done status.
A few things that are easy to get wrong:
- Auth is an Atlassian API token, not a password. Authentication is HTTP Basic auth with your Atlassian account email as the username and the API token as the password — the same mechanism as the Bitbucket driver.
- Issues render as native Jira content, not markdown. The description
(and recurrence comments) are sent as Atlassian Document Format (ADF), so
they show up in the Jira UI as real paragraphs, tables and bold text
instead of raw
**markdown**syntax. - Reopening uses workflow transitions, not a status field. Jira Cloud
has no generic "reopen" endpoint; instead the driver lists the issue's
available transitions and, by default, picks the first one that leads to a
non-Done status category. Workflows vary a lot between projects, so if the
wrong transition gets picked (or none is found), set
ISSUE_REPORTER_JIRA_REOPEN_TRANSITIONto the exact transition name or id you want used. - Labels are applied with surrounding whitespace trimmed and internal spaces replaced by underscores. Unlike
Bitbucket, Jira supports free-form labels, so the
labelsconfig is used directly; see configuration reference.
To use a tracker that isn't GitLab, Bitbucket, or Jira — GitHub, for
instance — register your own driver with TrackerManager::extend():
use Asdrubalp9\IssueReporter\TrackerManager;
app(TrackerManager::class)->extend('github', function ($app) {
return new MyGitHubTracker(/* ... */);
});
Your driver must implement Asdrubalp9\IssueReporter\Contracts\IssueTracker
(createIssue, commentOnIssue, getIssueState, reopenIssue). An
unimplemented method on a driver that isn't ready yet should throw
Asdrubalp9\IssueReporter\Exceptions\UnsupportedDriverException — that's
what BitbucketTracker did before it was implemented, and it's the pattern
this package expects: fail loudly rather than silently drop a report.
Testing
Run the package's own suite:
composer test
In the tests of a host application, swap the real tracker for
Asdrubalp9\IssueReporter\Trackers\FakeTracker, which records every call
instead of making HTTP requests:
use Asdrubalp9\IssueReporter\Trackers\FakeTracker;
use Asdrubalp9\IssueReporter\TrackerManager;
$tracker = new FakeTracker;
$this->app->make(TrackerManager::class)->extend('fake', fn () => $tracker);
config()->set('issue-reporter.default', 'fake');
// ... trigger the error ...
$this->assertCount(1, $tracker->created);
License
MIT. See LICENSE.