ashita-planning / laravel-error-monitor-github
GitHub Issue adapter for ashita-planning/laravel-error-monitor.
Package info
github.com/ashita-planning/laravel-error-monitor-github
pkg:composer/ashita-planning/laravel-error-monitor-github
Requires
- php: ^8.2
- ashita-planning/laravel-error-monitor: ^1.0
- guzzlehttp/guzzle: ^7.8
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^10.5|^11.0|^12.0
This package is auto-updated.
Last update: 2026-08-05 10:26:18 UTC
README
ashita-planning/laravel-error-monitor-github files the failures
ashita-planning/laravel-error-monitor
finds as GitHub issues.
It contains no log parsing, no fingerprinting and no masking — those belong to the core. This package knows about GitHub, and the core knows nothing about GitHub.
ashita-planning/laravel-error-monitor log analysis, aggregation, contracts
ashita-planning/laravel-error-monitor-xserver XServer stored log retrieval
ashita-planning/laravel-error-monitor-github GitHub Issue publishing ← this one
Requirements
- PHP 8.2 or newer
- Laravel 10, 11, or 12
ashita-planning/laravel-error-monitor
Installation
composer require ashita-planning/laravel-error-monitor-github
php artisan vendor:publish --provider="Apkk\LaravelErrorMonitorGithub\GithubErrorMonitorServiceProvider" --tag=error-monitor-github-config
ERROR_MONITOR_GITHUB_ENABLED=true ERROR_MONITOR_GITHUB_REPOSITORY=acme/shop ERROR_MONITOR_GITHUB_TOKEN=github_pat_...
Check the wiring before relying on it — this makes no network request:
php artisan error-monitor:github-status
And once, to confirm the token and repository actually work:
php artisan error-monitor:github-status --check-connection
From then on the core's daily command publishes as part of its run:
php artisan error-monitor:run
Token permissions
A fine-grained personal access token needs exactly one repository permission:
| Permission | Access | Why |
|---|---|---|
| Issues | Read and write | Search, create, comment, reopen, label |
Nothing else. No repo scope, no code access, no workflow permission. If you
are using a classic token, public_repo is enough for a public repository and
repo is the narrowest option GitHub offers for a private one — which is one
more reason to prefer a fine-grained token.
What happens, and when
| Situation | Action | action |
|---|---|---|
| Failure not tracked anywhere | Open an issue | created |
| Already reported today, unchanged | Nothing at all | skipped |
| Open issue, new day (or the day got worse) | Add a comment | commented |
| Closed issue, failure returned | Reopen, label, comment | reopened |
| Could not be settled safely | Nothing recorded | failed |
A day "gets worse" when its occurrence count or last occurrence moves, which changes the report hash and makes it worth saying again.
How duplicates are prevented
Two independent records, because either one alone has a blind spot.
The database link (error_monitor_issues, owned by the core) is checked
first. It is cheap and usually right, but it can be behind: a previous run may
have created an issue and then lost the response before recording anything.
HTML markers in the issue itself are the fallback, and the final authority:
<!-- error-monitor-fingerprint:{fingerprint} --> <!-- error-monitor-environment:{environment} --> <!-- error-monitor-provider:github --> <!-- error-monitor-target:{owner/repository} -->
and on every daily comment:
<!-- error-monitor-event:{fingerprint}:{date}:{report_hash} -->
They are invisible to readers, survive edits to the visible text, and are matched exactly — a body containing the fingerprint as a substring is not a match.
The order is always:
database link → GitHub search → GitHub issue list → create
Search API vs. issue list
Both are used, for different jobs.
The Search API is fast and is tried first, but it is eventually consistent: an issue created seconds ago may not be indexed yet.
The repository issue list (state=all, newest first, bounded to three
pages) reads the repository directly and sees an issue the moment it exists.
That distinction matters most after a lost write, where "not indexed yet" and "never created" would look identical through the search index — which is exactly the confusion that produces a duplicate. Recovery therefore never uses the search index at all.
Lost writes
A write whose response never arrived may well have succeeded, so it is never simply sent again:
| Write | On ConnectionException |
|---|---|
| Create issue | Scan the issue list for the fingerprint marker (up to 3 looks, short waits between). Found → treat as created. Not found → retry the POST, within the attempt limit. |
| Add comment | Re-read the comments and look for the daily event marker. Found → treat as commented. Not found → retry. |
| Reopen | Re-read the issue state. Already open → treat as reopened. Still closed → retry the PATCH. |
| Add label | Re-read the issue labels. Already present → done. |
Concurrency is handled separately. GitHub's creation endpoint takes no
idempotency key, so two workers that both look, both find nothing and both
create would produce two issues — no amount of searching closes a gap that
exists between the search and the write. Looking and creating therefore happen
inside a distributed cache lock keyed on
error-monitor-github:{repository}:{environment}:{fingerprint}. A worker that
cannot take the lock does nothing and makes no request at all.
If two issues carry the same fingerprint marker, the publication fails rather than choosing one — picking either would quietly orphan the other.
Retrying
The rule is whether GitHub answered.
Reads (GET) are retried on 429, 5xx, a secondary-rate-limit 403, and
connection failures.
Writes (POST, PATCH) are retried by the client only when GitHub actually
answered — a 429 or a 5xx means the request was processed and refused. A
connection failure is handed back to the caller instead, which checks the side
effect first.
| Response | Behaviour |
|---|---|
| 401 | Fails immediately. A rejected token stays rejected. |
| 403 (permission) | Fails immediately. |
| 403 (secondary rate limit) | Waits at least 60 seconds, then retries. Detected from the message, since GitHub does not always send Retry-After or zero the remaining count. |
403 with x-ratelimit-remaining: 0 |
Waits until x-ratelimit-reset. |
| 404 | Fails immediately — the repository or issue is not visible to this token. |
| 422 | Fails immediately. The same body would be rejected the same way. |
| 429 | Honours Retry-After, in seconds or as an HTTP-date. |
| 500 / 502 / 503 / 504 | Exponential backoff — 500ms, 1s, 2s… capped by retry.max_delay_ms. |
retry.max_wait_ms (default 60s) is a hard ceiling on every wait, including
Retry-After. Asked to wait longer, this package stops and reports a failure
instead of sleeping: a scheduled run that appears hung is harder to reason about
than one that failed, and the next run is minutes away.
An unparseable Retry-After falls back to the bounded backoff rather than
becoming an unbounded wait.
The regression label
Reopening the issue and posting the recurrence comment are required for a publication to succeed. The
regressionlabel is supporting information: if a transient GitHub API failure prevents it from being added, the recurrence comment is still posted. The outcome is recorded in the publication metadata (regression_label_added) and as a warning in the log.
This is deliberate. Failing the whole publication over a label would leave the state worse rather than better: the next run would find an issue that is already open with the day's comment already posted, and would never return to the labelling path.
Configuration
| Key | Environment variable | Default |
|---|---|---|
enabled |
ERROR_MONITOR_GITHUB_ENABLED |
false |
repository |
ERROR_MONITOR_GITHUB_REPOSITORY |
– |
token |
ERROR_MONITOR_GITHUB_TOKEN |
– |
api_url |
ERROR_MONITOR_GITHUB_API_URL |
https://api.github.com |
labels.default |
ERROR_MONITOR_GITHUB_LABELS |
laravel-500,auto-detected,ai-fix |
labels.regression |
ERROR_MONITOR_GITHUB_REGRESSION_LABEL |
regression |
retry.attempts |
ERROR_MONITOR_GITHUB_RETRY_ATTEMPTS |
3 |
retry.base_delay_ms |
ERROR_MONITOR_GITHUB_RETRY_BASE_DELAY_MS |
500 |
retry.max_delay_ms |
ERROR_MONITOR_GITHUB_RETRY_MAX_DELAY_MS |
5000 |
retry.max_wait_ms |
ERROR_MONITOR_GITHUB_RETRY_MAX_WAIT_MS |
60000 |
timeouts.connect_seconds |
ERROR_MONITOR_GITHUB_CONNECT_TIMEOUT |
10 |
timeouts.request_seconds |
ERROR_MONITOR_GITHUB_REQUEST_TIMEOUT |
30 |
lock_seconds |
ERROR_MONITOR_GITHUB_LOCK_SECONDS |
60 |
issue.title_prefix |
ERROR_MONITOR_GITHUB_TITLE_PREFIX |
[Laravel Error] |
issue.include_context |
ERROR_MONITOR_GITHUB_INCLUDE_CONTEXT |
false |
issue.include_metadata |
ERROR_MONITOR_GITHUB_INCLUDE_METADATA |
true |
include_context is off by default. The context is masked, but an issue is read
by more people than a database is, and none of them need the request detail to
know what broke.
GitHub Enterprise Server
Point api_url at your instance's API root:
ERROR_MONITOR_GITHUB_API_URL=https://github.example.com/api/v3
Nothing else changes. The adapter uses only the REST endpoints that Enterprise Server provides.
One repository per environment
The link is unique on (provider, environment, fingerprint, target), so the
same failure in production and staging gets its own issue, and the same
failure filed at two repositories gets one per repository.
Do not point two applications at one repository unless you want their issues
merged. Two applications sharing a repository and an environment name will
share issues for any failure whose fingerprint matches — which is occasionally
what you want, and usually is not. Give them different environment values, or
different repositories.
Scheduling
The core's daily command does the publishing:
use Illuminate\Support\Facades\Schedule; Schedule::command('error-monitor:run') ->dailyAt('05:00') ->onOneServer() ->withoutOverlapping();
Two things are worth knowing:
- Use a shared cache store (Redis, Memcached, or the database driver) if more than one machine can run the schedule. The publication lock is a cache lock; a per-machine store cannot serialise across machines. Without a lock provider the package still publishes — it does not refuse to work — but the create-race protection is gone.
--skip-githubsuppresses publishing for a run without touching the analysis, which is useful when backfilling.
Testing
composer update
composer check # Pint, PHPStan and PHPUnit
Every test runs against Http::fake(). Nothing in the suite reaches GitHub, and
CI has no token.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Nothing is published, no errors | enabled is false, or no token. Run error-monitor:github-status. |
GitHub answered 404 |
The repository does not exist, or the token cannot see it. Check repository and the token's repository access. |
GitHub refused the request (403) |
The token lacks Issues: read and write on that repository. |
Several GitHub issues carry this fingerprint |
Two issues have the same fingerprint marker. Close or edit one; this is deliberately not resolved automatically. |
| Publication fails with a rate limit | A Retry-After longer than retry.max_wait_ms. Raise it, or let the next run handle it. |
| Issues appear twice | Check that the cache store provides locks across all machines running the schedule. |
Current limitations
- Issues are searched within a bounded window: the search index, then the three most recent pages of the issue list. A fingerprint whose issue is older than that and missing from the search index would not be found.
- One repository per application per environment. Cross-repository routing — filing by source, or by domain — is not implemented.
- Pull request linking (
recordPullRequeston the core's repository) is not driven by this package yet. - Closing an issue when a failure stops occurring is not implemented; issues are reopened but never closed automatically.
License
MIT.