KD-0772: ship kendo-error-tracker PHP client library v0.1.0#1
Conversation
Greenfield Laravel client library that reports scrubbed exceptions into
kendo's error-events endpoint (KD-0771). Scaffold mirrors the sister
package phpstan-warroom-rules (Composer package, Pest/PHPStan/Pint,
ci.yml + release.yml, public packagist push-sync) adapted to a runtime
library (type: library, ServiceProvider auto-discovery).
- ErrorTracker::report(\Throwable): swallow-on-failure public surface.
- ReportErrorJob: async dispatch, 0 retries, error_log on fail, no requeue;
opt-in sync mode (error-tracker.sync). Scrubbing runs synchronously
before the queue boundary so no raw PII is serialized.
- Scrubber: redacts JWT / Bearer / BSN / email.
- PathNormalizer: exact base_path() strip per frame (mirrors
laravel/nightwatch Location::normalizeFile()) for host-stable fingerprints.
- POST {kendo_url}/api/projects/{project}/error-events (project = id,
Bearer auth, body {environment, release?, exception_class, message,
stack_trace}); 202 = success, all failures (401/403/422/5xx/timeout/
unreachable) swallowed.
- Pest tests: Scrubber, PathNormalizer, ReportTest (HTTP::fake), SwallowTest
(every failure mode). composer test / phpstan (level max) / format:check green.
- README, CHANGELOG, CLAUDE.md.
Closes KD-0772
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR Reviewer · claimed
|
PR Reviewer · 6/10 · REVISE — 🔴 2 🟡 3kendo-error-tracker #1 · AC anchor: kendo-issue KD-0772 🔴 MAJOR
why + fixsend() builds the request as ->withToken()->acceptJson()->asJson()->post() (ErrorTracker.php:75-79) with no ->timeout()/->connectTimeout(). It therefore inherits Laravel's 30s request / 10s connect default. The kendo cross-project convention pinned in repos/kendo/backend/tests/Arch/ExternalHttpTimeoutTest.php (Doctrine Principle #8 / ADR-0024 territory: 'All outbound HTTP requests to external services must have an explicit timeout set. No relying on PHP/framework defaults. Per-call timeouts are preferred over a global default') requires an explicit value. A 30s default block contradicts this library's stated 'never blocks the caller' guarantee in sync mode and pins a queue worker for 30s in async mode. The README (L125) and the send() docblock (ErrorTracker.php:61) both advertise 'HTTP timeout' as a swallowed failure mode, but as written that mode is only reachable via a network-level reset (ConnectionException) — a slow-but-connected kendo server will hold the caller for the full 30s default rather than failing fast. This is the 'missing timeout citation' MAJOR named in the Row 2 rubric. Not a BLOCKER only because Laravel does bound the default at 30s rather than leaving it unbounded. Fix: Add a short, config-tunable ->timeout(...)->connectTimeout(...) to the request chain in send() (a few seconds suits fire-and-forget telemetry), exposed as an error-tracker.timeout config key with an env default.
why + fixsend() builds the request as Fix: Set an explicit tight timeout on construction of the request, e.g. 🟡 MINOR
why + fixLine 105 reads Fix: Optional only:
why + fixRow 6 / ADR-0021 enforcement-ladder gap. The package introduces the structural rule 'the outbound HTTP client declares a timeout' but nothing enforces it at CI time — the kendo precedent (ExternalHttpTimeoutTest.php) is exactly the L1 transplant that is missing. Same root cause as the Row 2 MAJOR; recorded once here as the enforcement-level gap, not double-counted in severity. Without it a future send-path method can silently regress the timeout convention. Fix: Add a small Pest arch test asserting src/ErrorTracker.php contains '->timeout(', mirroring kendo's ExternalHttpTimeoutTest named-class whitelist (L1 on the Escalation Ladder).
why + fixconfigString() narrows any non-scalar/null config to '' (line 128). With kendo_url/project/token unset (the test 'does not throw when configuration is entirely missing' exercises exactly this), report() builds a POST to Fix: Emit one distinct error_log line when a required key (kendo_url/project/token) is empty at send time — e.g. Actionrevise — address MAJORs |
The send() POST relied on the framework HTTP default (no timeout), so a hung kendo host could block the caller — unacceptable for fire-and-forget telemetry reported from inside an exception handler. Add an explicit connect + total timeout to the request chain (config-tunable: connect_timeout default 2s, timeout default 5s; env ERROR_TRACKER_CONNECT_TIMEOUT / ERROR_TRACKER_TIMEOUT), read with the same numeric-narrowing discipline used for the string config. Add tests/Unit/HttpTimeoutTest.php mirroring kendo's tests/Arch/ExternalHttpTimeoutTest.php (Doctrine Principle #8) — a named-list arch test asserting every external-HTTP class declares an explicit timeout, so the convention is pinned at CI time. Document the two keys in the README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…onfigured When kendo_url, project, or token is empty at send time, emit one distinct '[kendo-error-tracker] not configured: missing kendo_url/project/token; report dropped' line and short-circuit before the HTTP call — so a silently misconfigured deploy is visible in the log instead of producing opaque request failures. Swallow-on-failure is intact (no throw, no block). Extend SwallowTest: the missing-config test now asserts no HTTP call is made (Http::assertNothingSent) and a per-key dataset covers each required key alone; add a hung-host case asserting send() returns promptly and swallows on a timeout-flavoured ConnectionException. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR Reviewer · claimed
|
PR Reviewer · 9/10 · PASS — 🟡 3kendo-error-tracker #1 · AC anchor: kendo-issue KD-0772 🟡 MINOR
why + fix
Fix: Read once:
why + fixThe scrubber is an explicit denylist covering exactly the four named contract classes — JWT, Bearer, BSN, email — and all four are implemented correctly and tested (ScrubberTest.php). The named PII contract from KD-0772 is therefore MET, which is why this is MINOR/informational, not a defect. Two scope notes for the reader, not blockers: (1) the docstring and CHANGELOG honestly disclose this as a 'v1 pattern set [covering] the leaks seen most often' — other secret classes (DB-connection passwords e.g. 'password=...', non-Bearer API keys, credit cards) fall outside the stated scope and are not redacted; (2) the exception_class field (ErrorTracker.php:129, $throwable::class) bypasses scrub() entirely — safe today because it is always a PHP FQCN that cannot carry PII, but if a future change ever routes a non-class string into that field it would leak unscrubbed. getTraceAsString() truncates scalar args, so trace-arg leakage is bounded and any surviving JWT/email/BSN run is still caught. The summary's own conclusion — named contract satisfied, limitation disclosed — is why severity is MINOR, not MAJOR. Fix: No change required for merge — the named contract is met. Optional hardening for a later iteration: add an arch-test or doc note pinning that exception_class must remain class-name-only, and track the denylist's known-out-of-scope classes (DB passwords / generic api-key= forms) as a follow-up so the v2 pattern set is a deliberate decision rather than a silent gap.
why + fixbuildPayload() scrubs BOTH the message and the path-normalized stack trace (src/ErrorTracker.php:119-122), but every PII test (ScrubberTest, ReportTest 'scrubs the message') exercises only the message string. The stack trace is the higher-risk leak surface — getTraceAsString() can embed method arguments, and the trace is scrubbed AFTER PathNormalizer runs, so the scrub-on-normalized-string ordering is untested. The code path is correct; only its coverage is thin. Not a silent failure — the scrubbing genuinely runs — so MINOR. Falsifier that would have flipped this to OK: a test planting a BSN/JWT into a Throwable trace (or feeding a normalized trace through buildPayload) and asserting the outbound stack_trace carries the [REDACTED:*] marker. Fix: Add a Pest case that throws an exception whose trace would contain a scrubable token and asserts the outbound stack_trace key contains [REDACTED:*] and not the raw value — closing the gap between 'message is scrubbed' coverage and the equally-load-bearing trace path. Actionmerge-ready ⚠ Approval withheld — self-authored (GitHub forbids self-approval) |
The trace IS scrubbed in buildPayload() (normalize -> scrub), but only the message scrub was covered. Add a ReportTest case asserting the POSTed stack_trace has scrubable tokens redacted. Construction guards against a false-green: getTraceAsString() elides argument values + the exception message, so a token planted as an arg/message never reaches the pre-scrub trace string and the assertion would pass trivially. getTraceAsString() DOES embed each frame's file path, so the fixture lives at a path carrying a BSN-shaped token (tests/Fixtures/trace-secret-123456789/). The test asserts the token is present pre-scrub (the guard) and [REDACTED:bsn] / raw-value-absent in the captured request body. Verified the test fails when the trace scrub is removed (not a false-green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes KD-0772
Greenfield Laravel client library that reports scrubbed exceptions into kendo's error-events endpoint (KD-0771, merged). Scaffold mirrors the sister package
phpstan-warroom-rules, adapted to a runtime library (type: library, ServiceProvider auto-discovery, illuminate/* ^11–13, Pest).Server contract (confirmed against KD-0771 PR #1408, merged to kendo
main)POST /api/projects/{project}/error-events—{project}route-key is the id (nogetRouteKeyNameoverride on the kendoProjectmodel).{environment, release?, exception_class, message, stack_trace}(matchesStoreErrorEventRequest::rules()+IngestErrorEventData).error-events:writeability.ErrorEventController::ingest). 401/403/422/5xx/timeout/unreachable all swallowed.Acceptance criteria
report($throwable)POSTs to{kendo_url}/api/projects/{project}/error-eventswith Bearer token +{environment, release?, exception_class, message, stack_trace}(scrubbed). No territory/context fields.error_logand does not throw/block.ReportErrorJob; sync mode POSTs inline; both swallow failure.base_path()strip yields identical normalized frames for/var/www/html/...and/home/forge/...(mirrors nightwatchLocation::normalizeFile()).composer test/composer phpstan/composer format:checkgreen (this PR's CI is the acceptance gate).What's built
ServiceProvider (auto-discovery) →
ErrorTracker(swallow-on-failure; scrubbing+normalization synchronous, before the queue boundary),ReportErrorJob(0 retries,error_logon fail, never requeue),Scrubber,PathNormalizer, publishableconfig/error-tracker.php(ERROR_TRACKER_*, noterritory), README (install/auto-discovery/one-call integration/scrub list/config ref/token minting), CHANGELOG, CLAUDE.md. CI (ci.ymlPR+push main) +release.yml(tag→GitHub release; packagist push-sync).Verification (local)
composer test→ 26 passed (52 assertions):ScrubberTest,PathNormalizerTest,ReportTest(HTTP::fake),SwallowTest(every failure mode).composer phpstan→ level max, no errors.composer format:check→ passed (Pint, canonical war-room config).composer audit→ no advisories.Deviations from kendo / warroom
Configrepository (not a constructor snapshot) so a singleton bound atregister()always sees current config — needed because the consuming app sets config after provider registration.ErrorTracker::configString()narrows the repository'smixedreturn for PHPStan level max (the package does not pull Larastan).🤖 Generated with Claude Code