asdrubalp9/laravel-issue-reporter

Capture Laravel errors and report them as GitLab issues, with deduplication.

Maintainers

Package info

gitlab.com/asdrubalp9/laravel-issue-reporter

Issues

pkg:composer/asdrubalp9/laravel-issue-reporter

Transparency log

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

v0.3.0 2026-07-23 18:00 UTC

This package is auto-updated.

Last update: 2026-07-23 22:00:52 UTC


README

Capture application errors and publish them as GitLab issues, with fingerprint-based deduplication.

pipeline status

What it does / what it doesn't

Does:

  • Captures exceptions and log messages through a dedicated issue-reporter log channel, or through the IssueReporter facade.
  • 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:test command 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 (default 300): 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 (default false): 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 to true to 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-case and concatenated forms — so api_key, Api-Key and X-Api-Key all match), including a built-in base list of password, passwd, secret, token, api_key, apikey, authorization, auth, credit_card, card_number, cvv, ssn, private_key, session, cookie, access_token, refresh_token, remember_token and _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.

KeyDefaultDescription
enabledtrueMaster 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.tokennullPersonal/project access token with api scope.
drivers.gitlab.projectnullProject path or numeric ID (group/project).
drivers.gitlab.timeout10HTTP timeout in seconds for GitLab API calls.
drivers.bitbucket.workspacenullBitbucket workspace slug.
drivers.bitbucket.repositorynullRepository slug within the workspace.
drivers.bitbucket.emailnullAtlassian account email, used as the Basic-auth username.
drivers.bitbucket.api_tokennullAtlassian 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.timeout10HTTP timeout in seconds for Bitbucket API calls.
drivers.jira.urlnullFull Jira Cloud site URL (e.g. https://acme.atlassian.net).
drivers.jira.emailnullAtlassian account email, used as the Basic-auth username.
drivers.jira.api_tokennullAtlassian API token, used as the Basic-auth password.
drivers.jira.projectnullJira 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_transitionnullTransition name or id to force when reopening; null auto-picks the first transition to a non-Done status.
drivers.jira.timeout10HTTP timeout in seconds for Jira API calls.
drivers.null[]No configuration; the null driver discards everything.
queue.enabledtrueWhether delivery is dispatched to the queue (true) or run synchronously (false).
queue.connectionnullQueue 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_seconds300Minimum seconds between two comments on the same fingerprint.
reopen_closedfalseWhether a recurrence reopens a closed issue.
stack_frames25Number of stack frames included in the issue body.
labels['bug', 'issue-reporter']Labels applied to every created issue.
label_with_environmenttrueWhether the current app.env is added as an extra label.
comment_includes_contexttrueWhether recurrence comments include the report's context array.
ignore_exceptionslist 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, not username / 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 labels config for this driver and instead files every report with a configurable kind (default bug) and priority (default major); see drivers.bitbucket.kind / drivers.bitbucket.priority in 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 (default Bug): 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 (default null): transition name or id to force when reopening; null auto-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_TRANSITION to 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 labels config 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.