diff --git a/.ai/guidelines/core.blade.php b/.ai/guidelines/core.blade.php new file mode 100644 index 0000000..93181b3 --- /dev/null +++ b/.ai/guidelines/core.blade.php @@ -0,0 +1,72 @@ +## Kirschbaum Monitor: control points + +This application uses `kirschbaum-development/monitor`. A **control point** is an operation whose failure matters: charging a card, filing with an external system, posting a ledger entry, sending an email a person is waiting for, calling any third-party API. Control points are declared, not improvised. + +### When an operation is critical + +Wrap it in a control point when a failure would lose money, lose data, break a promise to a user, or leave an external system in a different state from ours. When in doubt, it is critical. + +### How to declare one + +Create a class under `App\ControlPoints\{Domain}` with `php artisan make:control-point {Domain}/{Name}`. The domain is the business area (Payments, Filings, Notifications); it becomes the `domain` field on every record. + +```php +#[Point('payment.charge', profile: 'external')] +final class ChargeCard extends ControlPoint +{ + public function __construct(private readonly Invoice $invoice) {} + + protected function control(Control $control): void + { + $control + ->recover(CardDeclined::class, fn (CardDeclined $e) => ChargeResult::declined($e->code)) + ->ensure(fn (ChargeResult $r): bool => $r->settled, 'charge must be settled') + ->escalate(PagePayments::class); + } + + public function context(): array { return ['invoice' => $this->invoice->id]; } + + public function handle(StripeClient $stripe): ChargeResult { return $stripe->charge($this->invoice); } +} + +ChargeCard::run($invoice); // value, or throws what escaped +ChargeCard::attempt($invoice); // Outcome: ->status, ->value, ->exception +``` + +Use `attempt()` when the caller branches on the outcome and `run()` when it wants the value. Points nest: a child's escalation reaches the parent's `recover()`, and retries never compose across the stack. + +An operation that moves money or files with an external system also declares `->once('invoice:'.$id)` with an idempotency key, so a retried job or a double submit is refused with a `Duplicate` risk instead of running twice. To run a point on the queue, `ChargeCard::dispatch($invoice)`: the queue keeps its retries, the point keeps its policies, and a run refused by an open breaker releases the job for the breaker's retry-after. For an outbound call that does not deserve a point, `Http::breaker('stripe')->post(...)` puts the same circuit on the request. + +Rules that `php artisan monitor:points --check` enforces: + +- Names are dotted lowercase: `domain.operation`, unique across the app. +- Every point declares `escalate()` (an `Escalation` class or a closure) unless it recovers from `Throwable` deliberately. +- `control()` must not read constructor arguments; the inventory calls it without them. +- Profiles set sensible defaults: `external` for outbound HTTP, `database` for transactional writes, `messaging` for queues and email, `internal` otherwise. Declare on the point anything that differs. + +### Risks and corrections + +`recover(SomeException::class, fn ($e, Outcome $partial) => $value)` declares an expected failure and what to return instead; a class implementing `Kirschbaum\Monitor\Contracts\Correction` can stand in for the closure. The handler's return value **is** the result, `null` included. To escalate from a handler, throw. Do not use `try/catch` around the operation for expected failures; declare them. `escalateLimits()` also escalates a slow success; `throttleEscalation(600)` pages at most once per window during an outage. + +### Limits + +`within(seconds)` records a duration breach and never fails a completed run. `attempts(n)` caps retries. `ensure(fn ($result): bool, 'reason')` fails the run with `EnsureFailed` when the result is wrong even though the call "succeeded". + +### Testing a point + +```php +Monitor::fake(); // records every outcome, still runs the code +Monitor::fake()->failing('payment.charge', new CardDeclined('do_not_honor')); +Monitor::fake()->returning('payment.charge', ChargeResult::declined()); + +Monitor::assertSucceeded('payment.charge'); +Monitor::assertRecovered('payment.charge', from: CardDeclined::class); +Monitor::assertEscalated('payment.charge', with: ConnectionException::class); +Monitor::assertRetried('payment.charge', times: 2); +Monitor::assertNotEscalated('payment.charge'); +Monitor::assertNothingEscalated(); +``` + +### Reading what happened + +`php artisan monitor:points` lists every control point and its contract. `php artisan monitor:explain payment.charge` describes one. `php artisan monitor:outcomes --since=1h --status=escalated` lists recent outcomes when the store is on. Records in the log carry `point`, `domain`, `status`, `run_id` and `trace_id` as fields. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..945eef9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +* text=auto eol=lf + +# Keep development-only material out of the distributed package. +/.github export-ignore +/.githooks export-ignore +/scripts export-ignore +/tests export-ignore +/workbench export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/pint.json export-ignore +/testbench.yaml export-ignore + +# Diff/linguist hints +*.php diff=php +/tests/** linguist-vendored diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..07b0ad9 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,64 @@ +#!/bin/bash +# +# Enforce single-line commit messages with no attribution trailers. +# +# Repository reporting and per-developer exposure coverage are derived from git +# history. Co-author trailers split authorship across two identities and skew +# those reports; multi-line bodies are noise the parser has to strip. The "why" +# belongs in the PR description, the CHANGELOG, or a comment at the code site, +# where it stays readable. +# +# Installed by `composer setup-hooks`, which points core.hooksPath at .githooks. + +MSG_FILE="$1" +SOURCE="$2" + +# Merges, squashes and reverts generate bodies git wrote itself. +case "$SOURCE" in + merge|squash) exit 0 ;; +esac + +# Strip comments and trailing blank lines; that is what git will actually store. +BODY="$(grep -v '^#' "$MSG_FILE" | sed -e :a -e '/^\s*$/{$d;N;ba' -e '}')" + +if [[ -z "${BODY//[[:space:]]/}" ]]; then + # An empty message aborts the commit anyway; let git say so. + exit 0 +fi + +FIRST_LINE="$(printf '%s\n' "$BODY" | head -n 1)" + +case "$FIRST_LINE" in + Revert\ \"*) exit 0 ;; +esac + +fail() { + echo "" + echo "🚫 Commit rejected: $1" + echo "" + echo " Commit messages must be a single line, with no body and no trailers." + echo " Put the reasoning in the PR description, the CHANGELOG, or a code comment." + echo "" + echo " Got:" + printf '%s\n' "$BODY" | sed 's/^/ | /' + echo "" + exit 1 +} + +if printf '%s\n' "$BODY" | grep -qiE '^[[:space:]]*(co-authored-by|claude-session|signed-off-by[[:space:]]*:[[:space:]]*claude)'; then + fail "attribution trailers break authorship reporting." +fi + +if printf '%s\n' "$BODY" | grep -qiE 'claude\.ai/code/session'; then + fail "session links do not belong in git history." +fi + +if [[ "$(printf '%s\n' "$BODY" | wc -l | tr -d ' ')" -gt 1 ]]; then + fail "the message has more than one line." +fi + +if [[ ${#FIRST_LINE} -gt 72 ]]; then + fail "the subject is ${#FIRST_LINE} characters; keep it to 72." +fi + +exit 0 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 30c8a49..d159e42 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,4 +9,10 @@ updates: schedule: interval: "weekly" labels: - - "dependencies" \ No newline at end of file + - "dependencies" + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/.github/workflows/automerge-dependabot.yml b/.github/workflows/automerge-dependabot.yml index 5e04c8d..66ee3ff 100644 --- a/.github/workflows/automerge-dependabot.yml +++ b/.github/workflows/automerge-dependabot.yml @@ -14,7 +14,7 @@ jobs: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v2.5.0 + uses: dependabot/fetch-metadata@v3.1.0 with: github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/changelog-update.yml b/.github/workflows/changelog-update.yml index 2dd0e15..303437a 100644 --- a/.github/workflows/changelog-update.yml +++ b/.github/workflows/changelog-update.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: main diff --git a/.github/workflows/php-tests.yml b/.github/workflows/php-tests.yml index 43f47b0..5ca9338 100644 --- a/.github/workflows/php-tests.yml +++ b/.github/workflows/php-tests.yml @@ -10,25 +10,24 @@ jobs: test: runs-on: ${{ matrix.os }} strategy: - fail-fast: true + # One unusable matrix leg should not cancel the other nine. + fail-fast: false matrix: os: [ubuntu-latest] - php: [8.3, 8.4] - laravel: [11.*, 12.*] + php: ['8.3', '8.4', '8.5'] + laravel: [12.*, 13.*] stability: [prefer-lowest, prefer-stable] include: - - laravel: 11.* - testbench: ^9.9 - carbon: ^2.63 - laravel: 12.* testbench: 10.* - carbon: ^2.63|^3.0 + - laravel: 13.* + testbench: 11.* name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -42,13 +41,40 @@ jobs: echo "::add-matcher::${{ runner.tool_cache }}/php.json" echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + # Carbon is not pinned here: the package only reaches it through + # Laravel's helpers, so whatever the framework resolves is the version + # worth testing against. - name: Install dependencies run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update composer update --${{ matrix.stability }} --prefer-dist --no-interaction - name: List Installed Dependencies run: composer show -D - name: Execute tests - run: vendor/bin/pest --ci --bail --compact --memory --coverage \ No newline at end of file + run: vendor/bin/pest --ci --memory --coverage --min=100 + + performance: + name: performance guards + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, intl, fileinfo + # Explicitly no coverage: instrumentation dominates the clock and + # flattens the difference between a fast and a slow implementation, + # so these assertions skip themselves when a driver is active. + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v4 + + - name: Run performance guards + run: vendor/bin/pest --testsuite=Performance --ci diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..b92fa11 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,60 @@ +name: Security + +# SAST and dependency advisories, on a schedule as well as on push so an +# advisory published while nobody is committing is still caught. +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '30 5 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + semgrep: + name: Semgrep SAST + runs-on: ubuntu-latest + timeout-minutes: 10 + container: + image: semgrep/semgrep + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + # Test fixtures use a Stripe-shaped placeholder to prove redaction works. + # That rule is excluded rather than the files, so every other secret + # rule still runs over them. + - name: Semgrep scan + run: > + semgrep scan + --config p/php + --config p/secrets + --exclude-rule generic.secrets.security.detected-stripe-api-key.detected-stripe-api-key + --error + --text + + audit: + name: Dependency advisories + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Composer audit + run: composer audit diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 2abf291..00e15fc 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -4,9 +4,17 @@ on: push: paths: - '**.php' - - 'composer.lock' + - 'composer.json' - 'phpstan.neon.dist' - - '.github/workflows/phpstan.yml' + - 'rector.php' + - '.github/workflows/static-analysis.yml' + pull_request: + paths: + - '**.php' + - 'composer.json' + - 'phpstan.neon.dist' + - 'rector.php' + - '.github/workflows/static-analysis.yml' jobs: phpstan: @@ -14,17 +22,20 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: '8.5' extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo, swoole, openssl coverage: none - name: Install composer dependencies - uses: ramsey/composer-install@v3 + uses: ramsey/composer-install@v4 + + - name: Run Rector (dry run) + run: ./vendor/bin/rector process --dry-run --no-progress-bar - name: Run PHPStan - run: ./vendor/bin/phpstan --error-format=github \ No newline at end of file + run: ./vendor/bin/phpstan analyse --error-format=github --no-progress --memory-limit=1G \ No newline at end of file diff --git a/.github/workflows/style-check.yml b/.github/workflows/style-check.yml index 7354fe9..2d0d61c 100644 --- a/.github/workflows/style-check.yml +++ b/.github/workflows/style-check.yml @@ -2,6 +2,7 @@ name: Code Style on: workflow_dispatch: + pull_request: push: branches-ignore: - 'dependabot/npm_and_yarn/*' @@ -14,13 +15,10 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: '8.5' - name: Checkout - uses: actions/checkout@v6 - - - name: Copy .env - run: php -r "file_exists('.env') || copy('.env.example', '.env');" + uses: actions/checkout@v7 - name: Install Dependencies run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist diff --git a/CHANGELOG.md b/CHANGELOG.md index 6361e43..2953b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,222 @@ # Changelog All notable changes to this project will be documented in this file. + +## v1.0.0 - 2026-09-14 + +A rewrite around the control point, designed from the problem rather than +from 0.1. There is no compatibility layer; [docs/upgrading.md](docs/upgrading.md) +lists every 0.1 surface and its replacement. + +### Added - the control point + +- **Two forms.** `Monitor::control('payment.charge', $this)` declares a point + inline; a class extending `ControlPoint` with a `#[Point]` attribute declares + one that the inventory can read in full, the stub can generate, and an agent + can be sent to. Both produce the same `Outcome`. +- **Two terminals.** `run()` returns the value or throws what escaped; + `attempt()` returns the `Outcome` and never throws for what the operation did. +- **Risks and corrections.** `recover(Class, fn)` declares an expected failure; + the handler's return value is the result, `null`, `false` and `0` included. + A handler that throws escalates. First declared match wins. +- **Escalation.** `escalate()` with a closure or an `Escalation` class, called + for every failure no correction covers; an escalation that throws is recorded + as `EscalationFailed` and the original exception still propagates. +- **Limits.** `within()` records a duration breach and never fails a completed + run; `attempts()` caps retries whatever the policies ask; `ensure()` fails a + run whose result is wrong, after every policy, so it is never retried. +- **Policies.** `Retry` with backoff, multiplier and jitter; `Transaction` + retrying deadlocks as whole transactions; `Breaker` on a named circuit. A + fixed pipeline order (breaker, retry, transaction) so a breaker sees one + failure per run. Custom policies implement one interface. +- **Profiles.** `external`, `database`, `messaging` and `internal` bundle + policies and limits from config; a point starts from one and overrides what + it declares. +- **Nesting.** Points nest to any depth with a parent run id and the stack on + every record. A child's escalation reaches the parent's corrections, a + child's refusal surfaces as `BreakerOpen`, and retries never compose across + the stack. +- **Outcome.** One readonly object per run: status, value, exception, the risk + recovered from, attempts, duration, limits breached, policies, context, + timeline and stack. + +### Added - records + +- **One schema for every transition** (`monitor/1`, published at + `resources/schema/record-1.json`): `point`, `run_id`, `parent_run_id`, + `trace_id`, `domain`, `origin`, `status`, `attempts`, `duration_ms` and the + rest as log context fields, with a readable message. Levels per event are + configurable. +- **Domain as a field.** Derived from the origin's namespace through a + configurable map, so "escalations per domain" is a query rather than a regex. +- **Redaction through Redactor.** Context and exception messages pass through + the `observability` profile; identifiers are left alone so records stay + joinable. +- **Events first.** `PointStarted`, `PointRetried`, `PointLimitBreached`, + `PointRecovered`, `PointEscalated`, `PointRefused`, `PointEnded`, + `EscalationFailed`, `BreakerOpened`, `BreakerHalfOpen`, `BreakerClosed`. + Log lines and the store are listeners. +- **NDJSON.** `JsonTap` and `RecordFormatter` write one JSON object per line + with the record fields at the top level, and never lose a line to bad bytes. +- **`Monitor::log($origin)`** is a PSR-3 logger bound to an origin, adding + `origin` and `domain` to every record it writes. + +### Added - tracing + +- **Laravel Context.** The trace id and the current point live in Context, so + they reach queued jobs and every log line the application writes. Monitor + keeps no request state of its own. +- **W3C traceparent** read and written by `StartTrace`, alongside a legacy + header; incoming values are validated and an invalid one is replaced. +- **`Http::traced()`** adds both headers to an outgoing request. +- **Jobs and console** pick up or start a trace on their own. + +### Added - breakers + +- **A real state machine**: closed, open, half-open with a single probe, kept + in the cache so every process sees the same circuit. "after" failures + "within" seconds open it "for" seconds. +- **Standalone API** through `Monitor::breaker()` and the `CheckBreakers` + route middleware with a real `Retry-After`. + +### Added - store + +- **`monitor_outcomes`**, off by default. Rows are written after the response + or the job, never on the request path; a failing write is logged once and + never reaches a control point. `monitor:outcomes` and `monitor:prune`. + +### Added - verification + +- **`Monitor::fake()`** records every outcome while still running the point, + cans values with `returning()` and failures with `failing()`, and asserts + with `assertRan`, `assertSucceeded`, `assertRecovered`, `assertEscalated`, + `assertRefused`, `assertRetried`, `assertLimitBreached`, + `assertNothingEscalated` and friends. +- **`monitor:points`** inventories every point without running it, class-form + points in full and inline points by name, with table, JSON and SARIF + output. `--check` fails the build on duplicate names, names off the pattern, + a point with no escalation and no catch-all, `control()` that cannot be read + statically, a class in a critical namespace that is not a control point, + and warns on catch-alls without escalation and computed names. +- **`monitor:explain`** describes one point's contract in prose. +- **`make:control-point`** generates the class form and a test that already + uses the fake. +- **A Pest expectation** (`toBeControlled()`, `toHaveCompleteControlPoints()`) + and **a PHPStan rule** (`monitor.uncontrolled`) with the same semantics. +- **The fake follows the framework's.** `MonitorFake` implements the `Fake` + contract, `Monitor::fake()` is idempotent, `outcomes()` is a `Collection`, + and every positive assertion has its negative: `assertNotRan`, + `assertNotRecovered`, `assertNotEscalated`, `assertNotRefused`, + `assertNotRetried`, plus `assertRanOnce`. `assertRecovered` and + `assertEscalated` take a class or a closure. + +### Added - agents + +- **Guidelines** shipped at `.ai/guidelines/core.blade.php`, which Laravel + Boost composes into every consuming application's agent guidelines. +- **An MCP server** on `laravel/mcp`: `list_points`, `explain_point`, + `outcomes` and `escalations` tools, the record schema and guidelines as + resources, and a `wrap_operation` prompt. Read-only, off by default, every + response redacted JSON-aware. Registered from config, or by hand in + `routes/ai.php`. +- **A Boost skill** at `resources/boost/skills/monitor-development/SKILL.md`, + the path Laravel's own packages use, alongside the guideline. + +### Added - conventions, following Laravel's first-party packages + +- **Contracts live in `Contracts\`**: `Runner`, `Escalation`, `Limit`, + `Policy` and `Rule`. Commands live in `Console\Commands\`. +- **Services are open, value objects are `final readonly`.** `Outcome`, + `RunInfo`, `BreakerState`, `BreakerConfig`, `Decision`, `PointDescription`, + `Finding`, `ScannedFile`, the limits and `#[Point]` are immutable data; + everything else can be extended. `Outcome`, `BreakerState`, + `PointDescription` and `Finding` implement `Arrayable` and `JsonSerializable`. +- **`Control` is `Conditionable` and `Macroable`**, and `StructuredLogger` is + `Conditionable`, so a declaration can branch with `when()` and `unless()` and + an application can add methods of its own. +- **Point names accept a backed enum** wherever a name is passed, so an + application can centralise them. +- **`MonitorException` is an interface.** Each exception keeps its natural SPL + parent: the four `Invalid*` exceptions extend `InvalidArgumentException`, + the risks extend `RuntimeException`. Messages quote values in brackets. +- **`CircuitBreaker::attempt()` is `permit()`**, so it no longer shares a name + with `Control::attempt()` on the same facade. The policies drop their + `setTimes()` and `setRetries()` setters in favour of constructor arguments. +- **The provider does nothing at boot** beyond registering: commands and + publishing sit behind `runningInConsole()`, the migration is published with + `publishesMigrations()` and never auto-loaded, middleware aliases are + registered when the router resolves, the console trace starts on + `CommandStarting`, and `php artisan about` shows the package's settings. +- **The store flushes where first-party packages flush**: after each request + and command through the kernels, after each job, on `Queue::looping` and + worker stop, and when its buffer reaches 100, so it is safe under Octane and + in long-running commands. +- **Redaction degrades**: a Redactor failure inside the recorder or the MCP + server never reaches a control point. + +### Added - after the first review + +- **Idempotency.** `once('invoice:'.$id, ttl: 3600)` runs a point at most once + per key inside a window; a second run is refused before anything executes + with a `Duplicate` risk. The key is released when the run fails inside the + policies and kept when it completed, because the side effect has happened. + Outermost in the pipeline, so a refused run never claims a key. +- **A typed `run()`.** `Control::run()` carries a template, so PHPStan knows + the value a callback returns. +- **`Http::breaker('stripe')`** on the HTTP client, and on any `PendingRequest`: + the same circuit a control point uses, refusing without sending while open + and counting connection failures and 5xx responses. +- **Dispatchable points.** `ChargeCard::dispatch($invoice)` runs a control + point class as a queued job through `Queue\RunControlPoint`, named and + tagged after the point for Horizon; a run refused by an open breaker + releases the job for the breaker's retry-after instead of failing it. +- **Corrections as classes.** `recover(Class, Handler::class)` with a class + implementing `Contracts\Correction`, resolved from the container and named + in the inventory. +- **`escalateLimits()`** hands a run that completed but breached a limit to + the escalation, and **`throttleEscalation(seconds)`** lets at most one + escalation through per point per window, announcing the rest with + `EscalationThrottled`. +- **`Monitor::log()` with no origin** binds to the running control point, so + ad hoc lines inside a point carry its origin and domain. +- **`Outcome::$startedAt` and `$endedAt`**, written to the store's new + `started_at` column and to `ended_at` in place of the flush time. +- **Jobs start clean.** A job hands off the control stack it inherited through + Context and keeps the dispatching run as `dispatched_from_run`, so a point + inside a job has no parent from another process. The + `Queue\Middleware\WaitForBreaker` job middleware releases any job for a + breaker's retry-after while the circuit is open. + +### Changed + +- Requires PHP 8.3 to 8.5 and Laravel 12 or 13. Laravel 11 is no longer + supported. +- Environment variables are `MONITOR_STORE_ENABLED` and `MONITOR_MCP_ENABLED`. +- `kirschbaum-development/redactor` 1.x is a hard dependency. + +### Removed + +- `Monitor::controlled()`, `catching()`, `onUncaughtException()`, + `withCircuitBreaker()`, `withDatabaseTransaction()`, `overrideContext()`, + `addContext()`, `overrideTraceId()` and `NestedControlledBlockException`. +- `Monitor::time()`, `LogTimer`, `Monitor::redactor()`, the internal + `LogRedactor`, `OriginWrapper` and the origin prefix, separator and wrapper + settings. +- The `enabled`, `exception_trace`, `console_auto_trace`, `trace_header`, + `circuit_breaker` and `redactor` config keys. + +## v0.1.3 - 2025-07-08 + +- Switch to scoped container registration. + +## v0.1.2 - 2025-07-01 + +- Fix doubled context on custom taps. + +## v0.1.1 - 2025-06-19 + +- Use the published Redactor package. + +## v0.1.0 - 2025-06-17 + +- First release: controlled blocks, structured logging, trace ids, circuit breaker. diff --git a/LICENCE.md b/LICENSE.md similarity index 100% rename from LICENCE.md rename to LICENSE.md diff --git a/README.md b/README.md index 11265f1..bb25a69 100644 --- a/README.md +++ b/README.md @@ -1,654 +1,175 @@ -# Laravel Monitor +# Kirschbaum Monitor -![Laravel Supported Versions](https://img.shields.io/badge/laravel-10.x/11.x/12.x-green.svg) +![Laravel Supported Versions](https://img.shields.io/badge/laravel-12.x%20%7C%2013.x-green.svg) [![MIT Licensed](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) [![Latest Version on Packagist](https://img.shields.io/packagist/v/kirschbaum-development/monitor.svg?style=flat-square)](https://packagist.org/packages/kirschbaum-development/monitor) ![Application Testing](https://github.com/kirschbaum-development/monitor/actions/workflows/php-tests.yml/badge.svg) ![Static Analysis](https://github.com/kirschbaum-development/monitor/actions/workflows/static-analysis.yml/badge.svg) ![Code Style](https://github.com/kirschbaum-development/monitor/actions/workflows/style-check.yml/badge.svg) -Laravel Monitor is an observability helper / toolkit for Laravel applications. +Monitor gives a Laravel application **control points**: the operations where a failure matters, declared in code as a contract. A control point says what it is called, which domain it belongs to, which failures it expects and what to return instead, which policies bound it, which limits it should stay within, and who is told when something it did not expect gets out. Every run ends in one outcome, succeeded, recovered, escalated or refused, and every transition is written as a record with the same fields. -> This package is active development and its API can change abruptly without any notice. Please reach out if you plan to use it in a production environment. - -## Table of Contents - -- [Installation](#installation) -- [Components](#components) - - [Structured Logging](#structured-logging) - - [Controlled Execution Blocks](#controlled-execution-blocks) - - [Distributed Tracing](#distributed-tracing) - - [HTTP Middleware](#http-middleware) - - [Performance Timing](#performance-timing) - - [Circuit Breaker Direct Access](#circuit-breaker-direct-access) - - [Log Redactor Direct Access](#log-redactor-direct-access) - - [Log Redaction](#log-redaction) -- [Complete API Reference](#complete-api-reference) -- [Configuration](#configuration) -- [Output Examples](#output-examples) -- [Testing](#testing) -- [License](#license) - -## Installation - -Install via Composer: - -```bash -composer require kirschbaum-development/monitor -``` - -Publish configuration files: - -```bash -php artisan vendor:publish --tag="monitor-config" -``` - -## Components - -### Structured Logging - -**What it does:** Enhances Laravel's logging with automatic enrichment (trace IDs, timing, memory usage, structured context) and smart origin resolution from class namespaces. +Because the declaration is data, the rest of the package can read it: `monitor:points` lists every point and checks the declarations in CI, `Monitor::fake()` asserts on outcomes by name, an optional store keeps outcomes queryable, and an MCP server lets an agent ask the application what its control points are and what happened at them. The same convention ships as guidelines for Laravel Boost, so an agent adding a critical operation is told once, by the package. ```php -use Kirschbaum\Monitor\Facades\Monitor; - -// In App\Http\Controllers\Api\UserController -class UserController extends Controller -{ - public function login(LoginRequest $request) - { - // Automatic origin resolution from full namespace - Monitor::log($this)->info('User login attempt', [ - 'email' => $request->email, - 'ip' => $request->ip() - ]); - } +// A critical operation as a try/catch: no name, no attempt count, no trace, null means declined. +try { + DB::beginTransaction(); + $charge = $this->stripe->charge($amount); + DB::commit(); +} catch (CardDeclined $e) { + DB::rollBack(); + Log::warning('card declined: '.$e->getMessage()); + return null; +} catch (\Throwable $e) { + DB::rollBack(); + Log::error($e); + throw $e; } -// In App\Services\Payment\StripePaymentService -class StripePaymentService -{ - public function processPayment($amount) - { - // Origin automatically resolved to clean, readable format - Monitor::log($this)->info('Processing payment', [ - 'amount' => $amount, - 'processor' => 'stripe' - ]); - } -} -``` - -**Note:** While you can override with `Monitor::log('CustomName')`, using `log($this)` is preferred as it automatically provides meaningful, consistent origin tracking from your actual class structure. - -**What it logs:** -```json -{ - "level": "info", - "event": "Monitor:Http:Controllers:Api:UserController:info", - "message": "[Monitor:Http:Controllers:Api:UserController] User login attempt", - "trace_id": "9d2b4e8f-3a1c-4d5e-8f2a-1b3c4d5e6f7g", - "context": { - "email": "[REDACTED]", - "ip": "192.168.1.1" - }, - "timestamp": "2024-01-15T14:30:45.123Z", - "duration_ms": 245, - "memory_mb": 45.23 -} -``` - -**Note:** The `event` field uses the raw origin name (after path replacers but before wrapper), while the `message` field uses the wrapped origin name for readability. - -**Configuration:** Origin path replacers, separators, and wrappers control how class names appear in logs: - -```php -// config/monitor.php -'origin_path_replacers' => [ - 'App\\' => 'Monitor\\', // Default: Replace App\ with Monitor\ - // 'App\\Http\\Controllers\\' => '', // Example: Remove controller namespace - // 'App\\Services\\Payment\\' => 'Pay\\', // Example: Shorten payment services - // 'App\\Services\\' => 'Svc\\', // Example: General service shortening -], -'origin_separator' => ':', // App\Http\Controllers\Api\UserController → Monitor:Http:Controllers:Api:UserController -'origin_path_wrapper' => 'square', // Monitor:Http:Controllers:Api:UserController → [Monitor:Http:Controllers:Api:UserController] +// The same operation as a control point. +return Monitor::control('payment.charge', $this) + ->with(['invoice' => $invoice->id, 'amount' => $amount]) + ->profile('external') // retry, breaker and duration limit from config + ->transaction(retries: 2) + ->ensure(fn (ChargeResult $r): bool => $r->settled, 'charge must be settled') + ->recover(CardDeclined::class, fn (CardDeclined $e) => ChargeResult::declined($e->code)) + ->escalate(PagePayments::class) + ->run(fn () => $this->stripe->charge($amount)); ``` -### Controlled Execution Blocks - -**What it does:** Monitors critical operations with automatic start/end logging, exception-specific handling, DB transactions, circuit breakers, and true escalation for uncaught exceptions. - -**Note:** The second parameter `$origin` (usually `$this`) is optional and automatically provides origin context to the structured logger used by the controlled block, eliminating the need for a separate `->log()` call. - -#### **Factory & Execution** +The declaration says what the operation tolerates, what it tries again, when it stops calling the gateway, what counts as success, and who is paged. It produces one log record per transition with `point`, `domain`, `status`, `run_id` and `trace_id` as fields. -```php -use Kirschbaum\Monitor\Facades\Monitor; - -// Create and execute controlled block -$result = Monitor::controlled('payment_processing', $this) - ->run(function() { - return processPayment($data); - }); -``` +## Quick Start -#### **Context Management** - -```php -/* - * Adds additional context to the structured logger. - */ -Monitor::controlled('payment_processing', $this) - ->addContext([ - 'transaction_id' => 'txn_456', - 'gateway' => 'stripe' - ]); - -/* - * Will completely replace structured logger context. - * ⚠️ Not recommended unless you have a good reason to do so. - */ -Monitor::controlled('payment_processing', $this) - ->overrideContext([ - 'user_id' => 123, - 'operation' => 'payment', - 'amount' => 99.99 - ]); -``` - -#### **Exception Handling** - -**Exception-Specific Handlers (`catching`):** -```php -Monitor::controlled('payment_processing', $this) - ->catching([ - DatabaseException::class => function($exception, $meta) { - $cachedData = ExampleModel::getCachedData(); - return $cachedData; // Recovery value - }, - NetworkException::class => function($exception, $meta) { - $this->exampleRetryLater($meta); - // No return = just handle, don't recover - }, - PaymentException::class => function($exception, $meta) { - $this->exampleNotifyFinanceTeam($exception, $meta); - throw $exception; // Re-throw if needed - }, - // Other exception types remain uncaught. - ]) -``` - -**Uncaught Exception Handling (`onUncaughtException`):** -```php -Monitor::controlled('payment_processing', $this) - ->onUncaughtException(function($exception, $meta) { - // Example actions, the exception will remain uncaught - $this->alertOpsTeam($exception, $meta); - $this->sendToErrorTracking($exception); - }) -``` - -**Key Behavior:** -- Only specified exception types in `catching()` are handled -- Handlers can return recovery values to prevent re-throwing -- `onUncaughtException()` **only** fires for exceptions not caught by `catching()` handlers -- True separation between expected (caught) and unexpected (uncaught) failures - -#### **Circuit Breaker & Database Protection** - -**What are Circuit Breakers?** -Circuit breakers prevent cascading failures by temporarily stopping requests to a failing service, allowing it time to recover. They automatically "open" after a threshold of failures and "close" once the service is healthy again, protecting your application from wasting resources on operations likely to fail. - -```php -Monitor::controlled('payment_processing', $this) - ->withCircuitBreaker('payment_gateway', 3, 60) // 3 failures, 60s timeout - ->withDatabaseTransaction(2, [DeadlockException::class], [ValidationException::class]) +```bash +composer require kirschbaum-development/monitor +php artisan vendor:publish --tag=monitor-config ``` -**Circuit Breaker HTTP Middleware** - -You can also protect entire routes or route groups using the `CheckCircuitBreakers` middleware: +Add the trace middleware so every request, and every job it dispatches, shares one trace id: ```php -// bootstrap/app.php or register as route middleware +// bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { - $middleware->alias([ - 'circuit' => \Kirschbaum\Monitor\Http\Middleware\CheckCircuitBreakers::class, - ]); + $middleware->append(\Kirschbaum\Monitor\Http\Middleware\StartTrace::class); }) - -// In your routes -Route::middleware(['circuit:payment_gateway,external_api']) - ->group(function () { - Route::post('/payments', [PaymentController::class, 'store']); - Route::get('/external-data', [DataController::class, 'fetch']); - }); - -// Or on individual routes -Route::get('/api/data') - ->middleware('circuit:slow_service') - ->name('data.fetch'); -``` - -**Circuit Breaker Middleware Features:** -- **Multiple Breakers**: Check multiple circuit breakers with `circuit:breaker1,breaker2,breaker3` -- **Graceful Degradation**: Returns HTTP 503 (Service Unavailable) when circuit is open -- **Standard Headers**: Includes `Retry-After`, `X-Circuit-Breaker`, and `X-Circuit-Breaker-Status` headers -- **Jitter Protection**: Built-in randomized retry delays prevent thundering herd effects -- **Auto-Recovery**: Circuits automatically close when services recover - -**Response Headers When Circuit is Open:** -``` -HTTP/1.1 503 Service Unavailable -Retry-After: 45 -X-Circuit-Breaker: payment_gateway -X-Circuit-Breaker-Status: open ``` -The `Retry-After` header includes intelligent jitter - instead of all clients retrying at the exact same time, it provides a random delay between 0 and the remaining decay time, preventing overwhelming the recovering service. - -#### **Tracing & Logging** +Declare a critical operation as a class: -```php -Monitor::controlled('payment_processing', $this) - ->overrideTraceId('custom-trace-12345') - // Origin is automatically set from the second parameter ($this) +```bash +php artisan make:control-point Payments/ChargeCard --profile=external ``` -#### **Complete Example** - ```php -class PaymentService +#[Point('payments.charge_card', profile: 'external')] +final class ChargeCard extends ControlPoint { - public function processPayment($amount, $userId) - { - return Monitor::controlled('payment_processing', $this) - ->addContext([ - 'user_id' => $userId, - 'amount' => $amount, - 'currency' => 'USD' - ]) - ->withCircuitBreaker('payment_gateway', 3, 120) - ->withDatabaseTransaction(1, [DeadlockException::class]) - ->catching([ - PaymentDeclinedException::class => function($e, $meta) { - return ['status' => 'declined', 'reason' => $e->getMessage()]; - }, - InsufficientFundsException::class => function($e, $meta) { - return ['status' => 'insufficient_funds']; - } - ]) - ->onUncaughtException(fn($e, $meta) => SomeEscalationLogic::run($e, $meta)) - ->run(function() use ($amount) { - return $this->chargeCard($amount); - }); - } -} -``` - -#### **What it logs:** - -**Success:** -```json -{"message": "[Monitor:Services:PaymentService] STARTED", "controlled_block": "payment_processing", "controlled_block_id": "01HK..."} -{"message": "[Monitor:Services:PaymentService] ENDED", "status": "ok", "duration_ms": 1250} -``` - -**Caught Exception (Recovery):** -```json -{"message": "[Monitor:Services:PaymentService] STARTED", "controlled_block": "payment_processing"} -{"message": "[Monitor:Services:PaymentService] CAUGHT", "exception": "PaymentDeclinedException", "duration_ms": 500} -{"message": "[Monitor:Services:PaymentService] RECOVERED", "recovery_value": "array"} -``` - -**Uncaught Exception (Escalation):** -```json -{"message": "[Monitor:Services:PaymentService] STARTED", "controlled_block": "payment_processing"} -{"message": "[Monitor:Services:PaymentService] UNCAUGHT", "exception": "RuntimeException", "uncaught": true, "duration_ms": 300} -``` - -#### **API Reference** - -| Method | Purpose | Returns | -|--------|---------|---------| -| `Monitor::controlled(string $name, string\|object $origin = null)` | Create controlled block with optional origin | `self` | -| `->overrideContext(array $context)` | Replace entire context | `self` | -| `->addContext(array $context)` | Merge additional context | `self` | -| `->catching(array $handlers)` | Define exception-specific handlers | `self` | -| `->onUncaughtException(Closure $callback)` | Handle uncaught exceptions only | `self` | -| `->withCircuitBreaker(string $name, int $threshold, int $decay)` | Configure circuit breaker | `self` | -| `->withDatabaseTransaction(int $retries, array $only, array $exclude)` | Wrap in DB transaction with retry | `self` | -| `->overrideTraceId(string $traceId)` | Set custom trace ID | `self` | -| `->run(Closure $callback)` | Execute the controlled block | `mixed` | - -### Distributed Tracing + public function __construct(private readonly Invoice $invoice) {} -**What it does:** Provides correlation IDs that follow requests across services, jobs, and operations. - -```php -use Kirschbaum\Monitor\Facades\Monitor; - -class OrderController extends Controller -{ - public function store() + protected function control(Control $control): void { - // Start trace (typically via middleware) - Monitor::trace()->start(); - - Monitor::log($this)->info('Processing order'); - - // All subsequent operations share the same trace ID - $this->paymentService->charge($amount); - - // Queue job with trace context - ProcessOrderJob::dispatch($order); + $control + ->recover(CardDeclined::class, fn (CardDeclined $e) => ChargeResult::declined($e->code)) + ->ensure(fn (ChargeResult $r): bool => $r->settled, 'charge must be settled') + ->escalate(PagePayments::class); } -} -class PaymentService -{ - public function charge($amount) + public function context(): array { - // Automatically includes trace ID from OrderController - Monitor::log($this)->info('Charging card', ['amount' => $amount]); + return ['invoice' => $this->invoice->id]; } -} -``` - -**Trace Management:** -```php -// Manual control -Monitor::trace()->start(); // Generate new UUID (throws if already started) -Monitor::trace()->override($traceId); // Use specific ID (overwrites existing) -Monitor::trace()->pickup($traceId); // Start if not started, optionally with specific ID -Monitor::trace()->id(); // Get current ID (throws if not started) -Monitor::trace()->hasStarted(); // Check if active -Monitor::trace()->hasNotStarted(); // Check if not active -``` - -**Key Differences:** -- `start()` - Throws exception if trace already exists -- `override()` - Always sets trace ID, replacing any existing one -- `pickup()` - Safe method that starts only if not already started - -### HTTP Middleware - -**What it does:** Automatically manages trace IDs for HTTP requests, enabling seamless distributed tracing across services. - -**Registration:** -```php -// bootstrap/app.php -->withMiddleware(function (Middleware $middleware) { - $middleware->append(\Kirschbaum\Monitor\Http\Middleware\StartMonitorTrace::class); -}) -``` -**Behavior:** -- **Incoming:** Picks up `X-Trace-Id` header or generates new UUID -- **Outgoing:** Sets `X-Trace-Id` header in response -- **Preserves:** Existing traces when already started - -**Cross-service usage:** -```php -// Service A -$response = Http::withHeaders([ - 'X-Trace-Id' => Monitor::trace()->id() -])->get('https://service-b.example.com/api/data'); - -// Service B automatically uses the same trace ID -``` - -**Configuration:** Custom header name via `trace_header` config or `MONITOR_TRACE_HEADER` env var. - -### Performance Timing - -**What it does:** Provides millisecond-precision timing for operations. - -```php -use Kirschbaum\Monitor\Facades\Monitor; - -class DataProcessor -{ - public function processData() + public function handle(StripeClient $stripe): ChargeResult { - $timer = Monitor::time(); // Auto-starts - - // Your processing code - $this->heavyOperation(); - - $elapsed = $timer->elapsed(); // Milliseconds - - Monitor::log($this)->info('Processing complete', [ - 'duration_ms' => $elapsed - ]); + return $stripe->charge($this->invoice); } } -``` - -**Note:** All Monitor logging automatically includes `duration_ms` from service start. - -### Circuit Breaker Direct Access - -**What it does:** Provides direct access to circuit breaker state management for advanced use cases. - -```php -use Kirschbaum\Monitor\Facades\Monitor; - -// Check circuit breaker state -$isOpen = Monitor::breaker()->isOpen('payment_gateway'); -$state = Monitor::breaker()->getState('payment_gateway'); - -// Manual state management -Monitor::breaker()->recordFailure('api_service', 300); // Record failure with 300s decay -Monitor::breaker()->recordSuccess('api_service'); // Record success (resets failures) -Monitor::breaker()->reset('api_service'); // Force reset -Monitor::breaker()->forceOpen('api_service'); // Force open state -``` -**Usage in Custom Logic:** -```php -class ExternalApiService -{ - public function makeRequest() - { - if (Monitor::breaker()->isOpen('external_api')) { - return $this->getCachedResponse(); - } - - try { - $response = $this->performApiCall(); - Monitor::breaker()->recordSuccess('external_api'); - return $response; - } catch (Exception $e) { - Monitor::breaker()->recordFailure('external_api', 120); - throw $e; - } - } -} +ChargeCard::run($invoice); // the value, or throws what escaped +ChargeCard::attempt($invoice); // an Outcome: ->status, ->value, ->exception, ->attempts, ->durationMs ``` -### Log Redactor Direct Access - -**What it does:** Provides direct access to the redactor for custom redaction needs. +Test it by name: ```php -use Kirschbaum\Monitor\Facades\Monitor; - -// Direct redaction using configured profile -$redactedData = Monitor::redactor()->redact($sensitiveData); +Monitor::fake()->failing('payments.charge_card', new CardDeclined('insufficient_funds')); -// Custom profile redaction -$redactedData = Monitor::redactor()->redact($sensitiveData, 'strict'); +ChargeCard::run($invoice); -// Example usage -class UserDataProcessor -{ - public function processUserData(array $userData) - { - // Redact before logging or storing - $safeData = Monitor::redactor()->redact($userData); - - Monitor::log($this)->info('Processing user data', $safeData); - - return $this->process($userData); // Use original for processing - } -} +Monitor::assertRecovered('payments.charge_card', from: CardDeclined::class); +Monitor::assertNothingEscalated(); ``` -### Log Redaction - -**What it does:** Automatically scrubs sensitive data from log context using [Kirschbaum Redactor](https://github.com/kirschbaum-development/redactor) to ensure compliance and security while preserving important data. - -**Configuration:** Simple redaction configuration in `config/monitor.php`: +And prove in CI that every critical operation is declared and complete: -```php -'redactor' => [ - 'enabled' => true, - 'redactor_profile' => 'default', // Uses Kirschbaum Redactor profiles -], +```bash +php artisan monitor:points --check ``` -**Usage:** Redaction is automatically applied to all Monitor log context: +## How It Works -```php -Monitor::log($this)->info('User data', [ - 'id' => 123, - 'email' => 'user@example.com', // → '[REDACTED]' based on profile rules - 'password' => 'secret123', // → '[REDACTED]' based on profile rules - 'api_token' => 'sk-1234567890abcdef...', // → '[REDACTED]' based on profile rules - 'name' => 'John Doe', // → 'John Doe' (if allowed by profile) -]); -``` +A **control point** has a name (`payment.charge`), a domain derived from its class namespace, and a contract. The contract declares **risks** with their **corrections** (`recover()`: the handler's return value is the result), **policies** (`Retry`, `Transaction`, `Breaker`, composed in a fixed order), **limits** (`within()` records a slow run without failing it, `attempts()` caps retries, `ensure()` fails a run whose result is wrong), and an **escalation** for anything no correction covers. -For detailed redaction configuration, rules, patterns, and profiles, see the [Kirschbaum Redactor documentation](https://github.com/kirschbaum-development/redactor). +Every run ends in exactly one **outcome**: succeeded, recovered, escalated, or refused by an open breaker. The outcome is what `attempt()` returns, what the events carry, what the fake records, what the log receives as one record per transition, and what the optional store keeps in a table. -## Complete API Reference +Control points nest. A child carries its parent's run id, a child's escalation reaches the parent's corrections, and retries never compose across the stack. The trace id and the current point ride on Laravel's Context, so they reach queued jobs and every log line the application writes. -The Monitor facade provides access to all monitoring components: +An **inventory** reads the codebase without running it: `monitor:points` lists every point with its contract, `--check` fails the build when a point has no escalation, a name is duplicated, or a class in a critical namespace is not a control point. The same rules are available as a Pest expectation and a PHPStan rule. -```php -use Kirschbaum\Monitor\Facades\Monitor; +## What It Covers -// Structured logging -Monitor::log($origin)->info('message', $context); +- **Critical operations** through `Monitor::control()` inline or `ControlPoint` classes, with typed outcomes. +- **Resilience** through retry with backoff, whole-transaction retry on deadlock, `once()` for idempotent runs, and a closed/open/half-open circuit breaker shared across processes, also usable standalone, on the HTTP client as `Http::breaker()`, and as the `CheckBreakers` route middleware. +- **Queues** through `ChargeCard::dispatch()`, which runs a control point as a job tagged for Horizon and released for the breaker's retry-after when refused, and the `WaitForBreaker` job middleware. +- **Records** as one schema for every transition, redacted through [Redactor](https://github.com/kirschbaum-development/redactor), with a tap that writes NDJSON with the fields at the top level. +- **Tracing** with W3C `traceparent` and a legacy header, `Http::traced()` for outgoing calls, and automatic propagation to queued jobs. +- **A store** of outcomes, written after the response, for `monitor:outcomes` and the MCP tools when there is no log backend. +- **Verification** through `Monitor::fake()` and its assertions, `monitor:points --check` with table, JSON and SARIF output, a Pest expectation and a PHPStan rule. +- **Agents** through a guideline and a skill that Laravel Boost composes into every consuming app, a read-only MCP server (`list_points`, `explain_point`, `outcomes`, `escalations`, a `wrap_operation` prompt), and a `make:control-point` stub whose test already uses the fake. -// Controlled execution blocks -Monitor::controlled($name, $origin)->run($callback); +## Documentation -// Distributed tracing -Monitor::trace()->start(); -Monitor::trace()->pickup($traceId); +The full documentation lives in [`docs/`](docs/README.md): -// Performance timing -Monitor::time()->elapsed(); +| Page | What it covers | +| --- | --- | +| [Getting Started](docs/getting-started.md) | Installation, the inline form, the Outcome, a first class-form point, the trace middleware. | +| [Control Points](docs/control-points.md) | Naming, domains, the builder, the class form, nesting, Laravel Context. | +| [Risks and Corrections](docs/risks-and-corrections.md) | `recover()` semantics, the catch-all, `escalate()`, the risks Monitor raises. | +| [Policies and Limits](docs/policies-and-limits.md) | Retry, Transaction, Breaker, pipeline order, the three limits, profiles. | +| [Records](docs/records.md) | The record schema, levels, redaction, NDJSON, `Monitor::log()`, events. | +| [Tracing](docs/tracing.md) | Trace ids, the middleware, `Http::traced()`, jobs, console. | +| [Jobs](docs/jobs.md) | Dispatching a point as a job, Horizon tags, `WaitForBreaker`, what a job inherits. | +| [Breakers](docs/breakers.md) | The state machine, the standalone API, the route middleware. | +| [Store](docs/store.md) | Enabling the outcome store, what is written and when, `monitor:outcomes`, pruning. | +| [Inventory](docs/inventory.md) | `monitor:points`, every rule, `--check` in CI, `monitor:explain`, `make:control-point`. | +| [Testing](docs/testing.md) | `Monitor::fake()` and its assertions, the Pest expectations, the PHPStan rule, and the package's own tests. | +| [Agents](docs/agents.md) | The Boost guideline and skill, the MCP server, its tools, resources and prompt. | +| [Configuration](docs/configuration.md) | Every key in `config/monitor.php` with its type, default and environment variable. | +| [Extending](docs/extending.md) | Custom policies, escalations, inventory rules, event listeners. | +| [Upgrading](docs/upgrading.md) | Every 0.1 surface and its 1.0 replacement. | -// Circuit breaker management -Monitor::breaker()->isOpen($name); - -// Log redaction -Monitor::redactor()->redact($data); -``` +## Requirements -All components integrate seamlessly and share trace context automatically when used together. +- PHP 8.3, 8.4 or 8.5 +- Laravel 12 or 13 +- [kirschbaum-development/redactor](https://github.com/kirschbaum-development/redactor) 1.x, installed automatically +- [laravel/mcp](https://github.com/laravel/mcp) 1.x, only for the MCP server -## Configuration +## Testing -**Environment Variables:** ```bash -# Core settings -MONITOR_ENABLED=true - -# Exception tracing (applies to Controlled blocks only) -MONITOR_TRACE_ENABLED=true -MONITOR_TRACE_FULL_ON_DEBUG=true -MONITOR_TRACE_FORCE_FULL_TRACE=false -MONITOR_TRACE_MAX_LINES=15 - -# Auto-trace console commands -MONITOR_CONSOLE_AUTO_TRACE_ENABLED=true -MONITOR_CONSOLE_AUTO_TRACE_ENABLE_IN_TESTING=false - -# HTTP trace header -MONITOR_TRACE_HEADER=X-Trace-Id - -# Circuit breaker defaults -MONITOR_CIRCUIT_BREAKER_DECAY_SECONDS=300 -MONITOR_CIRCUIT_BREAKER_RETRY_AFTER=300 -MONITOR_CIRCUIT_BREAKER_CORS_HEADERS=false - -# Log redaction -MONITOR_REDACTOR_ENABLED=true -MONITOR_REDACTOR_PROFILE=default -``` - -**Logging Channel:** Configure a dedicated Monitor logging channel: - -```php -// config/logging.php -'channels' => [ - 'monitor' => [ - 'driver' => 'daily', - 'path' => storage_path('logs/monitor.log'), - 'level' => 'debug', - 'days' => 14, - 'tap' => [ - \Kirschbaum\Monitor\Taps\StructuredLoggingTap::class, - ], - ], -], -``` - -## Output Examples - -**Structured Log Entry:** -```json -{ - "level": "info", - "event": "Monitor:Http:Controllers:UserController:info", - "message": "[Monitor:Http:Controllers:UserController] User login successful", - "trace_id": "9d2b4e8f-3a1c-4d5e-8f2a-1b3c4d5e6f7g", - "context": { - "user_id": 123, - "ip_address": "192.168.1.1", - "_redacted": true - }, - "timestamp": "2024-01-15T14:30:45.123Z", - "duration_ms": 1245, - "memory_mb": 45.23 -} +composer test +composer preflight # pint, rector, phpstan and pest, as the pre-commit hook runs them +composer setup-hooks # point git at .githooks ``` -**Controlled Block Execution:** -```json -{"message": "[Monitor:Services:PaymentService] STARTED", "controlled_block": "payment_processing", "controlled_block_id": "01HK4...", "trace_id": "9d2b4e8f..."} -{"message": "[Monitor:Services:PaymentService] ENDED", "controlled_block": "payment_processing", "status": "ok", "duration_ms": 1250} -``` - -**Failure with Exception:** -```json -{ - "message": "[Monitor:Services:PaymentService] UNCAUGHT", - "controlled_block": "payment_processing", - "exception": { - "class": "RuntimeException", - "message": "Card declined", - "file": "/app/PaymentService.php", - "line": 45, - "trace": ["...", "..."] - }, - "duration_ms": 500, - "uncaught": true -} -``` +## Changelog -## Testing - -Run the test suite: - -```bash -vendor/bin/pest -``` +See [CHANGELOG.md](CHANGELOG.md) for what changed in each release. ## License -The MIT License (MIT). Please see [License File](LICENSE) for more information. +The MIT License (MIT). See [LICENSE.md](LICENSE.md). diff --git a/composer.json b/composer.json index 5222b94..0edb014 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,23 @@ { "name": "kirschbaum-development/monitor", - "description": "Laravel observability toolkit with critical control points, structured logging, performance timing, and trace context.", + "description": "Control points for Laravel: declare what a critical operation tolerates, bound it, record every outcome, and verify it in CI.", "type": "library", "license": "MIT", - "keywords": ["laravel", "logging", "telemetry", "observability", "trace", "controlled", "monitor"], + "keywords": [ + "laravel", + "observability", + "resilience", + "circuit breaker", + "retry", + "structured logging", + "tracing", + "control points", + "critical operations", + "monitor" + ], "autoload": { "psr-4": { - "Kirschbaum\\Monitor\\": "src/", - "Tests\\": "tests/" + "Kirschbaum\\Monitor\\": "src/" } }, "autoload-dev": { @@ -21,28 +31,36 @@ "authors": [ { "name": "Belisar Hoxholli", - "email": "belisar.hoxholli@gmail.com" + "email": "belisar.hoxholli@gmail.com", + "role": "Developer" } ], + "require": { + "php": "^8.3|^8.4|^8.5", + "illuminate/support": "^12.0|^13.0", + "monolog/monolog": "^3.0", + "symfony/finder": "^7.0|^8.0", + "kirschbaum-development/redactor": "^1.0", + "nikic/php-parser": "^5.0" + }, "require-dev": { - "pestphp/pest": "^3.8", - "laravel/pint": "^1.22", "larastan/larastan": "^3.4", - "orchestra/testbench": "^10.3", - "pestphp/pest-plugin-laravel": "^3.1", - "timacdonald/log-fake": "^2.4" + "laravel/pint": "^1.22", + "orchestra/testbench": "^10.3|^11.0", + "pestphp/pest": "^3.8", + "timacdonald/log-fake": "^2.4", + "laravel/mcp": "^1.0", + "rector/rector": "^2.6", + "driftingly/rector-laravel": "^2.5" + }, + "suggest": { + "laravel/mcp": "To let agents list control points, read their contracts and query outcomes over MCP" }, "config": { "allow-plugins": { "pestphp/pest-plugin": true } }, - "require": { - "illuminate/support": "^11.9|^12.0", - "spatie/laravel-package-tools": "^1.16", - "php": "^8.3|^8.4", - "kirschbaum-development/redactor": "^0.1.0" - }, "extra": { "laravel": { "providers": [ @@ -71,7 +89,8 @@ ], "lint": [ "@php vendor/bin/pint --ansi", - "@php vendor/bin/phpstan analyse --verbose --ansi" + "@php vendor/bin/rector process --ansi", + "@php vendor/bin/phpstan analyse --verbose --ansi --memory-limit=1G" ], "test": [ "@clear", @@ -79,8 +98,22 @@ ], "preflight": [ "@php vendor/bin/pint --test --ansi", - "@php vendor/bin/phpstan analyse --no-progress --ansi", + "@php vendor/bin/rector process --dry-run --ansi", + "@php vendor/bin/phpstan analyse --no-progress --ansi --memory-limit=1G", "@php vendor/bin/pest --parallel" + ], + "test-coverage": [ + "@clear", + "@php -d memory_limit=2G vendor/bin/pest --coverage --min=100" + ], + "mutate": [ + "@php -d memory_limit=2G vendor/bin/pest --mutate --everything --covered-only" + ], + "rector": [ + "@php vendor/bin/rector process --ansi" + ], + "rector:check": [ + "@php vendor/bin/rector process --dry-run --ansi" ] } } diff --git a/config/logging-monitor.php b/config/logging-monitor.php deleted file mode 100644 index 2d4944d..0000000 --- a/config/logging-monitor.php +++ /dev/null @@ -1,25 +0,0 @@ -info() -// -// The example below shows how you could configure a dedicated channel with Monitor's -// structured logging tap, but this is completely optional. - -return [ - 'channels' => [ - 'monitor_example' => [ - 'driver' => 'daily', - 'path' => storage_path('logs/monitor.log'), - 'level' => 'debug', - 'days' => 14, - 'tap' => [ - \Kirschbaum\Monitor\Taps\StructuredLoggingTap::class, - ], - ], - ], -]; diff --git a/config/monitor.php b/config/monitor.php index 49b771d..f4a57f0 100644 --- a/config/monitor.php +++ b/config/monitor.php @@ -1,291 +1,251 @@ env('MONITOR_ENABLED', true), + 'domains' => [ + 'map' => [ + 'App\\ControlPoints\\' => null, + 'App\\Services\\' => null, + 'App\\' => null, + ], + 'fallback' => 'App', + ], /* |-------------------------------------------------------------------------- - | Origin Prefix + | Critical Namespaces |-------------------------------------------------------------------------- | - | An optional prefix that will be prepended to all resolved origin names - | in log messages. For example, if you set this to 'MyApp', an origin - | like 'UserController' will become 'MyApp:UserController'. Set to null - | to disable prefixing entirely. - | - | Type: string|null - | Example: 'MyApp', 'Service', null + | Namespaces whose classes are expected to be control points. The inventory + | check, the Pest expectation and the PHPStan rule all read this list. A + | class here that neither extends ControlPoint nor calls Monitor::control() + | fails the check. | */ - 'prefix' => null, + 'critical_namespaces' => [ + 'App\\ControlPoints', + ], /* |-------------------------------------------------------------------------- - | Origin Path Replacers + | Point Names |-------------------------------------------------------------------------- | - | This array allows you to replace specific namespace paths in origin - | names. This is useful for shortening long class names or standardizing - | naming conventions in logs. Multiple replacements can be applied to the - | same string - they are processed in array order using strtr(). - | - | Examples: - | 'App\\Http\\Controllers\\Admin\\' => 'Admin\\', - | 'App\\Http\\Controllers\\' => 'Web\\', - | 'App\\Jobs\\' => 'Job\\', - | - | Type: array - | Key: The namespace path to match (case-sensitive) - | Value: The replacement string + | Every control point has a name that appears in code, records, the + | inventory and tests. This pattern is enforced at runtime and by the + | inventory check. The default is dotted lowercase: "payment.charge". | */ - 'origin_path_replacers' => [ - 'App\\' => 'Monitor\\', - ], + 'point_name_pattern' => '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/', /* |-------------------------------------------------------------------------- - | Origin Separator + | Discovery |-------------------------------------------------------------------------- | - | The character used to separate segments in origin names. This controls - | how namespace separators (\) are converted when building the origin path. - | For example, 'App\User\Controller' with separator ':' becomes 'App:User:Controller'. - | - | Note: This is different from the origin wrapper - the separator affects - | the internal structure, while the wrapper affects the final appearance. - | - | Type: string - | Common values: ':', '.', '-', '_' + | Where the inventory looks for control point classes and for inline + | Monitor::control() calls. Paths are absolute or relative to base_path(). | */ - 'origin_separator' => ':', + 'discovery' => [ + 'paths' => ['app'], + ], /* |-------------------------------------------------------------------------- - | Origin Path Wrapper + | Inventory Rules |-------------------------------------------------------------------------- | - | Controls how the final origin name appears in log messages by wrapping it - | with decorative characters. This affects the visual appearance of origins - | in your logs and can help with readability and parsing. - | - | Note: This is different from the origin separator - the separator controls - | how segments of class names (namespaces) are split, while the wrapper - | affects the final appearance (e.g., [Monitor:Foo]). + | What monitor:points --check enforces. Every rule is on unless set to + | false here. Errors fail the check; warnings are printed. | - | Type: string - | Available options: - | - 'none' => UserController (no wrapping) - | - 'square' => [UserController] (square brackets) - | - 'curly' => {UserController} (curly braces) - | - 'round' => (UserController) (parentheses) - | - 'angle' => (angle brackets) - | - 'double' => "UserController" (double quotes) - | - 'single' => 'UserController' (single quotes) - | - 'asterisks' => *UserController* (asterisks) + | duplicate_names error two points share a name + | name_pattern error a name does not match the pattern + | missing_escalation error a class-form point has no escalation and no catch-all + | catch_all_without_escalation warning recover(Throwable::class) with nowhere to escalate + | critical_namespace_uncontrolled error a class in a critical namespace is not a control point + | dynamic_name warning an inline point's name is not a string literal + | unreadable_control error control() cannot be read without the constructor | */ - 'origin_path_wrapper' => 'square', + 'inventory' => [ + 'rules' => [ + // 'catch_all_without_escalation' => false, + ], + ], /* |-------------------------------------------------------------------------- - | Exception Trace Configuration + | Profiles |-------------------------------------------------------------------------- | - | Configuration for how exception stack traces are handled and logged - | within Monitor Controlled execution blocks. These settings control the - | verbosity and detail level of exception information captured when Controlled - | operations fail. Note: These settings only apply to Controlled block exception - | handling, not general StructuredLogger usage. + | A profile is a named bundle of policies and limits. A point that calls + | ->profile('external') starts with these and overrides what it declares. + | Keys: retry, transaction, breaker, within, attempts. Omit a key to leave + | that policy or limit unset. | */ - 'exception_trace' => [ - - /* - |---------------------------------------------------------------------- - | Exception Trace Enabled - |---------------------------------------------------------------------- - | - | Whether to include exception stack traces in Controlled block failure logs. - | When disabled, only basic exception information (class, message, - | file, line) will be logged without the stack trace. - | - */ - - 'enabled' => env('MONITOR_TRACE_ENABLED', true), - - /* - |---------------------------------------------------------------------- - | Full Trace on Debug - |---------------------------------------------------------------------- - | - | When the application is in debug mode (app.debug=true), this setting - | controls whether to include the complete exception trace or a - | truncated version based on max_lines. This only applies when - | debug mode is active. - | - */ - - 'full_on_debug' => env('MONITOR_TRACE_FULL_ON_DEBUG', true), - - /* - |---------------------------------------------------------------------- - | Force Full Trace - |---------------------------------------------------------------------- - | - | When enabled, this forces full exception traces to be logged - | regardless of the debug mode setting. Use with caution in production - | as it can generate very verbose logs. - | - */ - - 'force_full_trace' => env('MONITOR_TRACE_FORCE_FULL_TRACE', false), - - /* - |---------------------------------------------------------------------- - | Maximum Trace Lines - |---------------------------------------------------------------------- - | - | The maximum number of lines to include in truncated exception stack - | traces (when not in debug mode or when full_on_debug is false). - | Must be greater than 0. Full traces are shown when debug conditions - | are met, ignoring this limit. - | - */ - - 'max_lines' => env('MONITOR_TRACE_MAX_LINES', 15), + 'profiles' => [ + 'external' => [ + 'retry' => ['times' => 2, 'backoff_ms' => 200, 'multiplier' => 2.0, 'jitter' => true], + 'breaker' => ['after' => 5, 'within' => 60, 'for' => 120], + 'within' => 10, + ], + 'database' => [ + 'transaction' => ['retries' => 2, 'on' => [DeadlockException::class]], + 'within' => 5, + ], + 'messaging' => [ + 'retry' => ['times' => 3, 'backoff_ms' => 500, 'multiplier' => 2.0, 'jitter' => true], + 'within' => 15, + ], + 'internal' => [ + 'within' => 2, + ], ], /* |-------------------------------------------------------------------------- - | Console Auto-Trace Configuration + | Trace |-------------------------------------------------------------------------- | - | Configuration for automatic trace initialization in console environments. - | The Monitor service provider can automatically start a trace when the - | application runs in console mode, ensuring all console commands have - | observability context. + | The trace ID follows a request through control points, queued jobs and + | every log line. The middleware reads W3C traceparent first, then the + | legacy header, validates what it finds, and writes both back on the + | response. In console, a trace starts with the process. | */ - 'console_auto_trace' => [ - - /* - |---------------------------------------------------------------------- - | Enabled - |---------------------------------------------------------------------- - | - | Whether console auto-trace functionality is enabled. When true, - | the service provider will automatically start a trace in console - | environments if no trace is already active. - | - */ - - 'enabled' => env('MONITOR_CONSOLE_AUTO_TRACE_ENABLED', true), - - /* - |---------------------------------------------------------------------- - | Enable in Testing Environment - |---------------------------------------------------------------------- - | - | Whether to enable console auto-trace when running in the testing - | environment. This is typically disabled to avoid interfering with - | tests, but can be enabled if you want to test console auto-trace - | functionality specifically. - | - */ - - 'enable_in_testing' => env('MONITOR_CONSOLE_AUTO_TRACE_ENABLE_IN_TESTING', false), + 'trace' => [ + 'header' => 'traceparent', + 'legacy_header' => env('MONITOR_TRACE_HEADER', 'X-Trace-Id'), + 'console' => env('MONITOR_TRACE_CONSOLE', true), ], /* |-------------------------------------------------------------------------- - | Trace Header + | Breakers |-------------------------------------------------------------------------- | - | The HTTP header used to pass and return the trace ID. This allows - | downstream and upstream services to correlate logs and metrics. - | The StartMonitorTrace middleware will look for this header in incoming - | requests and set it in outgoing responses. - | - | Default: 'X-Trace-Id' - | Environment: MONITOR_TRACE_HEADER + | Defaults for circuit breakers that are used without explicit numbers, + | including from the standalone Monitor::breaker() API and the route + | middleware. "after" failures "within" seconds open the breaker "for" + | seconds, after which one probe is let through. | */ - 'trace_header' => env('MONITOR_TRACE_HEADER', 'X-Trace-Id'), + 'breakers' => [ + 'store' => env('MONITOR_BREAKER_STORE'), + 'prefix' => 'monitor:breaker:', + 'after' => 5, + 'within' => 60, + 'for' => 120, + ], /* |-------------------------------------------------------------------------- - | Circuit Breaker Configuration + | Once |-------------------------------------------------------------------------- | - | Configuration for circuit breaker middleware and behavior. + | Where the once() policy keeps its idempotency keys. Null uses the default + | cache store; the keys are shared exactly as far as that store is. | */ - 'circuit_breaker' => [ - 'default_decay_seconds' => env('MONITOR_CIRCUIT_BREAKER_DECAY_SECONDS', 300), - 'default_retry_after' => env('MONITOR_CIRCUIT_BREAKER_RETRY_AFTER', 300), - 'add_cors_headers' => env('MONITOR_CIRCUIT_BREAKER_CORS_HEADERS', false), + 'once' => [ + 'store' => env('MONITOR_ONCE_STORE'), + 'prefix' => 'monitor:once:', ], /* |-------------------------------------------------------------------------- - | Redactor Configuration + | Records |-------------------------------------------------------------------------- | - | Configuration for log redaction using Kirschbaum Redactor - | (https://github.com/kirschbaum-development/redactor) for scrubbing - | logs of sensitive or noisy values that shouldn't be persisted. + | Every transition of a control point produces one record. Records are + | written as log lines to the channel below (null for the default channel), + | with context redacted through the given Redactor profile. Exception + | traces are never included unless asked for. + | + | The store keeps outcomes in a table so they can be queried without a log + | backend. Writes happen after the response is sent or the job finishes, + | and a failing store never fails a control point. | */ - 'redactor' => [ + 'records' => [ + 'channel' => env('MONITOR_LOG_CHANNEL'), + 'redaction' => env('MONITOR_REDACTION_PROFILE', 'observability'), + 'exception_trace' => env('MONITOR_EXCEPTION_TRACE', 'never'), // never | debug | always + 'exception_trace_lines' => 15, /* |---------------------------------------------------------------------- - | Redactor Enabled + | Record Levels |---------------------------------------------------------------------- | - | Whether the log redactor is enabled. When disabled, no redaction - | will be performed on log context data. + | The PSR-3 level each event is written at. A missing event is written + | at "info". Event names contain dots, so set the whole array rather + | than one nested key. | */ - 'enabled' => env('MONITOR_REDACTOR_ENABLED', true), + 'levels' => [ + 'point.started' => 'debug', + 'point.retried' => 'notice', + 'point.limit' => 'warning', + 'point.recovered' => 'warning', + 'point.refused' => 'warning', + 'point.escalated' => 'error', + 'point.ended' => 'info', + 'escalation.failed' => 'critical', + 'escalation.throttled' => 'notice', + 'breaker.opened' => 'error', + 'breaker.half_open' => 'notice', + 'breaker.closed' => 'info', + ], + 'store' => [ + 'enabled' => env('MONITOR_STORE_ENABLED', false), + 'connection' => env('MONITOR_STORE_CONNECTION'), + 'table' => 'monitor_outcomes', + 'retention_days' => 30, + ], + ], - /* - |---------------------------------------------------------------------- - | Redactor Profile - |---------------------------------------------------------------------- - | - | The redactor profile to use. This corresponds to profiles configured - | in the Kirschbaum Redactor package. - | - */ + /* + |-------------------------------------------------------------------------- + | MCP + |-------------------------------------------------------------------------- + | + | A read-only MCP server that lets an agent list control points, read a + | point's contract, and query recent outcomes. Requires laravel/mcp. Start + | it with `php artisan mcp:start monitor`. + | + */ - 'redactor_profile' => env('MONITOR_REDACTOR_PROFILE', 'default'), + 'mcp' => [ + 'enabled' => env('MONITOR_MCP_ENABLED', false), + 'handle' => 'monitor', ], ]; diff --git a/database/migrations/2026_09_14_000000_create_monitor_outcomes_table.php b/database/migrations/2026_09_14_000000_create_monitor_outcomes_table.php new file mode 100644 index 0000000..b2f8542 --- /dev/null +++ b/database/migrations/2026_09_14_000000_create_monitor_outcomes_table.php @@ -0,0 +1,56 @@ +getConnection())->create($this->table(), function (Blueprint $table): void { + $table->id(); + $table->string('run_id', 26)->unique(); + $table->string('parent_run_id', 26)->nullable()->index(); + $table->string('trace_id', 32)->index(); + $table->string('point', 191)->index(); + $table->string('domain', 191)->index(); + $table->string('origin', 255); + $table->string('profile', 64)->nullable(); + $table->string('status', 16)->index(); + $table->string('recovered_from', 255)->nullable(); + $table->string('exception_class', 255)->nullable(); + $table->text('exception_message')->nullable(); + $table->unsignedInteger('attempts'); + $table->decimal('duration_ms', 14, 3); + $table->json('stack'); + $table->json('context'); + $table->json('limits_breached'); + $table->json('policies'); + $table->json('timeline'); + $table->timestamp('started_at', 3); + $table->timestamp('ended_at', 3)->index(); + }); + } + + public function down(): void + { + Schema::connection($this->getConnection())->dropIfExists($this->table()); + } + + private function table(): string + { + $table = config('monitor.records.store.table'); + + return is_string($table) && $table !== '' ? $table : 'monitor_outcomes'; + } +}; diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..1e1d144 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,46 @@ +# Monitor Documentation + +Monitor gives a Laravel application **control points**: the operations where a failure matters, declared in code as a contract. A control point says what it is called, which domain it belongs to, which failures it expects and what to return instead, which policies bound it (retry, transaction, circuit breaker), which limits it should stay within, and who is told when something it did not expect gets out. Every run ends in exactly one outcome, succeeded, recovered, escalated or refused, and every transition is written as a record with the same fields. + +Because the declaration is data, the rest of the package can read it: `monitor:points` lists every control point in the application and checks the declarations in CI, `Monitor::fake()` asserts on outcomes in tests, an optional store keeps outcomes queryable without a log backend, and an MCP server lets an agent ask the application what its control points are and what happened at them. + +## Start Here + +If you are new to the package, read the pages in this order: + +1. [Getting Started](getting-started.md) installs the package, declares a first control point and shows what it writes to the log. +2. [Control Points](control-points.md) covers the inline and class forms, naming, domains and nesting. +3. [Risks and Corrections](risks-and-corrections.md) and [Policies and Limits](policies-and-limits.md) are the two halves of a point's contract. +4. [Inventory](inventory.md) and [Testing](testing.md) are how the contract is verified. + +Everything else can be read as you need it. + +## Pages + +| Page | What it covers | +| --- | --- | +| [Getting Started](getting-started.md) | Installation, the inline form, `run()` and `attempt()`, the `Outcome`, a first class-form point, the trace middleware, and where the records go. | +| [Control Points](control-points.md) | Names, origin and domain, the full inline builder, the class form, the constraint on `control()`, nesting, and what lands in Laravel's Context. | +| [Risks and Corrections](risks-and-corrections.md) | `recover()` semantics, the catch-all, `escalate()` with a closure or a class, `EscalationFailed`, and the risks Monitor raises itself. | +| [Policies and Limits](policies-and-limits.md) | `Retry`, `Transaction`, `Breaker`, pipeline order, custom policies, the three limits, and the shipped profiles. | +| [Records](records.md) | The record every transition writes, its fields and levels, the JSON schema, the NDJSON tap, redaction, and the events behind it. | +| [Tracing](tracing.md) | The trace ID, `traceparent` and the legacy header, the middleware, outgoing propagation with `Http::traced()`, queued jobs and console. | +| [Jobs](jobs.md) | What a job inherits, `ChargeCard::dispatch()` and `RunControlPoint`, Horizon tags, the `WaitForBreaker` job middleware, and why the queue keeps its retries. | +| [Breakers](breakers.md) | The circuit state machine, the standalone `Monitor::breaker()` API, and the `CheckBreakers` route middleware. | +| [Store](store.md) | The optional outcomes table, when it is written, `monitor:outcomes`, `monitor:prune`, and retention. | +| [Inventory](inventory.md) | `monitor:points`, the rules `--check` enforces, table, JSON and SARIF output, `monitor:explain`, and `make:control-point`. | +| [Testing](testing.md) | `Monitor::fake()` and its assertions, the Pest expectations, the PHPStan rule, and the package's own tests. | +| [Agents](agents.md) | The guideline and skill shipped for Laravel Boost, and the MCP server's tools, resources and prompt. | +| [Configuration](configuration.md) | Every key in `config/monitor.php` with its type, default and environment variable. | +| [Extending](extending.md) | Writing a policy, an escalation, an inventory rule, and listening to the events. | +| [Upgrading](upgrading.md) | Every 0.1 surface and its 1.0 replacement. | + +## Conventions + +Code samples assume the facade is imported: + +```php +use Kirschbaum\Monitor\Facades\Monitor; +``` + +Configuration paths are written relative to the file, so `records.store.enabled` means `config('monitor.records.store.enabled')`. diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..6f38cfa --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,111 @@ +# Agents + +- [Introduction](#introduction) +- [The Guidelines](#the-guidelines) +- [The MCP Server](#the-mcp-server) + - [Enabling It](#enabling-it) + - [Tools](#tools) + - [Resources](#resources) + - [The wrap_operation Prompt](#the-wrap_operation-prompt) + - [Redaction](#redaction) + - [Connecting an Agent](#connecting-an-agent) + +## Introduction + +An agent that adds a critical operation should not need the conventions spelled out in a prompt. The package carries them two ways: a guidelines file that Laravel Boost composes into the application's agent guidelines, so the agent knows the convention before it writes anything, and an MCP server that lets the agent read the inventory and recent outcomes, so it can find out what a point does and what happened at it instead of grepping logs. + +## The Guidelines + +The package ships two things for Boost: a guideline at `.ai/guidelines/core.blade.php`, and a skill at `resources/boost/skills/monitor-development/SKILL.md`, the path Laravel's own packages use for the longer, task-shaped material an agent loads when it is working on control points. The guideline is the convention in one page; the skill walks through declaring, testing and reading a point. + +Boost composes the guideline as follows. [Laravel Boost](https://github.com/laravel/boost) discovers guidelines in every installed package's `.ai/guidelines/` directory and composes them, with the project's own, into the single guidelines file it writes for the agent when `boost:install` runs. Requiring the package is enough for the convention to reach every agent working in the application. + +The file tells an agent: + +- what makes an operation critical, and that in doubt it is; +- that a critical operation is a class under `App\ControlPoints\{Domain}`, created with `make:control-point`, with a worked example; +- the rules `monitor:points --check` enforces: dotted lowercase unique names, an escalation on every point, a `control()` that does not read constructor arguments, which profile fits which kind of operation; +- that expected failures are declared with `recover()` and its return value is the result, rather than caught around the call; +- what `within()`, `attempts()` and `ensure()` do; +- how to test a point with `Monitor::fake()` and its assertions; +- how to read what happened with `monitor:points`, `monitor:explain` and `monitor:outcomes`. + +To adjust the wording for one application, add a guideline under the project's own `.ai/guidelines/` directory; Boost's precedence rules let a project guideline stand in for a package's, so consult the Boost documentation for the exact path it expects. + +## The MCP Server + +`Kirschbaum\Monitor\Mcp\MonitorServer` is a read-only server built on [`laravel/mcp`](https://github.com/laravel/mcp). It is off by default and does nothing unless `laravel/mcp` is installed. + +### Enabling It + +```bash +composer require laravel/mcp +``` + +```php +'mcp' => [ + 'enabled' => env('MONITOR_MCP_ENABLED', false), + 'handle' => 'monitor', +], +``` + +With `MONITOR_MCP_ENABLED=true`, the service provider registers the server as a local server under the handle, and it starts over stdio with: + +```bash +php artisan mcp:start monitor +``` + +If you would rather register it yourself, the way `laravel/mcp` documents for an application's own servers, leave `mcp.enabled` off and add it to `routes/ai.php`: + +```php +use Kirschbaum\Monitor\Mcp\MonitorServer; +use Laravel\Mcp\Facades\Mcp; + +Mcp::local('monitor', MonitorServer::class); +``` + +The server's instructions tell the client what a control point is and which tool to reach for. + +### Tools + +| Tool | Arguments | Returns | +| --- | --- | --- | +| `list_points` | `domain` (optional) | Every control point as the inventory describes it, plus the findings. With `domain`, only that domain's points and the findings about them. JSON. | +| `explain_point` | `point` (required) | The point's contract in prose, the same text as `monitor:explain`, with its last 24 hours when the store is on. An error when the name is unknown. | +| `outcomes` | `point`, `domain`, `status`, `trace`, `since` (`15m`, `2h`, `7d`; default `24h`), `limit` (default 50, max 500), all optional | Recent outcomes newest first, with `since`, `count` and the rows. JSON. Requires the [outcome store](store.md). | +| `escalations` | `since` (default `24h`), `domain`, both optional | Runs that escalated or were refused in the window, grouped by point with counts, the exception classes seen, and the last time and trace id. JSON. Requires the store. | + +The two store-backed tools return an error saying so while the store is disabled; records are still in the log. + +### Resources + +| URI | Content | +| --- | --- | +| `monitor://schema/record-1` | The JSON schema of every record, `resources/schema/record-1.json`, as `application/json`. | +| `monitor://guidelines` | The guidelines file, as `text/markdown`. An agent can read the convention without a tool call. | + +### The wrap_operation Prompt + +`wrap_operation` takes `path`, a PHP file relative to the project root, and an optional `point` name. It returns a user message that asks the model to turn the operation in that file into a control point class: infer the risks from the existing `catch` blocks, pick the profile from what the operation does, add an `ensure()` if the call can succeed with a failing result, declare an escalation, and write the Pest test with one case per risk and one for an unexpected failure. The guidelines and the file's source are included in the message. + +### Redaction + +Every response passes through Kirschbaum Redactor with the profile in `records.redaction`, the same one records use. The server's redactor knows its tools answer in JSON: a JSON text block is decoded and redacted field by field, so a trace id or a ULID is not mistaken for a secret and a whole payload is never replaced for being long. Prose, such as an explanation or the prompt, is redacted line by line. Error messages are redacted too. Set `records.redaction` to null to turn it off. + +### Connecting an Agent + +A local stdio server, for Claude Code: + +```json +{ + "mcpServers": { + "monitor": { + "command": "php", + "args": ["artisan", "mcp:start", "monitor"], + "cwd": "/path/to/the/application" + } + } +} +``` + +Cursor and other clients take the same three values: the command `php`, the arguments `artisan mcp:start monitor`, and the application root as the working directory. `MONITOR_MCP_ENABLED=true` must be set in the environment the command runs in, unless the server is registered in `routes/ai.php`. diff --git a/docs/breakers.md b/docs/breakers.md new file mode 100644 index 0000000..265b097 --- /dev/null +++ b/docs/breakers.md @@ -0,0 +1,193 @@ +# Breakers + +- [Introduction](#introduction) +- [The State Machine](#the-state-machine) +- [Storage](#storage) +- [On a Control Point](#on-a-control-point) +- [On an HTTP Call](#on-an-http-call) +- [The Standalone API](#the-standalone-api) +- [BreakerState](#breakerstate) +- [The Route Middleware](#the-route-middleware) +- [Events](#events) + +## Introduction + +A circuit breaker stops an application from calling a dependency that is failing, for long enough for it to recover. Monitor's breakers are named circuits shared by every process through the cache. A control point declares which circuit it depends on; the standalone API and the route middleware let anything else consult or drive the same circuit. + +## The State Machine + +A circuit is in one of three states. + +**Closed.** Attempts are allowed. Failures are counted inside a sliding window of `within` seconds. When the count reaches `after`, the circuit opens. + +**Open.** Attempts are refused for `for` seconds. A control point refused this way ends `refused` and raises `BreakerOpen`; see [Risks and Corrections](risks-and-corrections.md). When the period ends, the next attempt moves the circuit to half-open. + +**Half-open.** Exactly one probe is allowed through. Its success closes the circuit and clears the failures. Its failure opens the circuit again for a full period. A second caller arriving while the probe is in flight is refused. + +The three numbers are read as "`after` failures `within` seconds open the circuit `for` seconds". Defaults come from config and can be overridden per circuit: + +```php +'breakers' => [ + 'after' => 5, + 'within' => 60, + 'for' => 120, +], +``` + +Failures older than the window are forgotten, so five failures spread over an hour do not open a circuit whose window is a minute. + +## Storage + +State is kept in the cache so every worker and web process sees the same circuit. `breakers.store` names the cache store to use, or null for the default; `breakers.prefix` is prepended to the circuit name to make the key. + +The circuit is only shared as far as the cache store is. On the `array` or `file` driver each process keeps its own circuit and the half-open probe lock protects nothing beyond that process, so a breaker that must be shared across web workers and queue workers needs a shared store such as Redis, Memcached or the database driver. Point `breakers.store` at one when the application's default cache is local. + +```php +'breakers' => [ + 'store' => env('MONITOR_BREAKER_STORE'), + 'prefix' => 'monitor:breaker:', +], +``` + +The half-open probe is a cache lock alongside the state, so it works wherever `Cache::add()` does. An entry that cannot be read as a state is treated as closed. + +## On a Control Point + +A point declares its circuit with `breaker()`: + +```php +Monitor::control('payment.charge', $this) + ->breaker('stripe', after: 3, within: 60, for: 120) + ->run(fn () => $this->stripe->charge($amount)); +``` + +Omitted numbers fall back to the configured defaults. A profile can carry a breaker too, named after the point unless the profile names one; see [Policies and Limits](policies-and-limits.md). + +The breaker is the outermost policy, so it sees one result per run: a point that retried twice and then succeeded is a success, and one that retried and then escalated is one failure. The `Breaker` policy object accepts `on()` and `except()` to say which exception classes count as failures; a declined card is not a gateway outage: + +```php +use Kirschbaum\Monitor\Policies\Breaker; + +Monitor::control('payment.charge', $this) + ->policy(Breaker::named('stripe')->after(3, within: 60)->for(120)->except([CardDeclined::class])) + ->run(...); +``` + +When the circuit is open the point does not run its callback. The outcome's status is `refused`, its exception is a `Kirschbaum\Monitor\Risks\BreakerOpen` with the circuit's name and the seconds until it can be retried, and `run()` throws it. The point may recover from its own refusal with `recover(BreakerOpen::class, ...)`, and a parent point sees a child's refusal as the same exception. + +## On an HTTP Call + +Not every outbound call deserves a control point. `Http::breaker()` puts the same circuit on a request from Laravel's HTTP client: + +```php +use Illuminate\Support\Facades\Http; + +$rates = Http::breaker('rates')->get('https://rates.test/latest')->json(); + +Http::baseUrl('https://stripe.test')->breaker('stripe', after: 3, within: 60, for: 120)->post('/charges', $payload); +``` + +`breaker(string $name, ?int $after = null, ?int $within = null, ?int $for = null)` exists on the facade and on a `PendingRequest`; omitted numbers fall back to the configured defaults. It adds a Guzzle middleware that calls `permit()` before sending, throws `Kirschbaum\Monitor\Risks\BreakerOpen` without sending while the circuit is open, counts a connection failure or a 5xx response as a failure, and counts any other response as a success. The circuit is the one control points use, so `Http::breaker('stripe')` and a point with `breaker('stripe')` open and close together. + +## The Standalone API + +`Monitor::breaker()` returns the `CircuitBreaker` service. It drives the same circuits a point uses. + +```php +use Kirschbaum\Monitor\Facades\Monitor; + +$breaker = Monitor::breaker(); + +$breaker->state('stripe'); // a BreakerState: ->state, ->failures, ->openedAt, ->openFor +$breaker->isOpen('stripe'); // true while refusing; false when closed or when a probe is due +$breaker->isClosed('stripe'); // the opposite +$breaker->retryAfter('stripe'); // seconds until the open period ends, 0 when not open + +$breaker->permit('stripe'); // a Decision: ->allowed, ->probe, ->retryAfterSeconds +$breaker->recordFailure('stripe'); +$breaker->recordSuccess('stripe'); + +$breaker->open('stripe', 300); // open by hand for 300 seconds, e.g. from a health check +$breaker->close('stripe'); // close by hand and clear the failures +``` + +`permit()` is what the policy calls. It moves an expired open circuit to half-open and hands out its single probe, so call it only when you are about to make the attempt and will report back with `recordFailure()` or `recordSuccess()`. `isOpen()` only looks; it does not consume the probe. + +`permit()` and `recordFailure()` accept a `Kirschbaum\Monitor\Breaker\BreakerConfig` as a second argument to use numbers other than the defaults: + +```php +use Kirschbaum\Monitor\Breaker\BreakerConfig; + +$breaker->recordFailure('efiling', new BreakerConfig(after: 2, within: 30, for: 600)); +``` + +A hand-rolled guard around code that is not a control point: + +```php +$decision = Monitor::breaker()->permit('search'); + +if (! $decision->allowed) { + return $this->cachedResults(); +} + +try { + $results = $this->search->query($q); + Monitor::breaker()->recordSuccess('search'); + + return $results; +} catch (SearchUnavailable $e) { + Monitor::breaker()->recordFailure('search'); + + throw $e; +} +``` + +## BreakerState + +`state()` and every breaker event hand back a `Kirschbaum\Monitor\Breaker\BreakerState`, a readonly object. It implements `Arrayable` and `JsonSerializable`, so it can be returned from a health endpoint as it is. + +| Member | Meaning | +| --- | --- | +| `state` | A `Kirschbaum\Monitor\Breaker\State` case: `Closed`, `Open` or `HalfOpen`. | +| `failures` | Unix timestamps of the failures inside the window, oldest first, at most `after` of them. | +| `openedAt`, `openFor` | When the circuit opened and for how many seconds, or `null` while closed. | +| `isClosed()`, `isOpen()`, `isHalfOpen()` | The state as booleans. | +| `failureCount()` | How many failures are in the window. | +| `retryAfter(int $now)` | Seconds left on the open period at the given time, `0` when not open. | +| `probeDue(int $now)` | Whether the circuit is open and its period has ended, so the next attempt is the probe. | +| `toArray()` | `state`, `failures`, `opened_at`, `open_for`. | + +## The Route Middleware + +`Kirschbaum\Monitor\Http\Middleware\CheckBreakers` refuses a route while any of the named circuits is open. The package registers it under the `monitor.breakers` alias; name the circuits after a colon: + +```php +Route::post('/payments', [PaymentController::class, 'store'])->middleware('monitor.breakers:stripe'); +Route::middleware('monitor.breakers:stripe,efiling')->group(function () { + // ... +}); +``` + +`->middleware(CheckBreakers::class.':stripe,efiling')` works the same, as does an alias of your own registered in `bootstrap/app.php`. + +While a circuit is open the response is an empty `503 Service Unavailable` with three headers: + +``` +Retry-After: 45 +X-Circuit-Breaker: efiling +X-Circuit-Breaker-Status: open +``` + +`Retry-After` is the real time left on the circuit, at least 1. The middleware uses `isOpen()`, so it never consumes the half-open probe: once the open period ends, the request goes through, and the first control point inside it makes the probe. + +## Events + +Each transition dispatches an event carrying the circuit name and the new `BreakerState`: + +| Event | When | +| --- | --- | +| `Kirschbaum\Monitor\Events\BreakerOpened` | The failure threshold was reached, a probe failed, or `open()` was called. | +| `Kirschbaum\Monitor\Events\BreakerHalfOpen` | The open period ended and the next attempt arrived. | +| `Kirschbaum\Monitor\Events\BreakerClosed` | A probe succeeded or `close()` was called on a circuit that was not already closed. | + +Each is also written as a record (`breaker.opened`, `breaker.half_open`, `breaker.closed`); see [Records](records.md). A circuit that was already closed does not announce a close. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..312fdf4 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,173 @@ +# Configuration + +- [Introduction](#introduction) +- [domains](#domains) +- [critical_namespaces](#critical_namespaces) +- [point_name_pattern](#point_name_pattern) +- [discovery](#discovery) +- [inventory](#inventory) +- [profiles](#profiles) +- [trace](#trace) +- [breakers](#breakers) +- [once](#once) +- [records](#records) + - [records.levels](#recordslevels) + - [records.store](#recordsstore) +- [mcp](#mcp) +- [Environment Variables](#environment-variables) + +## Introduction + +All of the package's configuration lives in `config/monitor.php`. Publish it with `php artisan vendor:publish --tag=monitor-config`. Every key has a default, so the package runs unpublished. + +## domains + +How a class name becomes the `domain` field on every record. + +| Key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `map` | `array` | `App\ControlPoints\`, `App\Services\`, `App\` all `null` | Namespace prefix to domain. `null` takes the namespace segment right after the prefix; a string is used as-is. Tried in order, first match wins. | +| `fallback` | `string` | `'App'` | The domain for a class matching no prefix, or sitting directly under one. | + +## critical_namespaces + +| Type | Default | +| --- | --- | +| `string[]` | `['App\ControlPoints']` | + +Namespaces whose classes are expected to be control points. The inventory's `critical_namespace_uncontrolled` rule, the `toBeControlled()` Pest expectation and the PHPStan rule read this list: a class here that neither extends `ControlPoint` nor calls `Monitor::control()` is reported. Interfaces, traits, enums, abstract classes, escalations and policies are exempt. + +## point_name_pattern + +| Type | Default | +| --- | --- | +| `string` (regex) | `'/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/'` | + +Every point name is validated against this at declaration time and by the inventory's `name_pattern` rule. The default is dotted lowercase with at least one dot. + +## discovery + +| Key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `paths` | `string[]` | `['app']` | Directories the inventory scans for control point classes and inline `Monitor::control()` calls. Absolute, or relative to `base_path()`. | + +## inventory + +| Key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `rules` | `array` | `[]` | Rules to switch off by setting them to `false`. Every rule is on unless listed. | + +The rules are `duplicate_names`, `name_pattern`, `missing_escalation`, `critical_namespace_uncontrolled`, `unreadable_control` (errors) and `catch_all_without_escalation`, `dynamic_name` (warnings). See [Inventory](inventory.md#the-rules). + +## profiles + +| Type | Default | +| --- | --- | +| `array` | `external`, `database`, `messaging`, `internal` | + +Named bundles applied with `->profile()` or the attribute's `profile:` argument. Each profile is an array with any of these keys: + +| Key | Shape | +| --- | --- | +| `retry` | `['times' => int, 'backoff_ms' => int, 'multiplier' => float, 'jitter' => bool, 'on' => class[], 'except' => class[]]` | +| `transaction` | `['retries' => int, 'on' => class[], 'except' => class[], 'connection' => string\|null, 'backoff_ms' => int]` | +| `breaker` | `['name' => string, 'after' => int, 'within' => int, 'for' => int, 'on' => class[], 'except' => class[]]`; `name` defaults to the point's name | +| `within` | seconds, `int\|float` | +| `attempts` | `int` | + +The shipped values are listed in [Policies and Limits](policies-and-limits.md#the-shipped-profiles). Class names in `on` and `except` that are not `Throwable` are ignored. + +## trace + +| Key | Type | Default | Env | Meaning | +| --- | --- | --- | --- | --- | +| `header` | `string` | `'traceparent'` | | The W3C header the middleware reads first and writes back. | +| `legacy_header` | `string` | `'X-Trace-Id'` | `MONITOR_TRACE_HEADER` | A plain trace-ID header read second and written back, for clients that do not speak `traceparent`. | +| `console` | `bool` | `true` | `MONITOR_TRACE_CONSOLE` | Start a trace when an artisan command starts (on `CommandStarting`), so commands and workers have one. | + +## breakers + +Defaults for circuits used without explicit numbers, including the standalone `Monitor::breaker()` API, the route middleware and profiles that omit a value. + +| Key | Type | Default | Env | Meaning | +| --- | --- | --- | --- | --- | +| `store` | `string\|null` | `null` | `MONITOR_BREAKER_STORE` | The cache store holding circuit state; `null` is the default store. | +| `prefix` | `string` | `'monitor:breaker:'` | | Cache key prefix. | +| `after` | `int` | `5` | | Failures that open a circuit. | +| `within` | `int` | `60` | | Seconds a failure stays counted. | +| `for` | `int` | `120` | | Seconds a circuit stays open before a probe is allowed. | + +## once + +Where the `once()` policy keeps its idempotency keys. + +| Key | Type | Default | Env | Meaning | +| --- | --- | --- | --- | --- | +| `store` | `string\|null` | `null` | `MONITOR_ONCE_STORE` | The cache store holding the keys; `null` is the default store. Keys are shared exactly as far as the store is. | +| `prefix` | `string` | `'monitor:once:'` | | Cache key prefix, followed by the point name, a colon and the key. | + +## records + +| Key | Type | Default | Env | Meaning | +| --- | --- | --- | --- | --- | +| `channel` | `string\|null` | `null` | `MONITOR_LOG_CHANNEL` | The log channel records are written to; `null` is the default channel. | +| `redaction` | `string\|null` | `'observability'` | `MONITOR_REDACTION_PROFILE` | The Redactor profile applied to record context and exception messages, to stored outcomes, and to MCP responses. Empty or `null` disables redaction. | +| `exception_trace` | `'never'\|'debug'\|'always'` | `'never'` | `MONITOR_EXCEPTION_TRACE` | Whether exception summaries include a stack trace: never, only when `app.debug` is on, or always. | +| `exception_trace_lines` | `int` | `15` | | Trace lines kept when a trace is included; the rest is counted in `trace_truncated`. | + +### records.levels + +| Type | Default | +| --- | --- | +| `array` | see below | + +The PSR-3 level for each record event. Events not listed are written at `info`. + +| Event | Default | +| --- | --- | +| `point.started` | `debug` | +| `point.retried` | `notice` | +| `point.limit` | `warning` | +| `point.recovered` | `warning` | +| `point.refused` | `warning` | +| `point.escalated` | `error` | +| `point.ended` | `info` | +| `escalation.failed` | `critical` | +| `breaker.opened` | `error` | +| `breaker.half_open` | `notice` | +| `breaker.closed` | `info` | + +Event names contain dots, so set the whole array rather than one nested key. + +### records.store + +| Key | Type | Default | Env | Meaning | +| --- | --- | --- | --- | --- | +| `enabled` | `bool` | `false` | `MONITOR_STORE_ENABLED` | Keep outcomes in a table. Publish and run the migration first; see [Store](store.md). | +| `connection` | `string\|null` | `null` | `MONITOR_STORE_CONNECTION` | The database connection for the table; `null` is the default. | +| `table` | `string` | `'monitor_outcomes'` | | The table name. | +| `retention_days` | `int` | `30` | | What `monitor:prune` deletes past. | + +## mcp + +| Key | Type | Default | Env | Meaning | +| --- | --- | --- | --- | --- | +| `enabled` | `bool` | `false` | `MONITOR_MCP_ENABLED` | Register the read-only MCP server. Requires `laravel/mcp`. | +| `handle` | `string` | `'monitor'` | | The handle for `php artisan mcp:start monitor`. | + +## Environment Variables + +| Variable | Key | +| --- | --- | +| `MONITOR_TRACE_HEADER` | `trace.legacy_header` | +| `MONITOR_TRACE_CONSOLE` | `trace.console` | +| `MONITOR_BREAKER_STORE` | `breakers.store` | +| `MONITOR_ONCE_STORE` | `once.store` | +| `MONITOR_LOG_CHANNEL` | `records.channel` | +| `MONITOR_REDACTION_PROFILE` | `records.redaction` | +| `MONITOR_EXCEPTION_TRACE` | `records.exception_trace` | +| `MONITOR_STORE_ENABLED` | `records.store.enabled` | +| `MONITOR_STORE_CONNECTION` | `records.store.connection` | +| `MONITOR_MCP_ENABLED` | `mcp.enabled` | + +Only values that change between environments have a variable; the rest are code-level defaults edited in the published file. `php artisan about` shows the store, MCP and profile settings in effect. diff --git a/docs/control-points.md b/docs/control-points.md new file mode 100644 index 0000000..7d44c59 --- /dev/null +++ b/docs/control-points.md @@ -0,0 +1,184 @@ +# Control Points + +- [Introduction](#introduction) +- [Names](#names) +- [Origin and Domain](#origin-and-domain) +- [The Inline Form](#the-inline-form) +- [The Class Form](#the-class-form) + - [The Attribute](#the-attribute) + - [control()](#control) + - [context() and handle()](#context-and-handle) + - [Running a Class](#running-a-class) + - [Describing a Class](#describing-a-class) +- [Nesting](#nesting) +- [Laravel Context](#laravel-context) + +## Introduction + +A control point has two forms. The inline form is a fluent declaration at the call site, for an operation that lives in one place. The class form is a file under `App\ControlPoints`, for an operation that is reused, tested on its own, or important enough to be found by name. Both build the same `Kirschbaum\Monitor\Control` and run the same way. + +## Names + +Every point has a name that appears in code, in records, in the inventory and in tests. It is the join key. Names must match `point_name_pattern`, which by default is dotted lowercase with at least one dot: + +``` +payment.charge +court.e_filing.submit_v2 +``` + +`Monitor::control('Payment')` throws `Kirschbaum\Monitor\Exceptions\InvalidPointName`, an `InvalidArgumentException` that implements the package's `MonitorException` interface, at declaration time. The inventory's `name_pattern` rule reports class-form points whose attribute name does not match. Change the pattern in `point_name_pattern` if your house style differs. + +## Origin and Domain + +The origin is the class the point belongs to. Pass `$this` or a class name as the second argument to `Monitor::control()`, or call `from()`; a class-form point's origin is the class itself. When nothing is given the origin is `Kirschbaum\Monitor\Control`. + +The domain is derived from the origin's namespace through `domains.map`. Each key is a namespace prefix; a `null` value means "the namespace segment right after the prefix", a string is used as-is. Prefixes are tried in order and the first match wins; a class matching nothing gets `domains.fallback`. + +```php +'domains' => [ + 'map' => [ + 'App\\ControlPoints\\' => null, // App\ControlPoints\Payments\ChargeCard => Payments + 'App\\Services\\' => null, // App\Services\Filings\Submit => Filings + 'App\\Billing\\' => 'Payments', // App\Billing\Invoices\Send => Payments + 'App\\' => null, // App\Http\Controllers\X => Http + ], + 'fallback' => 'App', +], +``` + +A class sitting directly under a prefix has no segment of its own and gets the fallback. Override the derived domain for one point with `domain()`, or with the `domain:` argument of the `#[Point]` attribute. + +## The Inline Form + +```php +use Kirschbaum\Monitor\Facades\Monitor; + +$control = Monitor::control('payment.charge', $this); +``` + +`Monitor::control(string $name, string|object|null $origin = null)` returns a `Control`. Everything on it is chainable. + +| Method | Effect | +| --- | --- | +| `from(string\|object $origin)` | Set the origin class. | +| `domain(string $domain)` | Override the derived domain. | +| `with(array $context)` | Merge context into what is recorded with every transition. Calling it twice merges. | +| `profile(string $name)` | Start from a configured bundle of policies and limits. Throws `InvalidProfile` for an unknown name. | +| `retry(...)`, `transaction(...)`, `breaker(...)`, `once(...)`, `policy(Policy $policy)` | Policies; see [Policies and Limits](policies-and-limits.md). | +| `within(...)`, `attempts(...)`, `ensure(...)` | Limits; see [Policies and Limits](policies-and-limits.md#limits). | +| `recover(string $class, Closure\|string $handler)` | Declare a risk and its correction, a closure or a `Correction` class; see [Risks and Corrections](risks-and-corrections.md). | +| `escalate(Closure\|string $escalation)` | Who is told when a failure no correction covers gets out. | +| `escalateLimits()`, `throttleEscalation(int $seconds)` | Also escalate a run that breached a limit; escalate at most once per window. See [Risks and Corrections](risks-and-corrections.md#escalating-on-a-breached-limit). | +| `run(Closure $callback)` | Execute; return the value or throw what escaped. Typed with a template, so a callback returning `ChargeResult` gives PHPStan a `ChargeResult` back; a correction may return something else. | +| `attempt(Closure $callback)` | Execute; return the `Outcome`. | +| `describe()` | The static description the inventory uses: name, origin, domain, profile, policies, limits, risk classes, corrections (class names or `'closure'`), whether there is a catch-all, the escalation as a class name, `'closure'` or `null`, `escalate_limits` and `escalation_throttle`. | + +`name()`, `origin()`, `resolvedDomain()`, `context()` and `profileName()` read back what was declared, and `ensures()`, `risks()`, `riskClasses()`, `hasCatchAll()`, `escalation()` and `hasEscalation()` read back the contract, which is what the inventory and a custom rule use. `resolvedPolicies()`, `resolvedWithin()` and `resolvedAttempts()` return the effective policies and limits after the profile is merged. + +The builder uses `Conditionable` and `Macroable`, so a declaration can branch and an application can add its own methods: + +```php +Monitor::control('payment.charge', $this) + ->when(app()->isProduction(), fn (Control $c) => $c->breaker('stripe')) + ->unless($invoice->isTest(), fn (Control $c) => $c->escalate(PagePayments::class)) + ->run(...); + +Control::macro('external', fn () => $this->profile('external')->within(10)); +``` + +A point name may be a string or a backed enum wherever one is passed: `Monitor::control(Points::Charge)`, `#[Point(Points::Charge)]`, and every method and assertion on the fake. The enum's value is used. + +## The Class Form + +```php +namespace App\ControlPoints\Payments; + +use Kirschbaum\Monitor\Attributes\Point; +use Kirschbaum\Monitor\Control; +use Kirschbaum\Monitor\ControlPoint; + +#[Point('payment.charge', profile: 'external')] +final class ChargeCard extends ControlPoint +{ + public function __construct(private readonly Invoice $invoice, private readonly Money $amount) {} + + protected function control(Control $control): void + { + $control + ->transaction(retries: 2) + ->breaker('stripe') + ->ensure(fn (ChargeResult $r): bool => $r->settled, 'charge must be settled') + ->recover(CardDeclined::class, fn (CardDeclined $e) => ChargeResult::declined($e->code)) + ->escalate(PagePayments::class); + } + + public function context(): array + { + return ['invoice' => $this->invoice->id, 'amount' => $this->amount->minor()]; + } + + public function handle(StripeClient $stripe): ChargeResult + { + return $stripe->charge($this->invoice, $this->amount); + } +} +``` + +`php artisan make:control-point Payments/ChargeCard` generates this shape and a test. See [Inventory](inventory.md) for its options. + +### The Attribute + +`#[Point(string $name, ?string $profile = null, ?string $domain = null)]` carries what must be readable without running anything. A class without it throws `InvalidControlPoint` when run or described. + +### control() + +`control(Control $control)` declares risks, corrections, policies, limits and escalation on the `Control` the point builds. It runs on every execution, and it also runs when the inventory describes the class, on an instance created **without the constructor**. It must therefore not read constructor arguments or anything else only known at runtime. A `control()` that does is reported by the inventory's `unreadable_control` rule, and the point's other rules cannot be trusted until it is fixed. Put per-run data in `context()` instead. + +### context() and handle() + +`context()` returns the array recorded with every transition of this run; it may read the constructor arguments. `handle()` does the work. It is called through the container, so its parameters are injected; a class without a public `handle()` throws `InvalidControlPoint`. + +### Running a Class + +| Call | Returns | +| --- | --- | +| `ChargeCard::run(...$arguments)` | The value, or throws what escaped. Arguments go to the constructor. | +| `ChargeCard::attempt(...$arguments)` | The `Outcome`. | +| `ChargeCard::dispatch(...$arguments)` | A `PendingDispatch` of the job that runs the point on the queue; see [Jobs](jobs.md). | +| `ChargeCard::dispatchSync(...$arguments)` | Runs the point through the queue synchronously. | +| `(new ChargeCard(...))->execute()` | The `Outcome`, for an instance you already have. | +| `(new ChargeCard(...))->toControl()` | The `Control` the class builds: the attribute's name, profile and domain, then `control()`, then `with($this->context())`. | +| `ChargeCard::point()` | The `Point` attribute instance. | + +### Describing a Class + +`ChargeCard::describe()` returns the same array as `Control::describe()` plus `form`, `notes` and `unreadable`, without constructing the class. When `control()` throws on the constructor-less instance, `unreadable` is `true` and `notes` says why. + +## Nesting + +A control point inside another is the normal case. The child gets a `parentId` equal to the parent's run ID, and both carry the stack of point names from outermost to innermost: + +```php +$parent = Monitor::control('order.place')->attempt(function () { + $child = Monitor::control('payment.charge')->attempt(fn () => ...); + // $child->parentId === parent run id; $child->stack === ['order.place', 'payment.charge'] +}); +``` + +When a child escalates, the exception leaves the child and reaches the parent's corrections like any other, so a parent can `recover(CardDeclined::class, ...)` from a child's failure. The child's own outcome is still `Escalated` and still recorded. When a child is refused by its breaker, the parent sees `Kirschbaum\Monitor\Risks\BreakerOpen` and can recover from it. + +Retries never compose across the stack. A child's escalation is marked, and the parent's retry policy will not re-run it: two retries inside two retries is two attempts at the leaf, not nine. The parent still retries its own failures. `attempts()` is always a cap on the point that declares it. + +Depth is unbounded. `Monitor::stack()` exposes the current stack: `names()`, `depth()`, `current()`, `currentRunId()` and `isInside()`. + +## Laravel Context + +Monitor keeps its per-request state in Laravel's `Context`, so it propagates to queued jobs and appears on every log line the application writes, not only Monitor's own records: + +| Key | Visibility | Value | +| --- | --- | --- | +| `trace_id` | visible | The current trace ID. | +| `control_point` | visible | The innermost running point's name, removed when the outermost point ends. | +| `monitor.stack` | hidden | The stack of `{point, run_id}` entries. | + +Nothing else about a run is held in a singleton, which is what makes nesting, queue propagation and long-running workers work without special handling. diff --git a/docs/extending.md b/docs/extending.md new file mode 100644 index 0000000..32b91e9 --- /dev/null +++ b/docs/extending.md @@ -0,0 +1,302 @@ +# Extending + +- [Introduction](#introduction) +- [A Custom Policy](#a-custom-policy) +- [A Custom Escalation](#a-custom-escalation) +- [A Custom Correction](#a-custom-correction) +- [Listening to Events](#listening-to-events) +- [A Custom Inventory Rule](#a-custom-inventory-rule) +- [Domain Resolution](#domain-resolution) +- [A Custom MCP Tool](#a-custom-mcp-tool) + +## Introduction + +Monitor's surface is small on purpose, and each piece of it is a contract you can implement: a policy wraps the attempt, an escalation is told about a failure, an inventory rule judges the declarations, and domains come from a map you own. + +## A Custom Policy + +A policy is a class implementing `Kirschbaum\Monitor\Contracts\Policy`: + +```php +interface Policy +{ + public const ORDER_BREAKER = 100; + public const ORDER_RETRY = 200; + public const ORDER_TRANSACTION = 300; + + /** @param Closure(): mixed $next */ + public function around(Run $run, Closure $next): mixed; + + public function order(): int; + + /** @return array */ + public function describe(): array; +} +``` + +`around()` receives the run and the next step of the pipeline. Call `$next()` to make the attempt and return its value; catch what it throws when the policy is about failures. `order()` places the policy in the pipeline, lowest outermost: the shipped breaker is 100, retry 200 and transaction 300, so a breaker sees one result per run and a transaction is retried whole. `describe()` returns a static description with a `type` key for the inventory and the outcome; it must not depend on anything only known at runtime. + +The run offers `attempt()` (the current attempt number), `maxAttempts()` (the `attempts()` limit or `PHP_INT_MAX`), `retried($exception, $backoffMs)` for a policy that makes another attempt, and `note($event, $detail)` to add an entry to the outcome's timeline. + +A policy that logs how long the attempt took and adds it to the timeline: + +```php +namespace App\Monitor; + +use Closure; +use Kirschbaum\Monitor\Contracts\Policy; +use Kirschbaum\Monitor\Run; + +class Timed implements Policy +{ + public function around(Run $run, Closure $next): mixed + { + $start = hrtime(true); + + try { + return $next(); + } finally { + $run->note('timed', ['ms' => round((hrtime(true) - $start) / 1e6, 3)]); + } + } + + public function order(): int + { + return 250; // inside retry, outside the transaction + } + + public function describe(): array + { + return ['type' => 'timed']; + } +} +``` + +Attach it with `policy()`: + +```php +Monitor::control('search.index', $this)->policy(new Timed)->run(...); +``` + +Passing one of the shipped policies to `policy()` replaces the one of the same type; a custom policy is keyed by its class, so two different custom policies can coexist. Inside a class-form point the call is the same on the `$control` given to `control()`. The `on()` and `except()` filters the shipped policies share come from the trait `Kirschbaum\Monitor\Policies\Concerns\FiltersExceptions`, which you may use in your own. + +A policy that retries must consult `Kirschbaum\Monitor\Support\ChildEscalations::contains($exception)` and not retry when it is true; that is how retries stay per point rather than compounding through a nested stack. See [Policies and Limits](policies-and-limits.md). + +## A Custom Escalation + +An escalation is a class implementing `Kirschbaum\Monitor\Contracts\Escalation`: + +```php +namespace App\Escalations; + +use Kirschbaum\Monitor\Contracts\Escalation; +use Kirschbaum\Monitor\Outcome; + +class PagePayments implements Escalation +{ + public function __construct(private readonly Pager $pager) {} + + public function handle(Outcome $outcome): void + { + $this->pager->page('payments', [ + 'point' => $outcome->point, + 'exception' => $outcome->exception?->getMessage(), + 'trace' => $outcome->traceId, + 'run' => $outcome->id, + ]); + } +} +``` + +It is resolved from the container, so constructor injection works. Name it on the point with `escalate(PagePayments::class)`. It is called after the escalation has been recorded and before the exception propagates; if it throws, an `EscalationFailed` event and an `escalation.failed` record say so, and the original exception still leaves the point. A closure passed to `escalate()` behaves the same way. + +## A Custom Correction + +A correction is a class implementing `Kirschbaum\Monitor\Contracts\Correction`, for a recovery that needs injected services or is shared between points: + +```php +namespace App\Corrections; + +use Kirschbaum\Monitor\Contracts\Correction; +use Kirschbaum\Monitor\Outcome; +use Throwable; + +class QueueForManualFiling implements Correction +{ + public function __construct(private readonly FilingQueue $queue) {} + + public function __invoke(Throwable $exception, Outcome $outcome): mixed + { + $this->queue->push($outcome->context['filing'], $exception->getMessage()); + + return Filing::queuedForManualHandling(); + } +} +``` + +It is resolved from the container when the risk occurs and named on the point with `recover(FilingRejected::class, QueueForManualFiling::class)`. Its return value is the result of the point, `null` included; throwing from it escalates, as from a closure. The inventory lists it by class name under `corrections`. + +## Listening to Events + +Every transition is an event before it is a record, so alerting and metrics listen rather than parse. The events and what each carries are listed in [Records](records.md#events). + +A listener that counts outcomes per point and status: + +```php +namespace App\Listeners; + +use Kirschbaum\Monitor\Events\PointEnded; + +class CountOutcomes +{ + public function handle(PointEnded $event): void + { + $outcome = $event->outcome; + + Metrics::increment('control_point.runs', [ + 'point' => $outcome->point, + 'domain' => $outcome->domain, + 'status' => $outcome->status->value, + ]); + + Metrics::timing('control_point.duration_ms', $outcome->durationMs, ['point' => $outcome->point]); + } +} +``` + +One that pages when a breaker opens: + +```php +use Kirschbaum\Monitor\Events\BreakerOpened; + +Event::listen(BreakerOpened::class, function (BreakerOpened $event): void { + Pager::page('platform', "circuit {$event->breaker} opened after {$event->state->failureCount()} failures"); +}); +``` + +Listeners run synchronously inside the point's run, so keep them fast or queue the work. + +## A Custom Inventory Rule + +A rule implements `Kirschbaum\Monitor\Contracts\Rule`: + +```php +interface Rule +{ + public function name(): string; + + /** @return list */ + public function check(Inventory $inventory): array; +} +``` + +`check()` receives the whole inventory: `$inventory->points` (every `PointDescription`), `$inventory->classPoints()`, `$inventory->files` (every scanned file with the classes it declares and the inline calls it makes) and `$inventory->find($name)`. It returns findings, each a `Kirschbaum\Monitor\Inventory\Finding` with the rule name, a level (`Finding::ERROR` or `Finding::WARNING`), a message, and optionally the point, file and line. + +A rule that requires every external point to declare a breaker: + +```php +namespace App\Monitor\Rules; + +use Kirschbaum\Monitor\Inventory\Finding; +use Kirschbaum\Monitor\Contracts\Rule; +use Kirschbaum\Monitor\Inventory\Inventory; + +class ExternalPointsHaveBreakers implements Rule +{ + public function name(): string + { + return 'external_without_breaker'; + } + + public function check(Inventory $inventory): array + { + $findings = []; + + foreach ($inventory->classPoints() as $point) { + if ($point->profile !== 'external') { + continue; + } + + $types = array_column($point->policies, 'type'); + + if (! in_array('breaker', $types, true)) { + $findings[] = new Finding($this->name(), Finding::ERROR, sprintf('"%s" is external but has no breaker', $point->name), $point->name, $point->file, $point->line); + } + } + + return $findings; + } +} +``` + +The shipped commands run the configured rules. To run your own, build the inventory yourself with `Discovery::build()`, which takes the paths to scan (null for the configured ones) and the rules to run (null for the configured ones): + +```php +use App\Monitor\Rules\ExternalPointsHaveBreakers; +use Kirschbaum\Monitor\Inventory\Discovery; +use Kirschbaum\Monitor\Inventory\Rules\Rules; + +$rules = [...Rules::configured(), new ExternalPointsHaveBreakers]; + +$inventory = app(Discovery::class)->build(rules: $rules); + +if ($inventory->hasErrors()) { + // ... +} +``` + +`Rules::configured()` returns the shipped rules that `inventory.rules` leaves on; `Rules::all()` maps every shipped rule name to its class. A test is a natural home for a custom rule: build the inventory in it and assert the findings are empty. + +## Domain Resolution + +A domain is derived from a class name through `domains.map`, tried in order. A null value takes the namespace segment right after the prefix; a string value is used as it is; nothing matching gives `domains.fallback`. + +```php +'domains' => [ + 'map' => [ + 'App\\ControlPoints\\' => null, // App\ControlPoints\Payments\ChargeCard -> Payments + 'App\\Billing\\' => 'Payments', // anything under App\Billing -> Payments + 'Modules\\' => null, // Modules\Filings\Submit -> Filings + 'App\\' => null, + ], + 'fallback' => 'App', +], +``` + +The same map serves control points, `Monitor::log()` and the inventory, so one edit moves a whole namespace to a new domain. A single point can still override it with `domain()` on the control or `domain:` on its `#[Point]` attribute; see [Control Points](control-points.md). + +## A Custom MCP Tool + +The server's own tools are ordinary `laravel/mcp` tools that read the inventory and the store. A tool of your own can reuse the same services. Dependencies are injected into `handle()`, the way `laravel/mcp` invokes tools, rather than through the constructor, so the server's test helpers can instantiate the tool without the container: + +```php +namespace App\Mcp\Tools; + +use Illuminate\Contracts\JsonSchema\JsonSchema; +use Kirschbaum\Monitor\Inventory\Discovery; +use Laravel\Mcp\Request; +use Laravel\Mcp\Response; +use Laravel\Mcp\Server\Tool; + +class PointsWithoutBreakers extends Tool +{ + protected string $description = 'Control points that declare no breaker.'; + + public function schema(JsonSchema $schema): array + { + return ['domain' => $schema->string()->description('Only this domain')]; + } + + public function handle(Request $request, Discovery $discovery): Response + { + $points = array_filter( + $discovery->build()->classPoints(), + fn ($point): bool => ! in_array('breaker', array_column($point->policies, 'type'), true), + ); + + return Response::json(array_map(fn ($point): array => $point->toArray(), array_values($points))); + } +} +``` + +Register it on a server of your own in `routes/ai.php`, or extend `Kirschbaum\Monitor\Mcp\MonitorServer` and add it to `$tools`. Responses from the shipped server are redacted as described in [Agents](agents.md#redaction); a server of your own decides for itself. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..aba154c --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,185 @@ +# Getting Started + +- [Introduction](#introduction) +- [Installation](#installation) +- [Your First Control Point](#your-first-control-point) +- [Two Terminals](#two-terminals) +- [The Outcome](#the-outcome) +- [A Control Point Class](#a-control-point-class) +- [Tracing Requests](#tracing-requests) +- [What Lands in the Log](#what-lands-in-the-log) +- [Next Steps](#next-steps) + +## Introduction + +A control point is an operation whose failure matters: charging a card, filing a document with an external system, posting a ledger entry, sending the one email a person is waiting for. Monitor lets you declare, next to the operation, what it tolerates and what to do about it, then records every run in one shape. + +The package registers a service provider and a facade automatically. Nothing runs until you declare a point. + +## Installation + +Install the package with Composer: + +```bash +composer require kirschbaum-development/monitor +``` + +Then publish the configuration file: + +```bash +php artisan vendor:publish --tag=monitor-config +``` + +This writes `config/monitor.php`. You can run the package without publishing it; the defaults described in [Configuration](configuration.md) apply. + +Monitor requires PHP 8.3, 8.4 or 8.5 and Laravel 12 or 13. It depends on [Kirschbaum Redactor](https://github.com/kirschbaum-development/redactor), which is installed with it and used to redact the context of every record. + +## Your First Control Point + +Wrap the operation in `Monitor::control()`, name it, and say what it tolerates: + +```php +use Kirschbaum\Monitor\Facades\Monitor; + +$result = Monitor::control('payment.charge', $this) + ->with(['invoice' => $invoice->id, 'amount' => $amount]) + ->profile('external') + ->recover(CardDeclined::class, fn (CardDeclined $e) => ChargeResult::declined($e->code)) + ->escalate(PagePayments::class) + ->run(fn () => $this->stripe->charge($invoice, $amount)); +``` + +Reading top to bottom: the point is called `payment.charge` and belongs to the class of `$this`; it records the invoice and amount with every transition; it starts from the `external` profile, which adds a retry, a circuit breaker and a duration limit; a declined card is expected and turns into a `ChargeResult`; anything else is escalated to `PagePayments` and then thrown. + +Names are dotted lowercase, `domain.operation`, and are validated when the point is declared. See [Control Points](control-points.md#names) for the pattern. + +## Two Terminals + +`run()` returns the value of the operation or of the correction that handled its failure, and throws whatever escaped: + +```php +$charge = Monitor::control('payment.charge')->run(fn () => $stripe->charge($amount)); +``` + +`attempt()` returns the `Outcome` and never throws for what the operation did: + +```php +use Kirschbaum\Monitor\Status; + +$outcome = Monitor::control('court.filing.submit', $this) + ->recover(FilingRejected::class, fn ($e) => Filing::rejected($e->reasons)) + ->attempt(fn () => $this->efiling->submit($filing)); + +match ($outcome->status) { + Status::Succeeded => $this->notifySubmitted($outcome->value), + Status::Recovered => $this->notifyRejected($outcome->value), + Status::Escalated => $this->queueForManualFiling($outcome->exception), + Status::Refused => $this->queueForLater(), +}; +``` + +Both produce the same `Outcome`, the same events and the same records. + +## The Outcome + +`Kirschbaum\Monitor\Outcome` is a readonly object with everything about one run: + +| Property | Meaning | +| --- | --- | +| `point`, `id`, `parentId` | The point name, this run's ULID, and the enclosing run's ULID when nested. | +| `traceId`, `domain`, `origin`, `profile` | Where the run happened. | +| `status` | A `Status` case: `Succeeded`, `Recovered`, `Escalated` or `Refused`. | +| `value` | The operation's return, or the correction's; `null` when escalated or refused. | +| `exception` | The failure that was recovered from or escalated, or `null`. | +| `recoveredFrom` | The risk class the correction handled, when recovered. | +| `attempts`, `durationMs` | How many attempts were made and how long the whole run took. | +| `startedAt`, `endedAt` | When the run started and ended, as `CarbonImmutable`. | +| `limitsBreached` | Limits that were exceeded, keyed by name, each with `threshold` and `actual`. | +| `policies` | The policies that ran, described. | +| `context`, `stack`, `timeline` | The point's context, the stack of point names, and every transition with its offset in milliseconds. | + +It has `succeeded()`, `recovered()`, `escalated()`, `refused()`, `hasValue()` and `breachedLimit($name)` helpers, `info()` for the run's `RunInfo`, and `toArray()` for a scalar view with the exception summarised. It implements `Arrayable` and `JsonSerializable`, so it can be returned from a route or queued as it is. + +## A Control Point Class + +An operation that is reused or important enough to have a file becomes a class: + +```bash +php artisan make:control-point Payments/ChargeCard --profile=external +``` + +This creates `app/ControlPoints/Payments/ChargeCard.php` and a test under `tests/Feature/ControlPoints/Payments/`. The class carries a `#[Point]` attribute with its name, declares its contract in `control()`, and does the work in `handle()`, whose parameters are resolved from the container: + +```php +namespace App\ControlPoints\Payments; + +use Kirschbaum\Monitor\Attributes\Point; +use Kirschbaum\Monitor\Control; +use Kirschbaum\Monitor\ControlPoint; + +#[Point('payments.charge_card', profile: 'external')] +final class ChargeCard extends ControlPoint +{ + public function __construct(private readonly Invoice $invoice, private readonly Money $amount) {} + + protected function control(Control $control): void + { + $control + ->recover(CardDeclined::class, fn (CardDeclined $e) => ChargeResult::declined($e->code)) + ->ensure(fn (ChargeResult $r): bool => $r->settled, 'charge must be settled') + ->escalate(PagePayments::class); + } + + public function context(): array + { + return ['invoice' => $this->invoice->id, 'amount' => $this->amount->minor()]; + } + + public function handle(StripeClient $stripe): ChargeResult + { + return $stripe->charge($this->invoice, $this->amount); + } +} + +$result = ChargeCard::run($invoice, $amount); // value or throws +$outcome = ChargeCard::attempt($invoice, $amount); // Outcome +``` + +The class form is what the inventory, the test fake and the agent guidelines are built around. See [Control Points](control-points.md#the-class-form). + +## Tracing Requests + +Add the middleware so every request has a trace ID that reaches every control point, every queued job and every log line: + +```php +// bootstrap/app.php +->withMiddleware(function (Middleware $middleware) { + $middleware->append(\Kirschbaum\Monitor\Http\Middleware\StartTrace::class); +}) +``` + +It reads a W3C `traceparent` header or the legacy `X-Trace-Id`, validates it, and writes both back on the response. The same middleware is available to route groups under the `monitor.trace` alias. Console commands get a trace when the process starts. See [Tracing](tracing.md). + +## What Lands in the Log + +Every transition writes one record to your default log channel, with the context redacted. A recovered run of the point above produces four lines: `point.started`, `point.recovered`, `point.ended` and, if the limit was breached, `point.limit`. Each carries `point`, `domain`, `origin`, `run_id`, `trace_id`, `status` and the rest of the fields in [Records](records.md), so `domain:Payments AND status:escalated` is a query in your log backend rather than a regex. + +To write newline-delimited JSON with those fields at the top level, add the tap to a channel: + +```php +'monitor' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/monitor.log'), + 'tap' => [Kirschbaum\Monitor\Logging\JsonTap::class], +], +``` + +and point `records.channel` at it. + +## Next Steps + +- [Control Points](control-points.md) for the full builder and the class form. +- [Risks and Corrections](risks-and-corrections.md) for what `recover()` and `escalate()` do exactly. +- [Policies and Limits](policies-and-limits.md) for retry, transactions, breakers, `within()`, `attempts()` and `ensure()`. +- [Inventory](inventory.md) to list every point and check the declarations in CI. +- [Testing](testing.md) for `Monitor::fake()`. diff --git a/docs/inventory.md b/docs/inventory.md new file mode 100644 index 0000000..38a3da0 --- /dev/null +++ b/docs/inventory.md @@ -0,0 +1,193 @@ +# The Inventory + +- [Introduction](#introduction) +- [monitor:points](#monitorpoints) + - [What Is Shown](#what-is-shown) + - [Formats](#formats) + - [Paths](#paths) + - [Checking](#checking) +- [The Rules](#the-rules) +- [Critical Namespaces](#critical-namespaces) +- [monitor:explain](#monitorexplain) +- [make:control-point](#makecontrol-point) +- [In CI](#in-ci) + +## Introduction + +The inventory is the list of every control point in the application, built by reading the code rather than running it. It answers which operations are critical and what happens when each one fails. The same scan runs a set of rules, so a point declared without an escalation, or a class in a critical namespace with no control point at all, fails the build. + +## monitor:points + +```bash +php artisan monitor:points +``` + +The command scans the configured paths for PHP files, parses each without executing it, and finds two kinds of point. + +### What Is Shown + +**Class-form points**, classes extending `Kirschbaum\Monitor\ControlPoint`, are described completely: name, domain, origin, profile, policies, limits, risks, whether they recover from `Throwable`, and their escalation. The description comes from the `#[Point]` attribute and from calling `control()` on an instance built without its constructor, which is why `control()` must not read constructor arguments. See [Control Points](control-points.md). + +**Inline points**, `Monitor::control('name')` or `new Control('name')` in any class, are inventoried by name, file, line and enclosing class only. Their closures are not inspected, and the table says `not inspected` in the policy, risk and escalation columns. A name that is not a string literal is listed as `(dynamic)`. + +The table has the columns Point, Form, Domain, Origin, Profile, Policies, Risks, Escalation and, when the [outcome store](store.md) is enabled, Last seen with the status of the point's most recent run. Findings follow the table, one line each, and a summary line counts points, errors and warnings. + +### Formats + +```bash +php artisan monitor:points --format=table # the default +php artisan monitor:points --format=json +php artisan monitor:points --format=sarif +``` + +JSON prints `points` and `findings` as arrays. Each point carries `point`, `form`, `origin`, `domain`, `file`, `line`, `profile`, `policies`, `limits`, `risks`, `catch_all`, `escalation`, `dynamic_name`, `unreadable` and `notes`. Each finding carries `rule`, `level`, `message`, `point`, `file` and `line`. + +SARIF 2.1.0 lists the findings as results with file locations relative to the project root, so code scanning shows them as annotations on the pull request. Every rule is declared in the tool's rule list. + +### Paths + +By default the scan covers `discovery.paths`, `['app']` relative to the project root: + +```php +'discovery' => [ + 'paths' => ['app'], +], +``` + +`--path` scans other directories instead, absolute or relative, and may be repeated: + +```bash +php artisan monitor:points --path=app/ControlPoints --path=packages/billing/src +``` + +### Checking + +```bash +php artisan monitor:points --check +``` + +`--check` exits 1 when any rule reported an error, and 0 otherwise. Warnings are printed but never fail the check. Combine it with a format when a machine reads the output: + +```bash +php artisan monitor:points --check --format=sarif > monitor.sarif +``` + +## The Rules + +Every rule is on unless switched off in `inventory.rules`: + +```php +'inventory' => [ + 'rules' => [ + 'catch_all_without_escalation' => false, + ], +], +``` + +| Rule | Level | Reports | +| --- | --- | --- | +| `duplicate_names` | error | `"payment.charge" is declared 2 times: App\ControlPoints\Payments\ChargeCard:14, App\Services\Legacy\Charger:41`. A name is the join key across code, records and tests; two points sharing one cannot be told apart anywhere downstream. | +| `name_pattern` | error | `"ChargeCard" does not match the configured point name pattern`. Names must match `point_name_pattern`, dotted lowercase by default. | +| `missing_escalation` | error | `"payment.refund" declares no escalation and no catch-all; an unexpected failure would leave silently`. Class-form points only. | +| `catch_all_without_escalation` | warning | `"filing.submit" recovers from Throwable with no escalation; every failure is swallowed`. Class-form points only. | +| `critical_namespace_uncontrolled` | error | `App\ControlPoints\Filings\Uncontrolled sits in a critical namespace but is not a control point and calls none`. See [Critical Namespaces](#critical-namespaces). | +| `dynamic_name` | warning | `a control point in App\Services\Sync takes its name from an expression; use a string literal`. Inline points only. | +| `unreadable_control` | error | `"payment.capture": control() could not be read statically: ...`. The class's `control()` threw when called without its constructor, so nothing else about the point can be trusted. | + +The rules about risks and escalation see the class form only, because an inline point's closures are not inspected. That is why the guidelines send critical operations to the class form. + +## Critical Namespaces + +`critical_namespaces` lists namespaces whose classes are expected to be control points: + +```php +'critical_namespaces' => [ + 'App\\ControlPoints', + 'App\\Services\\Payments', +], +``` + +A class in one of them passes when it extends `ControlPoint`, or contains an inline `Monitor::control()` call. Interfaces, traits, enums, abstract classes, `Escalation` implementations and `Policy` implementations are not operations and are exempt. The same list drives the Pest expectation and the PHPStan rule described in [Testing](testing.md). With the list empty, the rule reports nothing. + +## monitor:explain + +Describes one point's contract in plain words, for a person or an agent: + +```bash +php artisan monitor:explain payment.charge +``` + +``` +payment.charge is a control point class, App\ControlPoints\Payments\ChargeCard, in the Payments domain with the "external" profile. +Policies: the "stripe" breaker opens after 5 failures within 60s for 120s; retry 2 time(s) with 200ms backoff. +Limits: should finish within 10s; the result must satisfy "charge must be settled". +It recovers from App\Exceptions\CardDeclined, App\Exceptions\InsufficientFunds. +App\Escalations\PagePayments is called when it escalates. +Last 24 hours: 40 succeeded, 1 recovered. +``` + +The history line appears when the [outcome store](store.md) is enabled. An inline point is described by its location and the note that only its name is known statically. `--json` prints the same description the JSON format of `monitor:points` uses for that point. The command exits 1 when no point has that name. + +## make:control-point + +Generates a control point class and its test: + +```bash +php artisan make:control-point Payments/RefundCard +php artisan make:control-point Payments/RefundCard --point=payment.refund --profile=external +php artisan make:control-point Payments/RefundCard --no-test +php artisan make:control-point Payments/RefundCard --force +``` + +The class goes under `App\ControlPoints`, so `Payments/RefundCard` becomes `app/ControlPoints/Payments/RefundCard.php`. The test goes to `tests/Feature/ControlPoints/Payments/RefundCardTest.php`. + +| Option | Meaning | +| --- | --- | +| `--point=` | The point name. Without it the name is derived: the directory segments in snake case, a dot, and the class in snake case, so `Payments/RefundCard` becomes `payments.refund_card` and a class with no directory gets `app.`. | +| `--profile=` | Adds `profile: '...'` to the attribute. | +| `--no-test` | Skip the test. | +| `--force` | Overwrite an existing class and test. Without it, an existing class is reported and nothing is written. | + +The class stub carries the `#[Point]` attribute, an empty constructor, a `control()` with a commented `recover()` and `ensure()` and an `escalate()` that reports the exception, a `context()` returning an empty array, and an empty `handle()`. The test stub already uses `Monitor::fake()`: one case asserting the point succeeds and one making it fail with a `RuntimeException` and asserting it escalates. Fill in the risks and the test grows one case per risk. + +## In CI + +Run the check on every pull request and upload the SARIF so findings appear inline: + +```yaml +name: Control points + +on: + pull_request: + +permissions: + contents: read + security-events: write + +jobs: + points: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + + - run: composer install --no-interaction --prefer-dist + + - name: Check control points + run: php artisan monitor:points --check --format=sarif > monitor.sarif + continue-on-error: true + id: check + + - uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: monitor.sarif + + - name: Fail on errors + if: steps.check.outcome == 'failure' + run: exit 1 +``` + +The check step is allowed to fail so the upload still happens; the last step fails the job afterwards. diff --git a/docs/jobs.md b/docs/jobs.md new file mode 100644 index 0000000..44bf720 --- /dev/null +++ b/docs/jobs.md @@ -0,0 +1,86 @@ +# Jobs + +- [Introduction](#introduction) +- [What a Job Inherits](#what-a-job-inherits) +- [Running a Point on the Queue](#running-a-point-on-the-queue) + - [RunControlPoint](#runcontrolpoint) + - [Horizon Tags](#horizon-tags) + - [When the Breaker Is Open](#when-the-breaker-is-open) +- [Waiting for a Breaker](#waiting-for-a-breaker) +- [Why the Queue Keeps Its Retries](#why-the-queue-keeps-its-retries) +- [The Store in a Worker](#the-store-in-a-worker) + +## Introduction + +Most critical operations run on the queue. Monitor does not replace the queue's own retry, backoff and failure handling; it runs inside a job, or as one, and leaves the job's lifecycle to Laravel. This page covers what a job inherits from the process that dispatched it, how to run a control point class as a job, and how to keep a job from retrying into a dependency whose circuit is open. + +## What a Job Inherits + +Laravel's Context is serialised with a job and restored when the job runs. Two things travel that way: + +- **The trace id**, so every record the job writes shares the id of the request that queued it. A job dispatched from a process with no trace gets one of its own when it starts. +- **The dispatching run**, not the stack. A job dispatched from inside `order.place` would otherwise start with `order.place` on its stack and report a `parent_run_id` for a run that ended in another process. Monitor listens to `Illuminate\Queue\Events\JobProcessing` with `Kirschbaum\Monitor\Trace\PicksUpJobTrace`, which picks up the trace and then calls `ControlStack::handOff()`: the inherited stack is cleared and the innermost run id is kept in Context as `dispatched_from_run`. + +So a point run inside a job has no parent, its `stack` holds only itself, and every log line the job writes, Monitor's records and the application's own, carries `dispatched_from_run` alongside `trace_id`. `Monitor::stack()->dispatchedFrom()` returns it in code. + +## Running a Point on the Queue + +A control point class can be dispatched like a job: + +```php +use App\ControlPoints\Payments\ChargeCard; + +ChargeCard::dispatch($invoice, $amount); // queued +ChargeCard::dispatch($invoice, $amount)->onQueue('payments'); +ChargeCard::dispatchSync($invoice, $amount); // through the queue, now +``` + +`dispatch(...$arguments)` constructs the point with the arguments, wraps it in `Kirschbaum\Monitor\Queue\RunControlPoint` and returns Laravel's `PendingDispatch`, so `onQueue()`, `onConnection()`, `delay()` and `afterCommit()` work as they do for any job. `dispatchSync(...$arguments)` runs it through the queue synchronously and returns what the job returned. + +### RunControlPoint + +`Kirschbaum\Monitor\Queue\RunControlPoint` is a queued job with two public properties: `point`, the `ControlPoint` instance, and `releaseWhenRefused`, which is `true` by default. Its `handle()` executes the point: + +| The run | The job | +| --- | --- | +| Succeeded or recovered | Completes normally and returns the `Outcome`. | +| Escalated | Throws the exception that escaped, so the job fails the way a job fails: `failed_jobs`, the job's `failed()` method and the queue's retries all see exactly what the point saw. | +| Refused by an open breaker | Releases the job for the breaker's retry-after instead of failing it. With `releaseWhenRefused` set to `false` it throws `BreakerOpen` instead. | + +The point keeps its policies, corrections, limits, escalation and records; the job keeps its queue, connection, tries and backoff. Nothing about the point changes because it ran on a worker. + +### Horizon Tags + +`displayName()` is the point name, so the queue and Horizon show `payment.charge` rather than the wrapper class. `tags()` returns `monitor:{point}` and `domain:{domain}`, so Horizon groups failures by operation and by domain. + +### When the Breaker Is Open + +A refused run does not fail the job. `RunControlPoint` calls `release()` with the seconds left on the circuit, so the job comes back when the breaker may let a probe through rather than on the queue's own backoff. Every release counts as an attempt, so the job's `$tries` or `retryUntil()` still bounds how long it waits. + +## Waiting for a Breaker + +A job that is not a control point can wait for a circuit the same way, with the `Kirschbaum\Monitor\Queue\Middleware\WaitForBreaker` job middleware: + +```php +use Kirschbaum\Monitor\Queue\Middleware\WaitForBreaker; + +class SyncToCrm implements ShouldQueue +{ + public function middleware(): array + { + return [new WaitForBreaker('crm')]; + } +} +``` + +While the `crm` circuit is open the middleware releases the job for the breaker's retry-after, at least `minimumDelay` seconds, and does not run it; when the circuit is closed or due for a probe the job runs. `new WaitForBreaker('crm', minimumDelay: 30)` raises the floor. As with `RunControlPoint`, each release is an attempt. + +The middleware reads the circuit through `isOpen()` and never consumes the half-open probe; the first control point or `Http::breaker()` call through does that. + +## Why the Queue Keeps Its Retries + +A job's `$tries`, `backoff()`, `retryUntil()`, `failed()` and the failed-jobs table are a complete retry and escalation story for the job as a whole. A control point's `Retry` policy retries the operation inside one attempt of the job; the queue retries the job. Do not wrap a job's `handle()` in a point to get retries, and do not give a point inside a job a `Retry` policy that duplicates the job's backoff. Wrap the critical operation so its outcome is recorded and its breaker is consulted, and let the queue own the job. + +## The Store in a Worker + +The [outcome store](store.md) buffers rows and writes them after each job (`JobProcessed` and `JobExceptionOccurred`), on `Illuminate\Queue\Events\Looping`, on `WorkerStopping`, and whenever the buffer reaches one hundred outcomes, so a long-running worker never holds an unbounded buffer and a stopped worker writes what it had. diff --git a/docs/policies-and-limits.md b/docs/policies-and-limits.md new file mode 100644 index 0000000..bbe92bf --- /dev/null +++ b/docs/policies-and-limits.md @@ -0,0 +1,151 @@ +# Policies and Limits + +- [Introduction](#introduction) +- [Policies](#policies) + - [Retry](#retry) + - [Transaction](#transaction) + - [Breaker](#breaker) + - [Once](#once) + - [Pipeline Order](#pipeline-order) + - [policy()](#policy) +- [Limits](#limits) + - [within()](#within) + - [attempts()](#attempts) + - [ensure()](#ensure) +- [Profiles](#profiles) + - [The Shipped Profiles](#the-shipped-profiles) + - [How a Profile Merges](#how-a-profile-merges) + +## Introduction + +A **policy** is a reusable behaviour wrapped around the attempt: retry it, run it in a transaction, refuse it while a circuit is open. A **limit** is a threshold the run should stay within. Policies decide whether the operation runs again; limits decide what is recorded, and in one case whether a completed run counts as a failure. + +Every policy and limit can be described without running, which is what the inventory prints. + +## Policies + +### Retry + +```php +->retry(times: 2, backoffMs: 200, multiplier: 2.0, jitter: true, on: [], except: []) +``` + +Attempts the operation again after a failure, up to `times` more attempts. The delay before the next attempt is `backoffMs × multiplier^(tries − 1)`, and with `jitter` it is a random value between half of that and all of it. Sleeping goes through Laravel's `Sleep`, so `Sleep::fake()` works in tests. + +`on` restricts retries to those exception classes; `except` never retries those. Both match with `instanceof`. An empty `on` means every exception is retried. + +Each retry policy counts its own tries. The `attempts()` limit caps the total for the run whatever the policies ask. When retries are exhausted, the exception that ended the last attempt is the one that reaches the corrections, so `recover(DeadlockException::class, ...)` matches what actually happened rather than a wrapper. + +Every retry fires `PointRetried` with the attempt that failed and the backoff used, and adds a `retried` entry to the outcome's timeline. The outcome's `attempts` is the total. + +The policy object is `Kirschbaum\Monitor\Policies\Retry`: `new Retry(times: 2)` or `Retry::times(2)`, then `->backoff(200, 2.0, true)->on([...])->except([...])`. + +### Transaction + +```php +->transaction(retries: 0, on: [DeadlockException::class], except: [], connection: null) +``` + +Runs the operation inside `DB::transaction()` on the given connection (the default when `null`), and when it fails with a retryable exception, rolls back and runs the whole transaction again. Only `Illuminate\Database\DeadlockException` is retried by default, because most failures inside a transaction are not made better by repeating them. + +A transaction's retries are counted like a retry policy's, so `retries: 2` can make three attempts. The policy object is `Kirschbaum\Monitor\Policies\Transaction`: `new Transaction(retries: 2)` or `Transaction::retries(2)`, then `->on([...])->except([...])->connection('tenant')->backoff(50)`; `backoff()` is a flat delay with jitter between attempts. + +The transaction sits inside any retry policy in the pipeline, so each retry gets a fresh transaction rather than retrying inside one that has already failed. + +### Breaker + +```php +->breaker('stripe', after: 3, within: 60, for: 120) +``` + +Refuses to attempt the operation while the named circuit is open. `after` failures inside `within` seconds open it; it stays open `for` seconds, then lets one probe through: a success closes it, a failure opens it again for a full period. Arguments left `null` take the defaults in `breakers`. + +The breaker is the outermost policy, so it sees one failure per run, after retries: an operation that was retried and then succeeded is a success. `on` and `except` on the policy object decide which exceptions count as failures, so a declined card need not count against a gateway: + +```php +use Kirschbaum\Monitor\Policies\Breaker; + +->policy(Breaker::named('stripe')->after(3, within: 60)->for(120)->except([CardDeclined::class])) +``` + +When the circuit is open the run ends `Refused` with a `BreakerOpen` exception and nothing is attempted; see [Risks and Corrections](risks-and-corrections.md#risks-monitor-raises). The state machine, the shared cache store and the standalone API are in [Breakers](breakers.md). + +### Once + +```php +->once('invoice:'.$invoice->id, ttl: 3600) +``` + +Run at most once per idempotency key inside a window. `once(string $key, int $ttl = 3600)` adds `Kirschbaum\Monitor\Policies\Once`, which claims the key in the cache with `Cache::add` before anything executes. A second run with the same key inside the window is refused before its callback runs, with a `Kirschbaum\Monitor\Risks\Duplicate` risk whose `$key` and `$originalRunId` say which run holds it; recover from it like any other risk: + +```php +Monitor::control('payment.charge', $this) + ->once('invoice:'.$invoice->id) + ->recover(Duplicate::class, fn (Duplicate $e) => ChargeResult::alreadyCharged($e->originalRunId)) + ->run(...); +``` + +The key is released when the run fails inside the policies, whether with an escalation or a declared risk, so a declined card or a gateway timeout can be tried again. It is kept when the run completed, even if `ensure()` then rejects the result, because the side effect has happened. It is the outermost policy, so a run the breaker refuses never claims a key. + +Keys are `once.prefix` (`monitor:once:` by default), then the point name, a colon and the key, in the cache store named by `once.store`; they are shared exactly as far as that store is. `Once::key($key, $ttl)` is the object form for `policy()`. The inventory describes it as `type`, `key` and `ttl`, and the timeline of a refused run notes `once.duplicate`. + +### Pipeline Order + +Policies wrap the attempt outermost first, ordered by `Policy::order()` ascending: `Once` is 50, `Breaker` 100, `Retry` 200, `Transaction` 300. Declaration order does not matter. `Control::resolvedPolicies()` returns the effective list in pipeline order. + +### policy() + +`policy(Policy $policy)` adds any object implementing `Kirschbaum\Monitor\Contracts\Policy`. A shipped policy passed this way replaces the one of the same type declared with the sugar method, so `->retry(times: 0)->policy(Retry::times(2))` retries twice. Writing your own policy is in [Extending](extending.md#a-custom-policy). + +## Limits + +### within() + +```php +->within(seconds: 5) +``` + +The whole run, attempts included, should finish inside this many seconds (a float is accepted). A breach is recorded on the outcome under `limitsBreached['duration']` with the threshold and actual in milliseconds, fires `PointLimitBreached`, and is written as a `point.limit` record at `warning`. It never fails the run: a completed side effect is not undone because it was slow, and PHP cannot pre-empt a running call. A slow success is a success that is out of control, and the record says exactly that. + +### attempts() + +```php +->attempts(3) +``` + +A hard cap on attempts for the run, whatever the retry policies ask for. When the cap is what stopped a failing run, the outcome records `limitsBreached['attempts']` with the cap and the attempts made. A successful run at the cap is not a breach. + +### ensure() + +```php +->ensure(fn (ChargeResult $r): bool => $r->settled, 'charge must be settled') +``` + +A post-condition on the returned value. It is the only limit that fails a run, because it judges the result rather than the road to it: a call that returned 200 with a failing body becomes a failure with `Kirschbaum\Monitor\Risks\EnsureFailed`, which goes through `recover()` and `escalate()` like any other. Several `ensure()` calls are checked in order and the first to fail wins. + +`ensure()` runs after every policy, so a failed check is never retried and never re-runs a completed side effect. The reason defaults to `result did not satisfy ensure()`. + +## Profiles + +A profile is a named bundle of policies and limits in `profiles`. `->profile('external')` applies it, and the `#[Point(..., profile: 'external')]` attribute does the same for a class. An unknown name throws `InvalidProfile` at declaration time. + +### The Shipped Profiles + +| Profile | Retry | Transaction | Breaker | within | +| --- | --- | --- | --- | --- | +| `external` | 2 times, 200 ms, ×2, jitter | | after 5 within 60 s, for 120 s, named after the point | 10 s | +| `database` | | 2 retries on `DeadlockException` | | 5 s | +| `messaging` | 3 times, 500 ms, ×2, jitter | | | 15 s | +| `internal` | | | | 2 s | + +A profile's breaker takes the point's name unless the profile sets `breaker.name`. Each profile key is optional; omit a key to leave that policy or limit unset. `attempts` is also accepted as a profile key. + +### How a Profile Merges + +The profile's policies come first, then the point's own declarations. A policy declared on the point replaces the profile's policy of the same type; a limit declared on the point replaces the profile's limit of the same kind. So: + +```php +Monitor::control('payment.charge')->profile('external')->retry(times: 1)->within(2) +``` + +runs with the profile's breaker, a retry of one instead of two, and a two-second limit instead of ten. `describe()` shows the merged result. diff --git a/docs/records.md b/docs/records.md new file mode 100644 index 0000000..f184ed5 --- /dev/null +++ b/docs/records.md @@ -0,0 +1,249 @@ +# Records + +- [Introduction](#introduction) +- [The Record Shape](#the-record-shape) + - [Fields on Every Point Event](#fields-on-every-point-event) + - [Fields by Event](#fields-by-event) + - [Breaker Events](#breaker-events) +- [Events and Levels](#events-and-levels) +- [The Message](#the-message) +- [Redaction](#redaction) +- [Exception Traces](#exception-traces) +- [The Channel](#the-channel) +- [NDJSON with the JSON Tap](#ndjson-with-the-json-tap) +- [The Origin-Bound Logger](#the-origin-bound-logger) +- [Events](#events) +- [The Schema File](#the-schema-file) + +## Introduction + +Every transition of a control point produces one record: it started, it was retried, it breached a limit, it recovered, it escalated, it was refused, it ended. Every circuit breaker transition produces one too. A record is written as a log line whose context carries a fixed set of fields, so a log backend indexes `point`, `domain` and `status` as fields rather than finding them inside free text. + +The fields, the event names and the status values are fixed by the schema at `resources/schema/record-1.json`, versioned as `monitor/1`. Nothing downstream needs to parse a message. + +## The Record Shape + +A record is the context array of one log line. The `Recorder` subscribes to Monitor's events and writes one line per event; the message is human readable, and everything a machine needs is in the context. + +### Fields on Every Point Event + +| Field | Type | Meaning | +| --- | --- | --- | +| `schema` | string | Always `monitor/1`. | +| `event` | string | One of the event names below. | +| `point` | string | The control point name, e.g. `payment.charge`. | +| `run_id` | string | ULID of this run. | +| `parent_run_id` | string or null | ULID of the enclosing run when the point is nested inside another. | +| `stack` | list of strings | Point names from the outermost to this one. | +| `trace_id` | string | 32 lowercase hex characters. See [Tracing](tracing.md). | +| `domain` | string | The business area the point belongs to, derived from its origin's namespace. | +| `origin` | string | The fully qualified class the point belongs to. | +| `profile` | string or null | The profile the point started from, if any. | +| `context` | object | The point's context, redacted. | + +### Fields by Event + +| Event | Extra fields | +| --- | --- | +| `point.started` | `attempt` (always `1`). | +| `point.retried` | `attempt` (the attempt that failed), `backoff_ms`, `exception`. | +| `point.limit` | `limit` with `name` (`duration` or `attempts`), `threshold` and `actual`. | +| `point.recovered` | `status`, `attempts`, `duration_ms`, `limits_breached`, `exception`, `risk` (the exception class the correction handled). | +| `point.escalated` | `status`, `attempts`, `duration_ms`, `limits_breached`, `exception`. | +| `point.refused` | `status`, `attempts`, `duration_ms`, `limits_breached`, `exception`, `breaker` with `name`, `state` and `retry_after_s`. | +| `point.ended` | `status`, `attempts`, `duration_ms`, `limits_breached`, and `exception` when the run failed. | +| `escalation.failed` | Everything `point.ended` carries, plus `escalation_exception`: the exception the escalation handler itself threw. The original failure still propagates. | +| `escalation.throttled` | Everything `point.ended` carries, plus `throttle_seconds`: an escalation was due but one for the same point already fired inside the window. Written at notice. | + +`status` is one of `succeeded`, `recovered`, `escalated` or `refused`. `limits_breached` is an object keyed by limit name, each holding `threshold` and `actual`; it is empty when nothing was breached. + +`exception` is a summary, never the object: `class`, `message`, `file`, `line`, `code`, an optional `previous` with the class and message one level down, and a `trace` only when [configured](#exception-traces). + +Every outcome event is followed by `point.ended`, so a consumer that only wants one line per run listens for `point.ended` and reads `status`. + +### Breaker Events + +`breaker.opened`, `breaker.half_open` and `breaker.closed` carry `schema`, `event` and a `breaker` object with `name`, `state` (`closed`, `open` or `half_open`), `failures` (failures inside the window) and `open_for` (seconds the circuit was opened for, or null). See [Breakers](breakers.md). + +## Events and Levels + +Each event is written at the level in `records.levels`. The defaults: + +| Event | Level | +| --- | --- | +| `point.started` | debug | +| `point.retried` | notice | +| `point.limit` | warning | +| `point.recovered` | warning | +| `point.refused` | warning | +| `point.escalated` | error | +| `point.ended` | info | +| `escalation.failed` | critical | +| `breaker.opened` | error | +| `breaker.half_open` | notice | +| `breaker.closed` | info | + +Change any of them in `config/monitor.php`: + +```php +'records' => [ + 'levels' => [ + 'point.started' => 'debug', + 'point.ended' => 'notice', + // ... + ], +], +``` + +An event missing from the map is written at `info`. + +## The Message + +The message is for a person tailing a file. It starts with `[Domain:Origin]`, where Origin is the short class name, then the point name, then what happened: + +``` +[Payments:StripeCharger] payment.charge started +[Payments:StripeCharger] payment.charge attempt 1 failed with App\Exceptions\GatewayTimeout, retrying after 200ms +[Payments:StripeCharger] payment.charge breached the duration limit: 14012.4 against 5000 +[Payments:StripeCharger] payment.charge recovered from App\Exceptions\CardDeclined in 893.2ms +[Payments:StripeCharger] payment.charge escalated App\Exceptions\GatewayDown after 3 attempt(s) in 4120ms +[Payments:StripeCharger] payment.charge refused: breaker stripe is open +[Payments:StripeCharger] payment.charge succeeded in 412ms +[Payments:StripeCharger] payment.charge escalation handler threw LogicException; the original failure still propagates +[Monitor] breaker stripe is now open +``` + +## Redaction + +Before a record is written, its `context` and the `message` of any exception summary go through [Kirschbaum Redactor](https://github.com/kirschbaum-development/redactor) with the profile in `records.redaction`. The default is Redactor's `observability` profile, which is built for this: it replaces credentials and personal data and leaves identifiers alone, so records stay joinable on `run_id`, `trace_id` and the like. No `_redacted` marker is added. + +Only application data is redacted. Point names, run ids, trace ids, domains and origins are never touched. + +```php +'records' => [ + 'redaction' => env('MONITOR_REDACTION_PROFILE', 'observability'), +], +``` + +Set it to `null` or an empty string to write context and messages as they are. Any other value must be a profile Redactor knows. + +## Exception Traces + +By default an exception summary has no stack trace. `records.exception_trace` controls that: + +| Value | Behaviour | +| --- | --- | +| `never` | No trace. The default. | +| `debug` | A trace when `app.debug` is true. | +| `always` | A trace in every environment. | + +When a trace is included it is cut to `records.exception_trace_lines` lines (15 by default), and `exception.trace_truncated` says how many were dropped. + +```php +'records' => [ + 'exception_trace' => env('MONITOR_EXCEPTION_TRACE', 'never'), + 'exception_trace_lines' => 15, +], +``` + +## The Channel + +Records go to the application's default log channel unless `records.channel` names another: + +```php +'records' => [ + 'channel' => env('MONITOR_LOG_CHANNEL'), +], +``` + +The channel must exist in `config/logging.php`. Laravel's [Context](https://laravel.com/docs/context) is attached to every record like any other log line, so the `trace_id` and `control_point` that Monitor keeps there appear in the record's extra data too. + +## NDJSON with the JSON Tap + +Laravel's default formatter writes the message followed by the context as JSON. For a backend that wants one JSON object per line with the record's fields at the top level, add the tap to the channel: + +```php +'channels' => [ + 'monitor' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/monitor.log'), + 'level' => 'debug', + 'days' => 14, + 'tap' => [Kirschbaum\Monitor\Logging\JsonTap::class], + ], +], +``` + +`JsonTap` installs `RecordFormatter` on every formattable handler of the channel. The formatter writes `ts`, `level`, `channel` and `message` first, then every key from the record's extra data, then every context key; a context key wins over an extra key of the same name. A line that cannot be encoded is replaced rather than dropped, so a record with bad bytes still leaves a line. + +```json +{"ts":"2026-09-14T18:10:25.011+00:00","level":"warning","channel":"monitor","message":"[Payments:StripeCharger] payment.charge recovered from App\\Exceptions\\CardDeclined in 893.2ms","schema":"monitor/1","event":"point.recovered","point":"payment.charge","run_id":"01K5A3ZR7Q8V1J3M0X6Y2N9WCD","parent_run_id":null,"stack":["payment.charge"],"trace_id":"7f3a2c1d9e8b4a6f0c5d1e2f3a4b5c6d","domain":"Payments","origin":"App\\Services\\Payments\\StripeCharger","profile":"external","context":{"invoice":48211,"amount":12900},"status":"recovered","attempts":2,"duration_ms":893.2,"limits_breached":{},"exception":{"class":"App\\Exceptions\\CardDeclined","message":"Card declined: insufficient_funds","file":"/app/app/Services/Payments/StripeCharger.php","line":41,"code":0},"risk":"App\\Exceptions\\CardDeclined"} +``` + +Point the channel at `records.channel` and every record lands there in this shape. The tap is also fine on a channel the rest of the application writes to; an ordinary log line simply has fewer fields. + +## The Origin-Bound Logger + +Records cover control points. For any other line you want grouped by domain, `Monitor::log()` returns a PSR-3 logger bound to an origin: + +```php +use Kirschbaum\Monitor\Facades\Monitor; + +Monitor::log($this)->info('Index rebuilt', ['documents' => 4120]); +``` + +The origin is the class of the object passed, or the string passed. Inside a running control point the origin can be left out: `Monitor::log()` binds to the innermost point's origin, so an ad hoc line inside `payment.charge` carries the same `origin` and `domain` as the point's records. Outside a point, `Monitor::log()` with no origin uses the `Kirschbaum\Monitor\Monitor` class. The line is written as: + +``` +[Search:Indexer] Index rebuilt {"documents":4120,"origin":"App\\Services\\Search\\Indexer","domain":"Search"} +``` + +The prefix is `[Domain:ShortClass]`, and `origin` and `domain` are added to the context so a backend can filter on them. Domain resolution is the same as for control points; see [Configuration](configuration.md). + +`with()` returns a logger that adds context to every line it writes, and `channel()` one that writes to a named channel: + +```php +$log = Monitor::log($this)->with(['tenant' => $tenant->id])->channel('search'); + +$log->info('Index rebuilt'); +$log->warning('Index stale', ['age_minutes' => 42]); +``` + +Both return new instances; the original is unchanged. The logger implements `Psr\Log\LoggerInterface`, so it can be handed to anything that accepts one, and uses `Conditionable`, so `when()` and `unless()` work on it. A level that is neither a string nor `Stringable` is written at `info`. + +## Events + +Every record is written by a listener, and the events are public. Alerting, metrics and anything else that reacts to a control point should listen to the events rather than to log lines. + +| Event | Carries | +| --- | --- | +| `Kirschbaum\Monitor\Events\PointStarted` | `run`: a `RunInfo` with the point, run id, parent id, trace id, domain, origin, profile, stack and context. | +| `Kirschbaum\Monitor\Events\PointRetried` | `run`, `exception`, `attempt` (the attempt that failed), `backoffMs`. | +| `Kirschbaum\Monitor\Events\PointLimitBreached` | `run`, `limit` (`duration` or `attempts`), `threshold`, `actual`. | +| `Kirschbaum\Monitor\Events\PointRecovered` | `outcome`. | +| `Kirschbaum\Monitor\Events\PointEscalated` | `outcome`. | +| `Kirschbaum\Monitor\Events\PointRefused` | `outcome`. | +| `Kirschbaum\Monitor\Events\PointEnded` | `outcome`. Dispatched after the status event, for every run. | +| `Kirschbaum\Monitor\Events\EscalationFailed` | `outcome`, `exception`: what the escalation handler threw. | +| `Kirschbaum\Monitor\Events\EscalationThrottled` | `outcome`, `seconds`: an escalation skipped by `throttleEscalation()`. Recorded as `escalation.throttled`. | +| `Kirschbaum\Monitor\Events\BreakerOpened` | `breaker` (the name), `state`: a `BreakerState`. | +| `Kirschbaum\Monitor\Events\BreakerHalfOpen` | `breaker`, `state`. | +| `Kirschbaum\Monitor\Events\BreakerClosed` | `breaker`, `state`. | + +`Outcome` is described in [Getting Started](getting-started.md#the-outcome); its `startedAt` and `endedAt` are `CarbonImmutable` instances, in `toArray()` as `started_at` and `ended_at`, and the store writes both. Records carry `duration_ms` rather than the two timestamps. `Kirschbaum\Monitor\RunInfo` is the readonly object the in-progress events carry, with public `point`, `id`, `parentId`, `traceId`, `domain`, `origin`, `profile`, `stack` and `context`; `Outcome::info()` returns the same object for a finished run. A listener that pages on escalation: + +```php +use Kirschbaum\Monitor\Events\PointEscalated; + +Event::listen(PointEscalated::class, function (PointEscalated $event): void { + if ($event->outcome->domain === 'Payments') { + Pager::page('payments', $event->outcome->toArray()); + } +}); +``` + +Recovered runs do not dispatch `PointEscalated`, and a point's own `escalate()` handler is separate from these events; see [Risks and Corrections](risks-and-corrections.md). The [outcome store](store.md) is itself a listener on `PointEnded`. + +## The Schema File + +`resources/schema/record-1.json` is a JSON Schema (draft 2020-12) for the record's context. It lists every field, the event enumeration and the status enumeration. It is also served to agents as an MCP resource; see [Agents](agents.md). diff --git a/docs/risks-and-corrections.md b/docs/risks-and-corrections.md new file mode 100644 index 0000000..a883b26 --- /dev/null +++ b/docs/risks-and-corrections.md @@ -0,0 +1,216 @@ +# Risks and Corrections + +- [Introduction](#introduction) +- [recover()](#recover) + - [The Return Value Is the Result](#the-return-value-is-the-result) + - [Order and Matching](#order-and-matching) + - [The Handler's Arguments](#the-handlers-arguments) + - [Throwing From a Correction](#throwing-from-a-correction) + - [Corrections as Classes](#corrections-as-classes) +- [The Catch-All](#the-catch-all) +- [escalate()](#escalate) + - [Escalation Classes](#escalation-classes) + - [Escalating on a Breached Limit](#escalating-on-a-breached-limit) + - [Throttling Escalation](#throttling-escalation) + - [When the Escalation Fails](#when-the-escalation-fails) +- [Risks Monitor Raises](#risks-monitor-raises) + +## Introduction + +A **risk** is an exception class the operation expects. Its **correction** is a handler whose return value becomes the result of the point. A failure with a declared risk ends the run as `Recovered`; a failure with no matching risk ends it as `Escalated`, calls the escalation, and propagates. There is no third state and no way for a failure to leave quietly. + +## recover() + +```php +Monitor::control('payment.charge') + ->recover(CardDeclined::class, fn (CardDeclined $e) => ChargeResult::declined($e->code)) + ->recover(InsufficientFunds::class, fn () => ChargeResult::insufficientFunds()) + ->run(fn () => $stripe->charge($amount)); +``` + +`recover(string $class, Closure $handler)` takes a `Throwable` class name and a handler. A class that is not a `Throwable` throws `InvalidControlPoint` at declaration time. + +### The Return Value Is the Result + +Whatever the handler returns is what `run()` returns and what `Outcome::$value` holds. There are no sentinels: `null`, `false`, `0` and `''` are all values. + +```php +$value = Monitor::control('cache.warm') + ->recover(CacheUnavailable::class, fn () => null) + ->run(fn () => $this->warm()); + +// $value === null; the outcome is Recovered, not Escalated. +``` + +The outcome's `recoveredFrom` is the risk class that matched, and `exception` is the failure the correction handled. + +### Order and Matching + +Risks are matched with `instanceof`, in declaration order, and the first match wins. Declare the specific class before the general one: + +```php +->recover(CardDeclined::class, ...) // matched for CardDeclined +->recover(PaymentException::class, ...) // matched for every other PaymentException +``` + +Declared the other way round, `PaymentException` would catch the declined card too. + +### The Handler's Arguments + +The handler receives the exception and a provisional `Outcome`: + +```php +->recover(CardDeclined::class, function (CardDeclined $e, Outcome $partial) { + $partial->point; // 'payment.charge' + $partial->context; // what with() recorded + $partial->attempts; // attempts made before this failure + $partial->durationMs; // time so far + + return ChargeResult::declined($e->code); +}) +``` + +The provisional outcome has `status` `Escalated` and no value, because it describes the failure as it stands before the correction runs. The final outcome the point returns is `Recovered`. + +### Throwing From a Correction + +A handler that throws escalates with the exception it threw; the original failure is not the one that propagates. To escalate the original, rethrow it: + +```php +->recover(CardDeclined::class, function (CardDeclined $e) { + if ($e->code === 'fraud') { + throw $e; // escalates CardDeclined + } + + return ChargeResult::declined($e->code); +}) +``` + +A correction that throws is noted on the outcome's timeline as `correction.threw`. + +### Corrections as Classes + +A correction can be a class instead of a closure, so it can be injected, reused across points and named in the inventory. It implements `Kirschbaum\Monitor\Contracts\Correction` and is resolved from the container when the risk occurs: + +```php +use Kirschbaum\Monitor\Contracts\Correction; + +class DeclineHandler implements Correction +{ + public function __construct(private readonly Notifier $notifier) {} + + public function __invoke(Throwable $exception, Outcome $outcome): mixed + { + $this->notifier->cardDeclined($outcome->context['invoice']); + + return ChargeResult::declined($exception->code); + } +} + +->recover(CardDeclined::class, DeclineHandler::class) +``` + +The return value is the result, exactly as with a closure. Passing a class that does not implement `Correction` throws `InvalidControlPoint` at declaration time. `describe()` lists corrections under `corrections`, as the class name or `closure`. See [Extending](extending.md#a-custom-correction) for a fuller example. + +## The Catch-All + +`recover(Throwable::class, fn () => ...)` declared last is the explicit catch-all: every failure becomes that value and nothing escalates. + +```php +->recover(Throwable::class, fn () => $this->cache->get('search.results', [])) +``` + +`Control::hasCatchAll()` reports it, and the inventory flags a class-form point that has one and no escalation with the `catch_all_without_escalation` warning, because a catch-all with nowhere to escalate is usually a risk analysis that has not been done. Keep it when the fallback really is fine for every failure, and say so with an escalation that at least records it. + +## escalate() + +```php +->escalate(function (Outcome $outcome) { + Alerts::page('payments', $outcome); +}) +``` + +`escalate(Closure|string $escalation)` names who is told when a failure no correction covers gets out. It is called once, after the outcome is complete and its events have fired, and before the exception propagates. It is not called for recovered or refused runs. + +The inventory's `missing_escalation` rule reports a class-form point with no escalation and no catch-all, because the one thing a critical operation must never do is fail without anyone being told. + +### Escalation Classes + +A class implementing `Kirschbaum\Monitor\Contracts\Escalation` is resolved from the container, so it can take constructor dependencies: + +```php +use Kirschbaum\Monitor\Contracts\Escalation; +use Kirschbaum\Monitor\Outcome; + +class PagePayments implements Escalation +{ + public function __construct(private readonly Pager $pager) {} + + public function handle(Outcome $outcome): void + { + $this->pager->page('payments', $outcome->point, $outcome->exception); + } +} + +->escalate(PagePayments::class) +``` + +Passing a class name that does not implement `Escalation` throws `InvalidControlPoint` at declaration time. The inventory shows the class name; a closure shows as `closure`. + +### Escalating on a Breached Limit + +`within()` records a slow run without failing it. To have a run that completed but breached a limit reach the escalation as well, so a charge that took fourteen seconds is seen by the same people as a charge that failed: + +```php +->within(5) +->escalate(PagePayments::class) +->escalateLimits() +``` + +`escalateLimits(bool $escalate = true)` hands any outcome with an entry in `limitsBreached` to the escalation after its events have fired. The outcome's status is still `succeeded` or `recovered`; the handler can tell a breach from a failure by `$outcome->exception` being null and `$outcome->limitsBreached` not being empty. `describe()` shows it as `escalate_limits`. + +### Throttling Escalation + +While a dependency is down a breaker refuses hundreds of runs, and each would escalate. `throttleEscalation(int $seconds)` lets at most one escalation through per point per window: + +```php +->escalate(PagePayments::class) +->throttleEscalation(600) +``` + +The first escalation in a window claims a cache key, `monitor:escalation:{point}`, for the window; the rest are skipped. A skipped escalation still has its `PointEscalated` event and record; it adds an `escalation.throttled` note to the outcome's timeline and dispatches `Kirschbaum\Monitor\Events\EscalationThrottled` with the outcome and the window, so a listener can count what was suppressed. `describe()` shows the window as `escalation_throttle`. + +### When the Escalation Fails + +An escalation that throws does not replace the original failure. The original still propagates, and a `Kirschbaum\Monitor\Events\EscalationFailed` event carries the outcome and the escalation's exception, which the recorder writes as an `escalation.failed` record at `critical` level. The pager not firing is the loudest thing the package can say. + +## Risks Monitor Raises + +Three failures come from the package rather than from the operation. Both extend `Kirschbaum\Monitor\Risks\Risk`, which extends `RuntimeException`, and both go through `recover()` and `escalate()` like anything else. + +**`Kirschbaum\Monitor\Risks\BreakerOpen`** is raised when the point's circuit breaker is open and nothing was attempted. The run's status is `Refused`, `run()` throws it, and a parent point sees it as an ordinary exception. A point can recover from its own refusal: + +```php +->breaker('stripe') +->recover(BreakerOpen::class, fn (BreakerOpen $e) => ChargeResult::deferred($e->retryAfterSeconds)) +``` + +`$e->breaker` is the circuit name and `$e->retryAfterSeconds` the time left. + +**`Kirschbaum\Monitor\Risks\EnsureFailed`** is raised when the operation returned but an `ensure()` check did not hold. `$e->reason` is the reason given to `ensure()` and `$e->value` the value that failed it: + +```php +->ensure(fn (ChargeResult $r): bool => $r->settled, 'charge must be settled') +->recover(EnsureFailed::class, fn (EnsureFailed $e) => ChargeResult::unsettled($e->value)) +``` + +See [Policies and Limits](policies-and-limits.md#ensure) for when `ensure()` runs. + +**`Kirschbaum\Monitor\Risks\Duplicate`** is raised by the `once()` policy when a run for the same idempotency key already exists inside the window, before anything executed. `$e->key` is the key and `$e->originalRunId` the run that holds it, when the cache still has it: + +```php +->once('invoice:'.$invoice->id) +->recover(Duplicate::class, fn (Duplicate $e) => ChargeResult::alreadyCharged($e->originalRunId)) +``` + +See [Policies and Limits](policies-and-limits.md#once) for when the key is released. diff --git a/docs/store.md b/docs/store.md new file mode 100644 index 0000000..3c51921 --- /dev/null +++ b/docs/store.md @@ -0,0 +1,177 @@ +# The Outcome Store + +- [Introduction](#introduction) +- [Enabling It](#enabling-it) +- [The Table](#the-table) +- [When Rows Are Written](#when-rows-are-written) +- [Querying](#querying) +- [monitor:outcomes](#monitoroutcomes) +- [Retention and monitor:prune](#retention-and-monitorprune) + +## Introduction + +Every run of a control point ends in an `Outcome`, and every outcome is written to the log as a record. The store keeps the same outcomes in a database table, so they can be queried without a log backend: from `monitor:outcomes`, from `monitor:explain` and `monitor:points`, which show a point's recent history and when it last ran, and from the MCP tools an agent uses. + +The store is off by default. Records in the log do not depend on it. + +## Enabling It + +Set `records.store.enabled`, then create the table: + +``` +MONITOR_STORE_ENABLED=true +``` + +```bash +php artisan vendor:publish --tag=monitor-migrations +php artisan migrate +``` + +Publishing copies the migration into `database/migrations` with a timestamp, the way Laravel's first-party packages ship theirs. The package never loads the migration on its own, so the table exists only once you have published and run it. + +```php +'records' => [ + 'store' => [ + 'enabled' => env('MONITOR_STORE_ENABLED', false), + 'connection' => env('MONITOR_STORE_CONNECTION'), + 'table' => 'monitor_outcomes', + 'retention_days' => 30, + ], +], +``` + +`connection` is a database connection name, or null for the default. `table` is the table name. + +## The Table + +| Column | Type | Content | +| --- | --- | --- | +| `id` | bigint | Primary key. | +| `run_id` | string(26), unique | ULID of the run. | +| `parent_run_id` | string(26), nullable, indexed | The enclosing run when nested. | +| `trace_id` | string(32), indexed | The trace. | +| `point` | string, indexed | The control point name. | +| `domain` | string, indexed | The domain. | +| `origin` | string | The fully qualified class. | +| `profile` | string, nullable | The profile the point started from. | +| `status` | string(16), indexed | `succeeded`, `recovered`, `escalated` or `refused`. | +| `recovered_from` | string, nullable | The exception class a correction handled. | +| `exception_class` | string, nullable | The class of the failure, when there was one. | +| `exception_message` | text, nullable | Its message, redacted. | +| `attempts` | unsigned integer | Total attempts. | +| `duration_ms` | decimal(14,3) | Wall time of the run. | +| `stack` | json | Point names, outermost first. | +| `context` | json | The point's context, redacted. | +| `limits_breached` | json | Limit name to threshold and actual. | +| `policies` | json | The policies that ran, described. | +| `timeline` | json | Every transition with its offset in milliseconds. | +| `started_at` | timestamp(3) | When the run started, from the outcome. | +| `ended_at` | timestamp(3), indexed | When the run ended, from the outcome; not when the row was written. | + +Context and exception messages go through the same Redactor profile as records, `records.redaction`; see [Records](records.md#redaction). + +## When Rows Are Written + +Nothing is written on the request path. `Kirschbaum\Monitor\Store\StoreOutcomes` listens to `PointEnded`, keeps the outcome in memory, and writes the buffer in one statement: + +- after each HTTP request, once the response has been sent; +- after each console command; +- after each queued job, on `JobProcessed` and `JobExceptionOccurred`, so a long-running worker never holds outcomes across jobs; +- on `Queue::looping` and when a worker stops; and +- as soon as the buffer holds 100 outcomes, so a long-running command that runs many points does not hold them until it exits. + +Rows are upserted on `run_id`, so writing the same buffer twice is harmless. A test that wants the rows before the request ends calls `app(StoreOutcomes::class)->flush()`; `pending()` says how many outcomes are waiting. + +A failing write is caught. The first failure in a process is logged at warning as `[Monitor] the outcome store could not be written; outcomes are still in the log`; later ones are silent. The store is never the reason a control point fails, and never adds a query to the request that ran it. + +## Querying + +`Kirschbaum\Monitor\Store\OutcomeStore` is resolved from the container. + +```php +use Kirschbaum\Monitor\Store\OutcomeStore; + +$store = app(OutcomeStore::class); + +$store->enabled(); // bool, from config +``` + +`recent()` returns rows newest first as arrays with the JSON columns decoded: + +```php +$rows = $store->recent([ + 'point' => 'payment.charge', // optional + 'domain' => 'Payments', // optional + 'status' => 'escalated', // optional + 'trace' => $traceId, // optional + 'since' => now()->subHours(2), // optional DateTimeInterface +], limit: 100); // default 50 +``` + +`tally()` counts rows per status, for one point or for all, optionally since a moment: + +```php +$store->tally(); // ['succeeded' => 412, 'recovered' => 9, 'escalated' => 2] +$store->tally('payment.charge', now()->subDay()); // ['succeeded' => 40, 'recovered' => 1] +``` + +`lastSeen()` returns, per point, when it last ran and how it ended; `monitor:points` shows this column when the store is on: + +```php +$store->lastSeen(); +// ['payment.charge' => ['ended_at' => '2026-09-14 18:10:25.011', 'status' => 'succeeded'], ...] +``` + +`prune()` deletes rows older than the given number of days, or the configured retention, and returns the count. + +The rest of the store's public methods: + +| Method | Meaning | +| --- | --- | +| `write(array $outcomes)` | Upsert a list of `Outcome` objects and return how many were written. `StoreOutcomes` calls it; a listener of your own may too. | +| `query()` | A query builder on the table, for anything the helpers do not cover. | +| `table()`, `connection()`, `retentionDays()` | The configured table, connection and retention. | + +## monitor:outcomes + +Lists recent outcomes from the store. + +```bash +php artisan monitor:outcomes +php artisan monitor:outcomes --point=payment.charge --since=1h +php artisan monitor:outcomes --domain=Payments --status=escalated --since=7d --limit=200 +php artisan monitor:outcomes --trace=7f3a2c1d9e8b4a6f0c5d1e2f3a4b5c6d +php artisan monitor:outcomes --json +``` + +| Option | Meaning | +| --- | --- | +| `--point=` | Only this control point. | +| `--domain=` | Only this domain. | +| `--status=` | `succeeded`, `recovered`, `escalated` or `refused`. | +| `--trace=` | Only this trace id. | +| `--since=` | How far back: a number followed by `m`, `h` or `d`, e.g. `15m`, `2h`, `7d`. Default `24h`. | +| `--limit=` | Rows to show. Default 50. | +| `--json` | Print the rows as JSON instead of a table. | + +The table has the columns Ended, Point, Domain, Status, Attempts, ms, Exception and the first eight characters of the trace id. With `--json` every column comes back, JSON columns decoded, so the output can be piped to `jq`. + +Exit codes: 0 on success, including when nothing matched; 1 when the store is disabled; 2 when `--since` is not in the accepted form. + +## Retention and monitor:prune + +Rows are kept for `records.store.retention_days`, 30 by default. Nothing deletes them unless `monitor:prune` runs: + +```bash +php artisan monitor:prune # older than the configured retention +php artisan monitor:prune --days=7 # older than 7 days +``` + +Schedule it: + +```php +// routes/console.php +Schedule::command('monitor:prune')->daily(); +``` + +The command reports how many rows it deleted, and does nothing while the store is disabled. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..2325c93 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,205 @@ +# Testing + +- [Introduction](#introduction) +- [Monitor::fake()](#monitorfake) + - [Canned Values and Failures](#canned-values-and-failures) + - [Reading Outcomes](#reading-outcomes) +- [Assertions](#assertions) +- [Tips](#tips) +- [Pest Expectations](#pest-expectations) +- [The PHPStan Rule](#the-phpstan-rule) +- [The Package's Own Tests](#the-packages-own-tests) + +## Introduction + +A control point declares what it tolerates. A test of a control point asserts on what it did: it succeeded, it recovered from the declined card, it escalated the timeout, it was refused by the breaker. `Monitor::fake()` gives a test that vocabulary. Two more surfaces prove the declarations themselves: a Pest expectation that a namespace is controlled and complete, and a PHPStan rule that says the same at analysis time. + +## Monitor::fake() + +```php +use Kirschbaum\Monitor\Facades\Monitor; + +$fake = Monitor::fake(); +``` + +The fake replaces the runner and records every `Outcome`. Control points still execute for real: policies run, corrections run, escalations run, records are written. What changes is that every outcome is kept, and a point can be given a canned result. + +It follows the framework's fakes: `MonitorFake` implements `Illuminate\Support\Testing\Fakes\Fake`, so `Monitor::isFake()` is true afterwards; calling `Monitor::fake()` twice returns the same fake rather than wrapping it; and the fake is the facade root, so assertions are made through `Monitor::` directly. + +### Canned Values and Failures + +`returning()` makes a point succeed with a value without running its callback: + +```php +Monitor::fake() + ->returning('payment.charge', ChargeResult::settled()) + ->returning('payment.refund', null); +``` + +`failing()` makes the point's callback throw, so the point's own corrections, retries, breaker and escalation are exercised as they would be on a real failure: + +```php +Monitor::fake()->failing('payment.charge', new CardDeclined('do_not_honor')); + +$result = $this->service->charge($invoice); // goes through recover(CardDeclined::class, ...) + +Monitor::assertRecovered('payment.charge', from: CardDeclined::class); +``` + +Registering a point with one replaces any earlier registration of the other. Points that are registered with neither run their callback. Wherever a point name is passed, a backed enum is accepted in place of the string. + +### Reading Outcomes + +`outcomes()` returns an `Illuminate\Support\Collection` of `Outcome` objects, in the order they ended: + +```php +Monitor::outcomes(); // every Outcome +Monitor::outcomes('payment.charge'); // for one point +Monitor::outcomes()->where('status', Status::Escalated)->count(); + +$fake->forget(); // discard what was recorded +``` + +## Assertions + +Every assertion returns the fake, so they chain. `$point` is a string or a backed enum. Where an assertion takes a `Closure`, it receives the `Outcome` and returns a bool. + +| Assertion | Passes when | +| --- | --- | +| `assertRan($point, ?Closure $callback = null)` | The point ran at least once; with a callback, at least one `Outcome` satisfies it. | +| `assertNotRan($point)` | The point did not run. | +| `assertRanOnce($point)` | The point ran exactly once. | +| `assertRanTimes($point, int $times)` | The point ran exactly that many times. | +| `assertSucceeded($point, ?Closure $callback = null)` | An outcome of the point is `succeeded`, and satisfies the callback if given. | +| `assertRecovered($point, Closure\|string\|null $from = null)` | An outcome is `recovered`; with a class, recovered from that exception class; with a closure, one that satisfies it. | +| `assertNotRecovered($point, Closure\|string\|null $from = null)` | No outcome of the point is `recovered`, or none from that class, or none satisfying the closure. | +| `assertEscalated($point, Closure\|string\|null $with = null)` | An outcome is `escalated`; with a class, the escaped exception is an instance of it; with a closure, one that satisfies it. | +| `assertNotEscalated($point, Closure\|string\|null $with = null)` | No outcome of the point is `escalated`, or none with that class, or none satisfying the closure. | +| `assertRefused($point)` | An outcome is `refused` by a breaker. | +| `assertNotRefused($point)` | No outcome of the point is `refused`. | +| `assertRetried($point, ?int $times = null)` | An outcome took more than one attempt; with `$times`, exactly `$times + 1` attempts. | +| `assertNotRetried($point)` | No outcome of the point took more than one attempt. | +| `assertLimitBreached($point, string $limit)` | An outcome breached the named limit, `duration` or `attempts`. | +| `assertNothingEscalated()` | No recorded outcome escalated. | +| `assertNothingRan()` | Nothing was recorded. | + +```php +Monitor::assertRan('payment.charge', fn (Outcome $o): bool => $o->context['invoice'] === 48211) + ->assertSucceeded('payment.charge') + ->assertNotRan('payment.refund') + ->assertNothingEscalated(); +``` + +Failure messages name the point and what was seen, for instance `Control point [payment.charge] did not end succeeded; saw: escalated.` + +## Tips + +**Retries sleep.** Fake `Sleep` so a test with backoff runs instantly, and assert on the delays if they matter: + +```php +use Illuminate\Support\Sleep; + +Sleep::fake(); + +Monitor::control('search.index')->retry(times: 2, backoffMs: 100)->attempt($flaky); + +Sleep::assertSleptTimes(2); +``` + +**Breakers use the clock.** Circuit state is timestamped through `Carbon::now()`, so `travel()` moves it: + +```php +Monitor::breaker()->open('stripe', 60); +$this->travel(61)->seconds(); + +expect(Monitor::breaker()->isOpen('stripe'))->toBeFalse(); +``` + +**Events.** `Event::fake([...])` with the event classes in [Records](records.md#events) asserts on transitions without reading a log. + +**Records.** `timacdonald/log-fake` captures records: after `LogFake::bind()`, `Log::channel()->logs()` holds every line with its level, message and context. + +**Idempotency.** `once()` keys live in the cache, which the test environment usually keeps in an `array` store that resets between tests. Inside one test, `Cache::flush()` or `travel()` past the window lets a point run again; `Monitor::fake()->failing()` releases the key the way a real failure does, so a test of the retry path needs no cleanup. + +**Dispatching.** `Queue::fake()` catches `ChargeCard::dispatch()` as a `Kirschbaum\Monitor\Queue\RunControlPoint` whose `displayName()` is the point name; `ChargeCard::dispatchSync()` under `Monitor::fake()` runs the point and records its outcome like any other. See [Jobs](jobs.md). + +**The store.** Rows are written after the request or job, so a test that reads the table first calls `app(Kirschbaum\Monitor\Store\StoreOutcomes::class)->flush()`; `pending()` says how many outcomes are waiting. See [Store](store.md#when-rows-are-written). + +## Pest Expectations + +Register once in `tests/Pest.php`: + +```php +Kirschbaum\Monitor\Testing\Expectations::register(); +``` + +Two expectations then take a namespace: + +```php +expect('App\Services\Payments')->toBeControlled(); +expect('App\ControlPoints')->toHaveCompleteControlPoints(); +``` + +`toBeControlled()` passes when every class in the namespace is a control point class or calls `Monitor::control()` inline, with the same exemptions the inventory's `critical_namespace_uncontrolled` rule applies. It reads `discovery.paths`, so the namespace must live under a scanned path. + +`toHaveCompleteControlPoints()` passes when no rule reports an error for a class-form point whose origin is in the namespace. Which rules run comes from `inventory.rules`. See [The Inventory](inventory.md#the-rules). + +Both are available as static methods for PHPUnit: + +```php +use Kirschbaum\Monitor\Testing\Expectations; + +Expectations::assertControlled('App\Services\Payments'); +Expectations::assertComplete('App\ControlPoints'); +``` + +A failure lists every offending class or point. + +## The PHPStan Rule + +The same guarantee at analysis time. Include the extension and name the namespaces: + +```neon +# phpstan.neon +includes: + - vendor/kirschbaum-development/monitor/extension.neon + +parameters: + monitor: + criticalNamespaces: + - App\ControlPoints + - App\Services\Payments +``` + +A class in one of those namespaces that does not extend `ControlPoint`, does not implement `Contracts\Escalation` or `Contracts\Policy`, is not abstract, and contains no `Monitor::control()` or `new Control()` call is reported: + +``` +App\Services\Payments\LegacyCharger sits in a critical namespace but is not a control point and calls none. +💡 Extend Kirschbaum\Monitor\ControlPoint, or wrap the operation in Monitor::control(). +``` + +The error identifier is `monitor.uncontrolled`. The rule reads its namespaces from the PHPStan configuration rather than from `config/monitor.php`, because PHPStan runs without the application; keep the two lists the same. + +## The Package's Own Tests + +If you contribute to the package, the suite is Pest on Orchestra Testbench: + +```bash +composer test # full suite, in parallel +composer test-coverage # with the 100% coverage floor enforced +composer lint # Pint, Rector, PHPStan (level 10, no baseline) +composer rector:check # what Rector would change, without changing it +composer mutate # mutation testing (Pest); local only, not run in CI +composer preflight # everything CI runs +``` + +Coverage needs a driver loaded in the CLI. With [Laravel Herd](https://herd.laravel.com), `herd coverage vendor/bin/pest --coverage --min=100` runs the suite under Xdebug with the same floor CI enforces with pcov; without a driver Pest reports no coverage. The PHPUnit configuration raises the memory limit, which the in-process PHPStan rule test needs. + +Conventions worth knowing: + +- Tests live under `tests/Feature`, `tests/Unit` and `tests/Performance`, all bound to `Tests\TestCase`, which registers the Redactor, MCP and Monitor providers and calls `Http::preventingStrayRequests()`. +- Exceptions and escalations shared across suites live in `tests/Fixtures`; `failingTimes()` in `tests/Pest.php` builds a callback that fails a given number of times. +- `workbench/app/ControlPoints` holds control points the inventory, MCP and PHPStan tests scan. Several are deliberately wrong, one per rule: a duplicate name, an invalid name, a point with no escalation, a catch-all with no escalation, a `control()` that reads a constructor argument, a class in the critical namespace with no control point, a class whose file name does not match, and an inline point with a computed name. `ChargeCard` is the complete one. +- `tests/Performance` holds relative timing guards that skip themselves under coverage instrumentation through `runningWithCoverage()`. +- The PHPStan rule is tested twice: in-process through PHPStan's `RuleTestCase`, which coverage sees, and by running `vendor/bin/phpstan` against a fixture configuration, which proves the extension file wires up. +- The pre-commit hook runs the same checks as `composer preflight`, and the commit-message hook keeps subjects to one line of at most 72 characters; `composer install` wires both up through `core.hooksPath`. diff --git a/docs/tracing.md b/docs/tracing.md new file mode 100644 index 0000000..b0615ee --- /dev/null +++ b/docs/tracing.md @@ -0,0 +1,122 @@ +# Tracing + +- [Introduction](#introduction) +- [The Trace ID](#the-trace-id) +- [The Trace API](#the-trace-api) +- [HTTP Requests](#http-requests) + - [Incoming: the StartTrace Middleware](#incoming-the-starttrace-middleware) + - [Outgoing: Http::traced()](#outgoing-httptraced) +- [Queued Jobs](#queued-jobs) +- [Console](#console) +- [Every Log Line](#every-log-line) + +## Introduction + +A trace ID follows one piece of work through every control point it touches, across queued jobs and across services. Every record Monitor writes carries it as `trace_id`, and so does every other log line the application writes while the trace is active, because Monitor keeps it in Laravel's [Context](https://laravel.com/docs/context) rather than in state of its own. + +## The Trace ID + +A trace ID is 32 lowercase hexadecimal characters, the form the W3C `traceparent` header uses. A UUID is accepted anywhere an ID is passed in and normalised to that form by removing its dashes and lowercasing it. Anything else, including an all-zero ID, is invalid and rejected. + +```php +Kirschbaum\Monitor\Trace\Trace::normalise('9D2B4E8F-3A1C-4D5E-8F2A-1B3C4D5E6F7A'); +// "9d2b4e8f3a1c4d5e8f2a1b3c4d5e6f7a" + +Kirschbaum\Monitor\Trace\Trace::normalise('not a trace'); +// null +``` + +## The Trace API + +`Monitor::trace()` returns the `Trace` service. + +```php +use Kirschbaum\Monitor\Facades\Monitor; + +Monitor::trace()->id(); // the current ID, starting a trace if none exists +Monitor::trace()->current(); // the current ID or null +Monitor::trace()->hasStarted(); // bool + +Monitor::trace()->start(); // a new ID, replacing any current one +Monitor::trace()->pickup(); // keep the current one, or start one +Monitor::trace()->pickup($id); // keep the current one, or adopt $id +Monitor::trace()->override($id); // adopt $id, replacing any current one +Monitor::trace()->clear(); // forget the trace +``` + +`pickup()` is the safe call: it never replaces a trace that exists. `override()` and `pickup()` with an ID throw `Kirschbaum\Monitor\Exceptions\InvalidTraceId` when the ID is not valid. A control point calls `id()` when it starts, so a point run outside any request or job still has a trace. + +The ID lives in Context under the key `trace_id`. + +`Trace::generate()` returns a fresh ID without storing it. `Kirschbaum\Monitor\Trace\TraceParent` builds and reads the header for clients the middleware does not cover: `TraceParent::format($traceId)` returns a `00---01` header, `TraceParent::parse($header)` returns the trace ID inside a valid one or `null`, and `TraceParent::spanId()` returns a random 16-character span ID. + +## HTTP Requests + +### Incoming: the StartTrace Middleware + +Add `Kirschbaum\Monitor\Http\Middleware\StartTrace` to the middleware stack: + +```php +// bootstrap/app.php +->withMiddleware(function (Middleware $middleware) { + $middleware->append(Kirschbaum\Monitor\Http\Middleware\StartTrace::class); +}) +``` + +To trace only some routes instead, the package registers the same middleware under the `monitor.trace` alias for route groups. + +For each request, when no trace has started yet, the middleware looks at the incoming headers in this order: + +1. The header in `trace.header`, `traceparent` by default, parsed as a W3C traceparent (`00---`). Its trace ID is adopted. +2. The header in `trace.legacy_header`, `X-Trace-Id` by default, which may hold a 32 hex ID or a UUID. Its normalised value is adopted. +3. Neither present: a new trace starts. + +A header that is present but invalid is not adopted. The request gets a fresh trace and one line is written at debug, `[Monitor] replaced an invalid incoming traceparent` or `... trace id`, naming the header. A client sending junk cannot put junk on every log line of the request. + +On the way out, both headers are set on the response: `traceparent` in full W3C form with a fresh span ID, and the legacy header with the bare ID, so a caller of either kind can correlate. + +```php +'trace' => [ + 'header' => 'traceparent', + 'legacy_header' => env('MONITOR_TRACE_HEADER', 'X-Trace-Id'), +], +``` + +A trace that already started before the middleware runs is kept as it is. + +### Outgoing: Http::traced() + +The service provider registers a `traced()` macro on Laravel's HTTP client. It adds the current trace to the request as both headers, starting a trace if none exists: + +```php +use Illuminate\Support\Facades\Http; + +Http::traced()->get('https://service-b.example.com/api/orders'); + +Http::baseUrl('https://service-c.example.com') + ->withToken($token) + ->traced() + ->post('/filings', $payload); +``` + +The macro exists on the factory (`Http::traced()`) and on a pending request (`->traced()` anywhere in a chain). Service B, running the same middleware, picks the ID up and its records join yours. + +## Queued Jobs + +Laravel's Context is serialised with a job when it is dispatched and restored when the job runs, so a job dispatched from inside a trace carries it and every record the job writes shares the ID. Nothing needs to be done for that. + +A job that arrives without a trace, because it was dispatched from a process that had none, gets a trace of its own when it starts: Monitor listens to `Illuminate\Queue\Events\JobProcessing` with `Kirschbaum\Monitor\Trace\PicksUpJobTrace`, which calls `pickup()`. The same listener hands off the control stack the job inherited, so the job starts with no parent point and a `dispatched_from_run` id instead; see [Jobs](jobs.md#what-a-job-inherits). + +## Console + +In console, a trace starts when an artisan command starts, on Laravel's `CommandStarting` event, so a command's records share one ID and nothing runs at provider boot. Turn that off with `trace.console`: + +```php +'trace' => [ + 'console' => env('MONITOR_TRACE_CONSOLE', true), +], +``` + +## Every Log Line + +Because the ID is kept in Context, Laravel attaches it to every log record the application writes while the trace is active, on every channel, not only to Monitor's records. While a control point runs, Context also carries `control_point`, the name of the innermost point, so an ordinary `Log::info()` inside a point says which point it happened in. Both disappear when the point ends and the trace is cleared. diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..15e7c98 --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,147 @@ +# Upgrading + +- [From 0.1 to 1.0](#from-01-to-10) +- [Platform](#platform) +- [The Facade](#the-facade) +- [Controlled Blocks Become Control Points](#controlled-blocks-become-control-points) +- [The Breaker API](#the-breaker-api) +- [Removed Surfaces](#removed-surfaces) +- [Logging and Middleware](#logging-and-middleware) +- [Configuration Keys](#configuration-keys) +- [Environment Variables](#environment-variables) +- [Exceptions](#exceptions) +- [The Log Shape](#the-log-shape) +- [Checklist](#checklist) + +## From 0.1 to 1.0 + +1.0 is a rewrite. There is no compatibility layer: every 0.1 class, method and configuration key was replaced or removed, and the log shape changed. The tables below list each 0.1 surface and what to use instead. Expect to touch every call site, which for most applications is a small number of critical operations. + +## Platform + +| 0.1 | 1.0 | +| --- | --- | +| PHP 8.3, 8.4 | PHP 8.3, 8.4, 8.5 | +| Laravel 11, 12 | Laravel 12, 13 | +| `kirschbaum-development/redactor` 0.1 | `kirschbaum-development/redactor` 1.x | + +## The Facade + +| 0.1 | 1.0 | +| --- | --- | +| `Monitor::controlled($name, $origin)` | `Monitor::control($name, $origin)`. `$name` may be a string or a backed enum. | +| `Monitor::log($origin)` | Kept; now a PSR-3 logger whose records carry `origin` and `domain` fields. A string origin such as `Monitor::log('Billing')` still works. | +| `Monitor::trace()` | Kept; `start()`, `pickup()`, `override()`, `id()`, `current()`, `hasStarted()`, `clear()`. `hasNotStarted()` is gone. IDs are 32 hex characters; UUIDs are normalised. | +| `Monitor::breaker()` | Kept; see [The Breaker API](#the-breaker-api). | +| `Monitor::time()` | Removed. `Outcome::$durationMs` carries the run's duration. | +| `Monitor::redactor()` | Removed. Use `Kirschbaum\Redactor\Facades\Redactor` directly. | +| | New: `Monitor::stack()`, `Monitor::fake()`. | + +## Controlled Blocks Become Control Points + +| 0.1 | 1.0 | +| --- | --- | +| `->catching([Class::class => fn ($e, $meta)])` | `->recover(Class::class, fn ($e, Outcome $partial))`, one call per risk. | +| A handler returning `null` meant "handled, no value" and made `run()` return `true`; `false`, `0` and `''` were treated as unhandled. | The handler's return value is the result, whatever it is, `null` included. | +| `->onUncaughtException(fn ($e, $meta))` | `->escalate(fn (Outcome $outcome))` or `->escalate(SomeEscalation::class)`. | +| `->withCircuitBreaker($name, $threshold, $decay)` | `->breaker($name, after: $threshold, within: 60, for: $decay)`. | +| `->withDatabaseTransaction($retries, $only, $exclude)` | `->transaction(retries: $retries, on: $only, except: $exclude)`. Deadlocks only, by default. | +| `->overrideContext([...])`, `->addContext([...])` | `->with([...])`, which merges. | +| `->overrideTraceId($id)` | `Monitor::trace()->override($id)` before the point. | +| `->withStructuredLogger($logger)` | Removed; records are written by the package. | +| `->run($callback)` | Kept. New: `->attempt($callback)` returns the `Outcome` without throwing. | +| `NestedControlledBlockException` | Removed; nesting is supported and recorded. | +| `ControlledFailureMeta` array (`controlled_block`, `controlled_block_id`, `breaker_tripped`, ...) | `Kirschbaum\Monitor\Outcome` (`point`, `id`, `status`, `exception`, `attempts`, `limitsBreached`, ...). | + +New in 1.0 with no 0.1 equivalent: `->profile()`, `->within()`, `->attempts()`, `->ensure()`, `->policy()`, `->domain()`, `->from()`, `->when()` and `->unless()`, macros, the class form (`ControlPoint`, `#[Point]`), `monitor:points`, `monitor:explain`, `monitor:outcomes`, `monitor:prune`, `make:control-point`, `Monitor::fake()`, the Pest expectations, the PHPStan rule, the outcome store, the MCP server and the Boost guideline and skill. + +## The Breaker API + +| 0.1 | 1.0 | +| --- | --- | +| `isOpen($name, $threshold, $decay)` | `isOpen($name)`; thresholds come from the policy, a `BreakerConfig`, or `breakers`. | +| `isClosed($name, $threshold, $decay)` | `isClosed($name)`. | +| `isHealthy($name)` | `state($name)->isClosed()`. | +| `recordFailure($name, $decay)` | `recordFailure($name, ?BreakerConfig $config)`. | +| `recordSuccess($name)` | Kept. | +| `forceOpen($name)`, `reset($name)` | `open($name, ?int $forSeconds)`, `close($name)`. | +| `getState($name)` | `state($name)`, returning a `BreakerState` with `state`, `failures`, `openedAt`, `openFor`. | +| `getFailureCount($name)` | `state($name)->failureCount()`. | +| `getLastFailureTime($name)` | The last entry of `state($name)->failures`. | +| | New: `permit($name, ?BreakerConfig $config)` returns a `Decision` and hands out the half-open probe; `retryAfter($name)`. | + +## Removed Surfaces + +| 0.1 | 1.0 | +| --- | --- | +| `Kirschbaum\Monitor\LogTimer` | Removed. | +| `Kirschbaum\Monitor\Support\LogRedactor`, `RedactorConfig` | Removed. Redaction is the Redactor package's, applied with the profile in `records.redaction`. | +| `Kirschbaum\Monitor\Enums\OriginWrapper` | Removed. The human message keeps a fixed `[Domain:Class]` prefix; origin and domain are fields. | +| `Kirschbaum\Monitor\Support\ControlledContext` | `Kirschbaum\Monitor\ControlStack`, backed by Laravel's Context. | +| `Kirschbaum\Monitor\Data\CircuitBreakerState` | `Kirschbaum\Monitor\Breaker\BreakerState`. | +| The `monitor-logging` publish tag and `config/logging-monitor.php` | Removed. Add `Kirschbaum\Monitor\Logging\JsonTap` to a channel of your own; see [Records](records.md). | + +## Logging and Middleware + +| 0.1 | 1.0 | +| --- | --- | +| `Kirschbaum\Monitor\Taps\StructuredLoggingTap` | `Kirschbaum\Monitor\Logging\JsonTap` | +| `Kirschbaum\Monitor\Formatters\StructuredJsonFormatter` | `Kirschbaum\Monitor\Logging\RecordFormatter` | +| `Kirschbaum\Monitor\Http\Middleware\StartMonitorTrace` | `Kirschbaum\Monitor\Http\Middleware\StartTrace`, registered under the `monitor.trace` alias; reads `traceparent` first and validates what it gets. | +| `Kirschbaum\Monitor\Http\Middleware\CheckCircuitBreakers` aliased `circuit:a,b` | `Kirschbaum\Monitor\Http\Middleware\CheckBreakers`, registered under the `monitor.breakers` alias: `->middleware('monitor.breakers:a,b')`. | +| | New: `Http::traced()` adds the trace headers to an outgoing request; queued jobs pick up or start a trace. | + +## Configuration Keys + +| 0.1 | 1.0 | +| --- | --- | +| `enabled` | Removed; it was not read. | +| `prefix`, `origin_path_replacers`, `origin_separator`, `origin_path_wrapper` | `domains.map` and `domains.fallback`, which produce a `domain` field instead of rewriting the origin. | +| `exception_trace.enabled`, `full_on_debug`, `force_full_trace`, `max_lines` | `records.exception_trace` (`never`, `debug`, `always`) and `records.exception_trace_lines`. | +| `console_auto_trace.enabled`, `enable_in_testing` | `trace.console`. The trace now starts on `CommandStarting` rather than when the provider boots. | +| `trace_header` | `trace.legacy_header`; `trace.header` is `traceparent`. | +| `circuit_breaker.default_decay_seconds`, `default_retry_after`, `add_cors_headers` | `breakers.after`, `within`, `for`, `store`, `prefix`. Retry-After is the real time left on the circuit. | +| `redactor.enabled`, `redactor.redactor_profile` | `records.redaction`; empty or `null` disables. | +| | New: `critical_namespaces`, `point_name_pattern`, `discovery`, `inventory.rules`, `profiles`, `records.channel`, `records.levels`, `records.store`, `mcp`. | + +## Environment Variables + +A `.env` carrying a 0.1 variable that is not listed as kept does nothing in 1.0; nothing reads it. + +| 0.1 | 1.0 | +| --- | --- | +| `MONITOR_ENABLED` | Removed. | +| `MONITOR_TRACE_ENABLED`, `MONITOR_TRACE_FULL_ON_DEBUG`, `MONITOR_TRACE_FORCE_FULL_TRACE` | `MONITOR_EXCEPTION_TRACE` (`never`, `debug`, `always`). | +| `MONITOR_TRACE_MAX_LINES` | `records.exception_trace_lines` in the config file; no variable. | +| `MONITOR_CONSOLE_AUTO_TRACE_ENABLED` | `MONITOR_TRACE_CONSOLE`. | +| `MONITOR_CONSOLE_AUTO_TRACE_ENABLE_IN_TESTING` | Removed. | +| `MONITOR_TRACE_HEADER` | Kept, now the legacy header (`trace.legacy_header`). | +| `MONITOR_CIRCUIT_BREAKER_DECAY_SECONDS`, `MONITOR_CIRCUIT_BREAKER_RETRY_AFTER`, `MONITOR_CIRCUIT_BREAKER_CORS_HEADERS` | Removed. `breakers.after`, `within` and `for` are set in the config file; `MONITOR_BREAKER_STORE` names the cache store. | +| `MONITOR_REDACTOR_ENABLED` | Removed. Set `MONITOR_REDACTION_PROFILE` to an empty value to disable. | +| `MONITOR_REDACTOR_PROFILE` | `MONITOR_REDACTION_PROFILE`. | +| | New: `MONITOR_LOG_CHANNEL`, `MONITOR_STORE_ENABLED`, `MONITOR_STORE_CONNECTION`, `MONITOR_MCP_ENABLED`. | + +## Exceptions + +`Kirschbaum\Monitor\Exceptions\MonitorException` is an interface every exception the package throws implements, so `catch (MonitorException $e)` still catches all of them. `InvalidPointName`, `InvalidProfile`, `InvalidControlPoint` and `InvalidTraceId` extend `InvalidArgumentException`; the risks a run can end with, `Risks\BreakerOpen` and `Risks\EnsureFailed`, extend `RuntimeException` through `Risks\Risk`. Messages quote the offending value in brackets. + +## The Log Shape + +0.1 wrote one message per log call with the origin in a bracketed prefix and the trace ID, timing and memory inside a `context` blob, and a formatter that read `event` from the wrong place. + +1.0 writes one record per transition with a fixed field set at the top level of the log context: `schema` (`monitor/1`), `event`, `point`, `run_id`, `parent_run_id`, `stack`, `trace_id`, `domain`, `origin`, `profile`, `context`, and per event `attempt`, `attempts`, `duration_ms`, `status`, `risk`, `limit`, `exception` and `breaker`. The events are `point.started`, `point.retried`, `point.limit`, `point.recovered`, `point.escalated`, `point.refused`, `point.ended`, `escalation.failed`, `breaker.opened`, `breaker.half_open` and `breaker.closed`; `status` on `point.ended` is `succeeded`, `recovered`, `escalated` or `refused`. The `STARTED` / `ENDED` / `CAUGHT` / `RECOVERED` / `UNCAUGHT` messages are gone. + +Dashboards and alerts built on 0.1 messages need rebuilding on the fields. The full field list is in [Records](records.md), and the schema ships in the package at `resources/schema/record-1.json`. + +## Checklist + +1. Update `composer.json` to PHP 8.3+ and Laravel 12 or 13; Redactor 1.x comes with the package. +2. Re-publish the config with `vendor:publish --tag=monitor-config --force`, or merge the new keys by hand, and delete the 0.1 keys and `config/logging-monitor.php`. +3. Replace every `Monitor::controlled()` chain with `Monitor::control()`: `catching()` becomes one `recover()` per risk, `onUncaughtException()` becomes `escalate()`, the transaction and breaker calls take named arguments, and any handler that returned `null` to mean "handled" now returns the value the caller should get. +4. Check every handler that returned `false`, `0` or `''`: 1.0 treats those as results, not as unhandled. +5. Move critical operations into `App\ControlPoints\{Domain}` classes with `make:control-point`, and give every one an escalation; then run `php artisan monitor:points --check`. +6. Replace `StartMonitorTrace` with `StartTrace` (or the `monitor.trace` alias) and `circuit:` with `monitor.breakers:`; replace `StructuredLoggingTap` with `JsonTap` on any channel that should write NDJSON. +7. Replace the breaker calls listed above, and `Monitor::time()` with `Outcome::$durationMs`. +8. Rewrite dashboards and alerts on the record fields, and, if you want outcomes queryable without a log backend, enable the store and publish and run its migration. +9. Replace the removed environment variables in every `.env`. +10. Run the suite with `Monitor::fake()` in place of any log assertions on the 0.1 messages. diff --git a/extension.neon b/extension.neon new file mode 100644 index 0000000..b881852 --- /dev/null +++ b/extension.neon @@ -0,0 +1,25 @@ +# Include from the application's phpstan.neon: +# +# includes: +# - vendor/kirschbaum-development/monitor/extension.neon +# parameters: +# monitor: +# criticalNamespaces: +# - App\ControlPoints +# +parameters: + monitor: + criticalNamespaces: [] + +parametersSchema: + monitor: structure([ + criticalNamespaces: listOf(string()) + ]) + +services: + - + class: Kirschbaum\Monitor\Analysis\PhpStan\CriticalNamespaceRule + arguments: + namespaces: %monitor.criticalNamespaces% + tags: + - phpstan.rules.rule diff --git a/ide.json b/ide.json new file mode 100644 index 0000000..b928182 --- /dev/null +++ b/ide.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://laravel-ide.com/schema/laravel-ide-v2.json", + "codeGenerations": [ + { + "name": "Create Monitor Control Point", + "id": "kirschbaum-development/monitor:control-point", + "files": [ + { + "appNamespace": "ControlPoints", + "template": { + "path": "stubs/control-point.stub", + "parameters": { + "{{ namespace }}": "${INPUT_FQN|namespace}", + "{{ class }}": "${INPUT_FQN|className}", + "{{ point }}": "${INPUT_FQN|className|snake}", + "{{ profile }}": "" + } + } + } + ] + } + ] +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index db92607..7557ade 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -3,6 +3,13 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true" + cacheDirectory=".phpunit.cache" + failOnWarning="true" + failOnRisky="true" + failOnDeprecation="true" + failOnNotice="true" + failOnEmptyTestSuite="true" + beStrictAboutOutputDuringTests="true" > @@ -11,6 +18,9 @@ tests/Feature + + tests/Performance + @@ -25,6 +35,7 @@ + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..eba39d9 --- /dev/null +++ b/rector.php @@ -0,0 +1,28 @@ +withPaths([ + __DIR__.'/src', + __DIR__.'/config', + __DIR__.'/tests', + ]) + ->withPhpSets(php83: true) + ->withPreparedSets( + deadCode: true, + codeQuality: true, + typeDeclarations: true, + earlyReturn: true, + ) + ->withSkip([ + ReadOnlyClassRector::class, + ]) + ->withSets([ + LaravelSetList::LARAVEL_CODE_QUALITY, + LaravelSetList::LARAVEL_COLLECTION, + ]); diff --git a/resources/boost/skills/monitor-development/SKILL.md b/resources/boost/skills/monitor-development/SKILL.md new file mode 100644 index 0000000..f9583ec --- /dev/null +++ b/resources/boost/skills/monitor-development/SKILL.md @@ -0,0 +1,83 @@ +--- +name: monitor-development +description: "Use when working with Kirschbaum Monitor, the control point package for Laravel. Trigger whenever the query mentions Monitor by name, control points, critical operations, retries, circuit breakers, escalation, Monitor::control, ControlPoint, #[Point], monitor:points, or testing an operation's failure handling with Monitor::fake() in a Laravel project. Tasks include wrapping an operation as a control point, declaring risks and corrections, picking a profile, adding an escalation, reading records and outcomes, and writing Pest tests for a point. Do not trigger for generic logging, generic exception handling, or queue configuration unrelated to control points." +license: MIT +metadata: + author: kirschbaum-development +--- +# Monitor Control Points + +## Documentation + +The package documentation lives in `vendor/kirschbaum-development/monitor/docs/`. Read `control-points.md`, `risks-and-corrections.md` and `testing.md` before declaring a point. + +This application uses `kirschbaum-development/monitor`. A **control point** is an operation whose failure matters: charging a card, filing with an external system, posting a ledger entry, sending an email a person is waiting for, calling any third-party API. Control points are declared, not improvised. + +### When an operation is critical + +Wrap it in a control point when a failure would lose money, lose data, break a promise to a user, or leave an external system in a different state from ours. When in doubt, it is critical. + +### How to declare one + +Create a class under `App\ControlPoints\{Domain}` with `php artisan make:control-point {Domain}/{Name}`. The domain is the business area (Payments, Filings, Notifications); it becomes the `domain` field on every record. + +```php +#[Point('payment.charge', profile: 'external')] +final class ChargeCard extends ControlPoint +{ + public function __construct(private readonly Invoice $invoice) {} + + protected function control(Control $control): void + { + $control + ->recover(CardDeclined::class, fn (CardDeclined $e) => ChargeResult::declined($e->code)) + ->ensure(fn (ChargeResult $r): bool => $r->settled, 'charge must be settled') + ->escalate(PagePayments::class); + } + + public function context(): array { return ['invoice' => $this->invoice->id]; } + + public function handle(StripeClient $stripe): ChargeResult { return $stripe->charge($this->invoice); } +} + +ChargeCard::run($invoice); // value, or throws what escaped +ChargeCard::attempt($invoice); // Outcome: ->status, ->value, ->exception +``` + +Use `attempt()` when the caller branches on the outcome and `run()` when it wants the value. Points nest: a child's escalation reaches the parent's `recover()`, and retries never compose across the stack. + +An operation that moves money or files with an external system also declares `->once('invoice:'.$id)` with an idempotency key, so a retried job or a double submit is refused with a `Duplicate` risk instead of running twice. To run a point on the queue, `ChargeCard::dispatch($invoice)`: the queue keeps its retries, the point keeps its policies, and a run refused by an open breaker releases the job for the breaker's retry-after. For an outbound call that does not deserve a point, `Http::breaker('stripe')->post(...)` puts the same circuit on the request. + +Rules that `php artisan monitor:points --check` enforces: + +- Names are dotted lowercase: `domain.operation`, unique across the app. +- Every point declares `escalate()` (an `Escalation` class or a closure) unless it recovers from `Throwable` deliberately. +- `control()` must not read constructor arguments; the inventory calls it without them. +- Profiles set sensible defaults: `external` for outbound HTTP, `database` for transactional writes, `messaging` for queues and email, `internal` otherwise. Declare on the point anything that differs. + +### Risks and corrections + +`recover(SomeException::class, fn ($e, Outcome $partial) => $value)` declares an expected failure and what to return instead; a class implementing `Kirschbaum\Monitor\Contracts\Correction` can stand in for the closure. The handler's return value **is** the result, `null` included. To escalate from a handler, throw. Do not use `try/catch` around the operation for expected failures; declare them. `escalateLimits()` also escalates a slow success; `throttleEscalation(600)` pages at most once per window during an outage. + +### Limits + +`within(seconds)` records a duration breach and never fails a completed run. `attempts(n)` caps retries. `ensure(fn ($result): bool, 'reason')` fails the run with `EnsureFailed` when the result is wrong even though the call "succeeded". + +### Testing a point + +```php +Monitor::fake(); // records every outcome, still runs the code +Monitor::fake()->failing('payment.charge', new CardDeclined('do_not_honor')); +Monitor::fake()->returning('payment.charge', ChargeResult::declined()); + +Monitor::assertSucceeded('payment.charge'); +Monitor::assertRecovered('payment.charge', from: CardDeclined::class); +Monitor::assertEscalated('payment.charge', with: ConnectionException::class); +Monitor::assertRetried('payment.charge', times: 2); +Monitor::assertNotEscalated('payment.charge'); +Monitor::assertNothingEscalated(); +``` + +### Reading what happened + +`php artisan monitor:points` lists every control point and its contract. `php artisan monitor:explain payment.charge` describes one. `php artisan monitor:outcomes --since=1h --status=escalated` lists recent outcomes when the store is on. Records in the log carry `point`, `domain`, `status`, `run_id` and `trace_id` as fields. diff --git a/resources/schema/record-1.json b/resources/schema/record-1.json new file mode 100644 index 0000000..a34a818 --- /dev/null +++ b/resources/schema/record-1.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/kirschbaum-development/monitor/resources/schema/record-1.json", + "title": "Monitor record", + "description": "One transition of a control point or a circuit breaker, as written to the log by Kirschbaum Monitor. Written as the log record's context; the JsonTap lifts these fields to the top level of an NDJSON line alongside ts, level, channel and message.", + "type": "object", + "required": ["schema", "event"], + "properties": { + "schema": { "const": "monitor/1" }, + "event": { + "enum": [ + "point.started", "point.retried", "point.limit", "point.recovered", "point.escalated", "point.refused", "point.ended", + "escalation.failed", "escalation.throttled", "breaker.opened", "breaker.half_open", "breaker.closed" + ] + }, + "point": { "type": "string", "description": "The control point name, e.g. payment.charge." }, + "run_id": { "type": "string", "description": "ULID of this run." }, + "parent_run_id": { "type": ["string", "null"], "description": "ULID of the enclosing run, when nested." }, + "stack": { "type": "array", "items": { "type": "string" }, "description": "Point names from outermost to this one." }, + "trace_id": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "domain": { "type": "string" }, + "origin": { "type": "string", "description": "Fully qualified class the point belongs to." }, + "profile": { "type": ["string", "null"] }, + "context": { "type": "object", "description": "Point context, redacted." }, + "attempt": { "type": "integer", "minimum": 1, "description": "On point.started and point.retried: the attempt in question." }, + "attempts": { "type": "integer", "minimum": 1, "description": "On outcome events: total attempts made." }, + "backoff_ms": { "type": "integer", "minimum": 0 }, + "duration_ms": { "type": "number", "minimum": 0 }, + "status": { "enum": ["succeeded", "recovered", "escalated", "refused"] }, + "risk": { "type": ["string", "null"], "description": "On point.recovered: the exception class the correction handled." }, + "limit": { + "type": "object", + "required": ["name", "threshold", "actual"], + "properties": { + "name": { "enum": ["duration", "attempts"] }, + "threshold": { "type": "number" }, + "actual": { "type": "number" } + } + }, + "limits_breached": { "type": "object", "additionalProperties": { "type": "object", "properties": { "threshold": { "type": "number" }, "actual": { "type": "number" } } } }, + "exception": { + "type": "object", + "required": ["class", "message"], + "properties": { + "class": { "type": "string" }, + "message": { "type": "string" }, + "file": { "type": "string" }, + "line": { "type": "integer" }, + "code": {}, + "previous": { "type": "object" }, + "trace": { "type": "array", "items": { "type": "string" } }, + "trace_truncated": { "type": "integer" } + } + }, + "escalation_exception": { "$ref": "#/properties/exception" }, + "throttle_seconds": { "type": "integer", "description": "On escalation.throttled: the window inside which one escalation already fired." }, + "breaker": { + "type": ["object", "null"], + "properties": { + "name": { "type": "string" }, + "state": { "enum": ["closed", "open", "half_open"] }, + "failures": { "type": "integer" }, + "open_for": { "type": ["integer", "null"] }, + "retry_after_s": { "type": "integer" } + } + } + } +} diff --git a/scripts/preflight.sh b/scripts/preflight.sh index a943a29..350f4ea 100755 --- a/scripts/preflight.sh +++ b/scripts/preflight.sh @@ -21,11 +21,24 @@ else echo "${GREEN}✨ ✅ Pint passed.${RESET}" fi +# -------------------------------------- +# Step 1b: Rector (Refactoring rules, dry run) +# -------------------------------------- +echo "${YELLOW}🛠 Running Rector...${RESET}" +./vendor/bin/rector process --dry-run --no-progress-bar +if [[ $? -ne 0 ]]; then + echo "${RED}🛠 ⭕ Rector would change files.${RESET}" + echo " ${YELLOW}Run 'composer rector' to apply them, then commit again.${RESET}" + status=1 +else + echo "${GREEN}🛠 ✅ Rector passed.${RESET}" +fi + # -------------------------------------- # Step 2: PHPStan (Static analysis) # -------------------------------------- echo "${YELLOW}🔢 Running PHPStan...${RESET}" -./vendor/bin/phpstan analyse --no-progress +./vendor/bin/phpstan analyse --no-progress --memory-limit=1G if [[ $? -ne 0 ]]; then echo "${RED}🔢 ⭕ PHPStan reported issues.${RESET}" status=1 diff --git a/src/Analysis/PhpStan/CriticalNamespaceRule.php b/src/Analysis/PhpStan/CriticalNamespaceRule.php new file mode 100644 index 0000000..76a77c4 --- /dev/null +++ b/src/Analysis/PhpStan/CriticalNamespaceRule.php @@ -0,0 +1,101 @@ + + */ +class CriticalNamespaceRule implements Rule +{ + /** + * @param list $namespaces + */ + public function __construct(private readonly array $namespaces) {} + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $reflection = $node->getClassReflection(); + $original = $node->getOriginalNode(); + + if ($this->namespaces === [] || ! $original instanceof Node\Stmt\Class_) { + return []; + } + + $class = $reflection->getName(); + + if (! $this->inCriticalNamespace($class) || $reflection->isAbstract() || $reflection->isAnonymous()) { + return []; + } + + if ($reflection->isSubclassOf(ControlPoint::class) || $reflection->implementsInterface(Escalation::class) || $reflection->implementsInterface(Policy::class)) { + return []; + } + + if ($this->callsControlInline($original)) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf('%s sits in a critical namespace but is not a control point and calls none.', $class)) + ->identifier('monitor.uncontrolled') + ->tip('Extend Kirschbaum\Monitor\ControlPoint, or wrap the operation in Monitor::control().') + ->build(), + ]; + } + + private function inCriticalNamespace(string $class): bool + { + foreach ($this->namespaces as $namespace) { + if (str_starts_with($class, rtrim($namespace, '\\').'\\')) { + return true; + } + } + + return false; + } + + private function callsControlInline(Node\Stmt\Class_ $node): bool + { + $calls = (new NodeFinder)->find($node->stmts, function (Node $n): bool { + if ($n instanceof Node\Expr\StaticCall && $n->name instanceof Node\Identifier && $n->name->toString() === 'control' && $n->class instanceof Node\Name) { + return in_array($n->class->toString(), [MonitorFacade::class, Monitor::class, 'Monitor'], true); + } + + return $n instanceof Node\Expr\New_ && $n->class instanceof Node\Name && $n->class->toString() === Control::class; + }); + + return $calls !== []; + } +} diff --git a/src/Attributes/Point.php b/src/Attributes/Point.php new file mode 100644 index 0000000..e363fb9 --- /dev/null +++ b/src/Attributes/Point.php @@ -0,0 +1,26 @@ +name = $name instanceof BackedEnum ? (string) $name->value : $name; + } +} diff --git a/src/Breaker/BreakerConfig.php b/src/Breaker/BreakerConfig.php new file mode 100644 index 0000000..06c5431 --- /dev/null +++ b/src/Breaker/BreakerConfig.php @@ -0,0 +1,37 @@ +after, + within: $within ?? $this->within, + for: $for ?? $this->for, + ); + } +} diff --git a/src/Breaker/BreakerState.php b/src/Breaker/BreakerState.php new file mode 100644 index 0000000..e5b05d7 --- /dev/null +++ b/src/Breaker/BreakerState.php @@ -0,0 +1,127 @@ + + */ +final readonly class BreakerState implements Arrayable, JsonSerializable +{ + /** + * @param list $failures unix timestamps of recent failures, oldest first + */ + public function __construct( + public State $state = State::Closed, + public array $failures = [], + public ?int $openedAt = null, + public ?int $openFor = null, + ) {} + + public static function closed(): self + { + return new self; + } + + public function isClosed(): bool + { + return $this->state === State::Closed; + } + + public function isOpen(): bool + { + return $this->state === State::Open; + } + + public function isHalfOpen(): bool + { + return $this->state === State::HalfOpen; + } + + public function failureCount(): int + { + return count($this->failures); + } + + /** + * Seconds until the open period ends, or zero when not open. + */ + public function retryAfter(int $now): int + { + if ($this->openedAt === null || $this->openFor === null) { + return 0; + } + + return max(0, $this->openedAt + $this->openFor - $now); + } + + public function probeDue(int $now): bool + { + return $this->isOpen() && $this->retryAfter($now) === 0; + } + + public function withFailure(int $now, BreakerConfig $config): self + { + $failures = array_values(array_filter( + $this->failures, + fn (int $at): bool => $at > $now - $config->within, + )); + $failures[] = $now; + + if (count($failures) > $config->after) { + $failures = array_slice($failures, -$config->after); + } + + return new self(state: $this->state, failures: $failures, openedAt: $this->openedAt, openFor: $this->openFor); + } + + public function opened(int $now, int $for): self + { + return new self(state: State::Open, failures: $this->failures, openedAt: $now, openFor: $for); + } + + public function halfOpened(): self + { + return new self(state: State::HalfOpen, failures: $this->failures, openedAt: $this->openedAt, openFor: $this->openFor); + } + + /** + * @return array{state: string, failures: list, opened_at: int|null, open_for: int|null} + */ + public function toArray(): array + { + return [ + 'state' => $this->state->value, + 'failures' => $this->failures, + 'opened_at' => $this->openedAt, + 'open_for' => $this->openFor, + ]; + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $state = is_string($data['state'] ?? null) ? State::tryFrom($data['state']) : null; + $failures = is_array($data['failures'] ?? null) ? array_values(array_filter($data['failures'], is_int(...))) : []; + $openedAt = is_int($data['opened_at'] ?? null) ? $data['opened_at'] : null; + $openFor = is_int($data['open_for'] ?? null) ? $data['open_for'] : null; + + return new self($state ?? State::Closed, $failures, $openedAt, $openFor); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Breaker/CircuitBreaker.php b/src/Breaker/CircuitBreaker.php new file mode 100644 index 0000000..f943d0f --- /dev/null +++ b/src/Breaker/CircuitBreaker.php @@ -0,0 +1,167 @@ +cache()->get($this->key($name)); + + return is_array($data) ? BreakerState::fromArray($data) : BreakerState::closed(); + } + + public function isOpen(string $name): bool + { + $state = $this->state($name); + + return $state->isOpen() && ! $state->probeDue($this->now()); + } + + public function isClosed(string $name): bool + { + return ! $this->isOpen($name); + } + + /** + * Decide whether an attempt may proceed, moving an expired open circuit to + * half-open and handing out its single probe. + */ + public function permit(string $name, ?BreakerConfig $config = null): Decision + { + $config ??= BreakerConfig::defaults(); + $state = $this->state($name); + $now = $this->now(); + + if ($state->isClosed()) { + return Decision::allow(); + } + + if ($state->isOpen() && ! $state->probeDue($now)) { + return Decision::refuse($state->retryAfter($now)); + } + + if ($state->isOpen()) { + $state = $state->halfOpened(); + $this->put($name, $state, $config); + $this->events->dispatch(new BreakerHalfOpen($name, $state)); + } + + // Half-open: one probe at a time. The lock outlives the open period so + // a probe that never reports back cannot hold the circuit half-open forever. + if ($this->cache()->add($this->key($name).':probe', 1, $config->for)) { + return Decision::probe(); + } + + return Decision::refuse(max(1, $state->retryAfter($now))); + } + + public function recordFailure(string $name, ?BreakerConfig $config = null): BreakerState + { + $config ??= BreakerConfig::defaults(); + $now = $this->now(); + $state = $this->state($name)->withFailure($now, $config); + + if ($state->isHalfOpen() || $state->failureCount() >= $config->after) { + $state = $state->opened($now, $config->for); + $this->put($name, $state, $config); + $this->cache()->forget($this->key($name).':probe'); + $this->events->dispatch(new BreakerOpened($name, $state)); + + return $state; + } + + $this->put($name, $state, $config); + + return $state; + } + + public function recordSuccess(string $name): BreakerState + { + $previous = $this->state($name); + + $this->cache()->forget($this->key($name)); + $this->cache()->forget($this->key($name).':probe'); + + $state = BreakerState::closed(); + + if (! $previous->isClosed()) { + $this->events->dispatch(new BreakerClosed($name, $state)); + } + + return $state; + } + + /** + * Open the circuit by hand, for instance from a health check. + */ + public function open(string $name, ?int $forSeconds = null): BreakerState + { + $config = BreakerConfig::defaults(); + $state = $this->state($name)->opened($this->now(), $forSeconds ?? $config->for); + + $this->put($name, $state, $config->with(for: $state->openFor)); + $this->events->dispatch(new BreakerOpened($name, $state)); + + return $state; + } + + public function close(string $name): BreakerState + { + return $this->recordSuccess($name); + } + + public function retryAfter(string $name): int + { + return $this->state($name)->retryAfter($this->now()); + } + + protected function now(): int + { + return Date::now()->getTimestamp(); + } + + private function put(string $name, BreakerState $state, BreakerConfig $config): void + { + // Keep the entry a little past the longest thing it describes. + $ttl = max($config->within, $config->for) + 60; + + $this->cache()->put($this->key($name), $state->toArray(), $ttl); + } + + private function key(string $name): string + { + return Config::string('monitor.breakers.prefix', 'monitor:breaker:').$name; + } + + private function cache(): Repository + { + $store = Config::get('monitor.breakers.store'); + + return Cache::store(is_string($store) && $store !== '' ? $store : null); + } +} diff --git a/src/Breaker/Decision.php b/src/Breaker/Decision.php new file mode 100644 index 0000000..1af0592 --- /dev/null +++ b/src/Breaker/Decision.php @@ -0,0 +1,29 @@ +defaultThreshold < 1) { - throw new InvalidArgumentException('Threshold must be at least 1.'); - } - - if ($this->defaultDecaySeconds < 1) { - throw new InvalidArgumentException('Decay seconds must be at least 1.'); - } - } - - public function isOpen(string $name, ?int $threshold = null, ?int $decaySeconds = null): bool - { - $state = $this->getState($name); - - if ($state === null || $state->isHealthy()) { - return false; - } - - $threshold ??= $this->defaultThreshold; - $decaySeconds ??= $this->defaultDecaySeconds; - - if ($state->hasExpired($decaySeconds)) { - $this->reset($name); - - return false; - } - - return $state->exceedsThreshold($threshold); - } - - public function isClosed(string $name, ?int $threshold = null, ?int $decaySeconds = null): bool - { - return ! $this->isOpen($name, $threshold, $decaySeconds); - } - - public function recordFailure(string $name, ?int $decaySeconds = null): self - { - $decaySeconds ??= $this->defaultDecaySeconds; - $currentState = $this->getState($name) ?? CircuitBreakerState::healthy($decaySeconds); - $newState = $currentState->recordFailure($decaySeconds); - - $this->setState($name, $newState, $decaySeconds); - - return $this; - } - - public function recordSuccess(string $name): self - { - $this->reset($name); - - return $this; - } - - public function forceOpen(string $name, ?int $decaySeconds = null): self - { - $decaySeconds ??= $this->defaultDecaySeconds; - $state = CircuitBreakerState::failed($this->defaultThreshold, $decaySeconds); - - $this->setState($name, $state, $decaySeconds); - - return $this; - } - - public function reset(string $name): self - { - Cache::forget($this->cacheKey($name)); - - return $this; - } - - public function getState(string $name): ?CircuitBreakerState - { - $data = Cache::get($this->cacheKey($name)); - - if ($data === null) { - return null; - } - - /** @var array $arrayData */ - $arrayData = (array) $data; - - return CircuitBreakerState::fromArray($arrayData); - } - - public function getFailureCount(string $name): int - { - return $this->getState($name)->failures ?? 0; - } - - public function getLastFailureTime(string $name): ?int - { - $lastFailureAt = $this->getState($name)?->lastFailureAt; - - return $lastFailureAt > 0 ? $lastFailureAt : null; - } - - public function isHealthy(string $name): bool - { - $state = $this->getState($name); - - return $state === null || $state->isHealthy(); - } - - private function setState(string $name, CircuitBreakerState $state, int $decaySeconds): void - { - $ttl = $decaySeconds + self::CACHE_TTL_BUFFER_SECONDS; - - Cache::put($this->cacheKey($name), $state->toArray(), $ttl); - } - - private function cacheKey(string $name): string - { - return self::CACHE_PREFIX.$name; - } -} diff --git a/src/Console/Commands/ExplainCommand.php b/src/Console/Commands/ExplainCommand.php new file mode 100644 index 0000000..3575393 --- /dev/null +++ b/src/Console/Commands/ExplainCommand.php @@ -0,0 +1,40 @@ +argument('point'); + $point = $discovery->build()->find($name); + + if (! $point instanceof PointDescription) { + $this->components->error(sprintf('No control point named "%s" was found.', $name)); + + return self::FAILURE; + } + + if ($this->option('json')) { + $this->line((string) json_encode($point->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return self::SUCCESS; + } + + $this->line($explainer->explain($point, $store->enabled() ? $store : null)); + + return self::SUCCESS; + } +} diff --git a/src/Console/Commands/MakeControlPointCommand.php b/src/Console/Commands/MakeControlPointCommand.php new file mode 100644 index 0000000..2c3da23 --- /dev/null +++ b/src/Console/Commands/MakeControlPointCommand.php @@ -0,0 +1,122 @@ +option('no-test')) { + $this->writeTest(); + } + + return $result; + } + + protected function getStub(): string + { + return __DIR__.'/../../../stubs/control-point.stub'; + } + + protected function getDefaultNamespace($rootNamespace): string + { + return $rootNamespace.'\ControlPoints'; + } + + protected function buildClass($name): string + { + $stub = parent::buildClass($name); + + return str_replace( + ['{{ point }}', '{{ profile }}'], + [$this->pointName($name), $this->profileArgument()], + $stub, + ); + } + + /** + * @return array> + */ + protected function getOptions(): array + { + return [ + ['point', null, InputOption::VALUE_OPTIONAL, 'The control point name (default: derived from the class)'], + ['profile', null, InputOption::VALUE_OPTIONAL, 'The profile to start from: external, database, messaging or internal'], + ['no-test', null, InputOption::VALUE_NONE, 'Do not generate the test'], + ['force', 'f', InputOption::VALUE_NONE, 'Overwrite the class if it already exists'], + ]; + } + + private function pointName(string $class): string + { + $given = $this->option('point'); + + if (is_string($given) && $given !== '') { + return $given; + } + + $relative = Str::after($class, $this->getDefaultNamespace(trim($this->rootNamespace(), '\\')).'\\'); + $segments = explode('\\', $relative); + $short = array_pop($segments); + $domain = $segments === [] ? 'app' : Str::snake(implode('', $segments)); + + return $domain.'.'.Str::snake($short); + } + + private function profileArgument(): string + { + $profile = $this->option('profile'); + + return is_string($profile) && $profile !== '' ? ", profile: '{$profile}'" : ''; + } + + private function writeTest(): void + { + $name = $this->qualifyClass($this->getNameInput()); + $relative = Str::after($name, $this->getDefaultNamespace(trim($this->rootNamespace(), '\\')).'\\'); + $path = base_path('tests/Feature/ControlPoints/'.str_replace('\\', '/', $relative).'Test.php'); + + if ($this->files->exists($path) && ! $this->option('force')) { + $this->components->warn(sprintf('Test already exists: %s', $path)); + + return; + } + + $stub = $this->files->get(__DIR__.'/../../../stubs/control-point-test.stub'); + $short = class_basename($name); + + $content = str_replace( + ['{{ class }}', '{{ fqcn }}', '{{ point }}'], + [$short, $name, $this->pointName($name)], + $stub, + ); + + $this->makeDirectory($path); + $this->files->put($path, $content); + $this->components->info(sprintf('Test [%s] created successfully.', $path)); + } +} diff --git a/src/Console/Commands/OutcomesCommand.php b/src/Console/Commands/OutcomesCommand.php new file mode 100644 index 0000000..3125ed8 --- /dev/null +++ b/src/Console/Commands/OutcomesCommand.php @@ -0,0 +1,104 @@ +enabled()) { + $this->components->error('The outcome store is disabled. Set MONITOR_STORE_ENABLED=true and run the migration.'); + + return self::FAILURE; + } + + $since = self::parseSince($this->stringOption('since') ?? '24h'); + + if (! $since instanceof Carbon) { + $this->components->error('--since must look like 15m, 2h or 7d.'); + + return self::INVALID; + } + + $rows = $store->recent([ + 'point' => $this->stringOption('point'), + 'domain' => $this->stringOption('domain'), + 'status' => $this->stringOption('status'), + 'trace' => $this->stringOption('trace'), + 'since' => $since, + ], (int) ($this->stringOption('limit') ?? '50')); + + if ($this->option('json')) { + $this->line((string) json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return self::SUCCESS; + } + + if ($rows === []) { + $this->components->info('No outcomes in that window.'); + + return self::SUCCESS; + } + + $this->table( + ['Ended', 'Point', 'Domain', 'Status', 'Attempts', 'ms', 'Exception', 'Trace'], + array_map(fn (array $row): array => [ + is_string($row['ended_at']) ? $row['ended_at'] : '', + $row['point'], + $row['domain'], + $row['status'], + $row['attempts'], + $row['duration_ms'], + $row['exception_class'] ?? '', + is_string($row['trace_id']) ? substr($row['trace_id'], 0, 8).'…' : '', + ], $rows), + ); + + return self::SUCCESS; + } + + /** + * Parse a window like 15m, 2h or 7d into the moment it starts. + * + * @internal + */ + public static function parseSince(string $value): ?Carbon + { + if (preg_match('/^(\d+)([mhd])$/', trim($value), $m) !== 1) { + return null; + } + + $amount = (int) $m[1]; + + return match ($m[2]) { + 'm' => Date::now()->subMinutes($amount), + 'h' => Date::now()->subHours($amount), + default => Date::now()->subDays($amount), + }; + } + + private function stringOption(string $name): ?string + { + $value = $this->option($name); + + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/Console/Commands/PointsCommand.php b/src/Console/Commands/PointsCommand.php new file mode 100644 index 0000000..0150f13 --- /dev/null +++ b/src/Console/Commands/PointsCommand.php @@ -0,0 +1,59 @@ +option('path'), is_string(...))); + $inventory = $discovery->build($paths === [] ? null : $paths); + + $format = $this->option('format'); + + match ($format) { + 'json' => $this->line((new JsonReport)->render($inventory)), + 'sarif' => $this->line((new SarifReport)->render($inventory, base_path())), + default => (new TableReport)->render($this->output, $inventory, $this->lastSeen($store)), + }; + + if ($this->option('check') && $inventory->hasErrors()) { + return self::FAILURE; + } + + return self::SUCCESS; + } + + /** + * @return array + */ + private function lastSeen(OutcomeStore $store): array + { + if (! $store->enabled()) { + return []; + } + + try { + return $store->lastSeen(); + } catch (Throwable) { + return []; + } + } +} diff --git a/src/Console/Commands/PruneCommand.php b/src/Console/Commands/PruneCommand.php new file mode 100644 index 0000000..64919ed --- /dev/null +++ b/src/Console/Commands/PruneCommand.php @@ -0,0 +1,31 @@ +enabled()) { + $this->components->warn('The outcome store is disabled; nothing to prune.'); + + return self::SUCCESS; + } + + $days = $this->option('days'); + $deleted = $store->prune(is_numeric($days) ? max(1, (int) $days) : null); + + $this->components->info("Pruned {$deleted} outcome(s)."); + + return self::SUCCESS; + } +} diff --git a/src/Contracts/Correction.php b/src/Contracts/Correction.php new file mode 100644 index 0000000..a353e82 --- /dev/null +++ b/src/Contracts/Correction.php @@ -0,0 +1,21 @@ + + */ + public function describe(): array; +} diff --git a/src/Contracts/Policy.php b/src/Contracts/Policy.php new file mode 100644 index 0000000..bbac4cc --- /dev/null +++ b/src/Contracts/Policy.php @@ -0,0 +1,41 @@ + + */ + public function describe(): array; +} diff --git a/src/Contracts/Rule.php b/src/Contracts/Rule.php new file mode 100644 index 0000000..c3046f2 --- /dev/null +++ b/src/Contracts/Rule.php @@ -0,0 +1,18 @@ + + */ + public function check(Inventory $inventory): array; +} diff --git a/src/Contracts/Runner.php b/src/Contracts/Runner.php new file mode 100644 index 0000000..3229ead --- /dev/null +++ b/src/Contracts/Runner.php @@ -0,0 +1,20 @@ + */ + private array $context = []; + + /** @var array keyed by type for the shipped policies, by class for others */ + private array $policies = []; + + private ?Within $within = null; + + private ?Attempts $attempts = null; + + /** @var list */ + private array $ensures = []; + + /** @var list, handler: Closure|class-string}> */ + private array $risks = []; + + private bool $escalateLimits = false; + + private ?int $escalationThrottle = null; + + /** @var Closure|class-string|null */ + private Closure|string|null $escalation = null; + + /** + * @param bool $validate false only when describing a point whose name may be invalid + */ + public function __construct(string|BackedEnum $name, string|object|null $origin = null, bool $validate = true) + { + $this->name = PointName::of($name); + + if ($validate) { + PointName::validate($this->name); + } + + $this->origin = $this->originOf($origin); + } + + public function name(): string + { + return $this->name; + } + + /** + * The class this point belongs to. Usually $this at the call site. + */ + public function from(string|object $origin): self + { + $this->origin = $this->originOf($origin); + + return $this; + } + + public function origin(): string + { + return $this->origin; + } + + /** + * Override the domain derived from the origin's namespace. + */ + public function domain(string $domain): self + { + $this->domain = $domain; + + return $this; + } + + public function resolvedDomain(): string + { + return $this->domain ?? Domain::resolve($this->origin); + } + + /** + * Context recorded with every transition. Merged with what is already there. + * + * @param array $context + */ + public function with(array $context): self + { + $this->context = array_merge($this->context, $context); + + return $this; + } + + /** + * @return array + */ + public function context(): array + { + return $this->context; + } + + /** + * Start from a configured bundle of policies and limits. Anything declared + * on the point afterwards overrides the profile's version of it. + */ + public function profile(string $name): self + { + Profiles::definition($name); + + $this->profile = $name; + + return $this; + } + + public function profileName(): ?string + { + return $this->profile; + } + + /** + * @param list> $on + * @param list> $except + */ + public function retry(int $times = 2, int $backoffMs = 200, float $multiplier = 2.0, bool $jitter = true, array $on = [], array $except = []): self + { + $this->policies['retry'] = Retry::times($times)->backoff($backoffMs, $multiplier, $jitter)->on($on)->except($except); + + return $this; + } + + /** + * @param list> $on + * @param list> $except + */ + public function transaction(int $retries = 0, array $on = [DeadlockException::class], array $except = [], ?string $connection = null): self + { + $this->policies['transaction'] = Transaction::retries($retries)->on($on)->except($except)->connection($connection); + + return $this; + } + + public function breaker(string $name, ?int $after = null, ?int $within = null, ?int $for = null): self + { + $breaker = Breaker::named($name); + + if ($after !== null) { + $breaker->after($after, $within); + } + + if ($for !== null) { + $breaker->for($for); + } + + $this->policies['breaker'] = $breaker; + + return $this; + } + + /** + * Run at most once per idempotency key inside the window, refusing a + * second run with a Duplicate risk. + */ + public function once(string $key, int $ttl = 3600): self + { + $this->policies['once'] = Once::key($key, $ttl); + + return $this; + } + + /** + * Any policy, including your own. A shipped policy passed here replaces the + * one of the same type. + */ + public function policy(Policy $policy): self + { + $this->policies[$this->policyKey($policy)] = $policy; + + return $this; + } + + /** + * The whole run should finish inside this many seconds. Recorded, never fatal. + */ + public function within(float|int $seconds): self + { + $this->within = new Within((float) $seconds); + + return $this; + } + + /** + * A hard cap on attempts, whatever the retry policies ask for. + */ + public function attempts(int $max): self + { + $this->attempts = new Attempts(max(1, $max)); + + return $this; + } + + /** + * A post-condition on the value. Fails the run with EnsureFailed when false. + * + * @param Closure(mixed): bool $check + */ + public function ensure(Closure $check, string $reason = 'result did not satisfy ensure()'): self + { + $this->ensures[] = new Ensure($check, $reason); + + return $this; + } + + /** + * Declare a risk and its correction. The handler's return value becomes the + * result of the point. First matching class wins, in declaration order. + * + * @param (Closure(Throwable, Outcome): mixed)|string $handler a closure, or a Correction class name + */ + public function recover(string $class, Closure|string $handler): self + { + if (! is_a($class, Throwable::class, true)) { + throw new InvalidControlPoint(sprintf('recover() expects a Throwable class, got [%s].', $class)); + } + + if (is_string($handler) && ! is_a($handler, Correction::class, true)) { + throw new InvalidControlPoint(sprintf('recover() expects a Closure or a Correction class, got [%s].', $handler)); + } + + $this->risks[] = ['class' => $class, 'handler' => $handler]; + + return $this; + } + + /** + * Who is told when the point fails with something no correction covers. + * + * @param Closure(Outcome): void|string $escalation a closure or an Escalation class name + */ + public function escalate(Closure|string $escalation): self + { + if (is_string($escalation) && ! is_a($escalation, Escalation::class, true)) { + throw new InvalidControlPoint(sprintf('escalate() expects a Closure or an Escalation class, got [%s].', $escalation)); + } + + $this->escalation = $escalation; + + return $this; + } + + /** + * Also escalate a run that completed but breached a limit, so a slow + * success reaches the same people as a failure. + */ + public function escalateLimits(bool $escalate = true): self + { + $this->escalateLimits = $escalate; + + return $this; + } + + public function escalatesLimits(): bool + { + return $this->escalateLimits; + } + + /** + * Escalate at most once per this many seconds for this point, so an + * outage does not page for every refused run. + */ + public function throttleEscalation(int $seconds): self + { + $this->escalationThrottle = max(1, $seconds); + + return $this; + } + + public function escalationThrottle(): ?int + { + return $this->escalationThrottle; + } + + /** + * Execute and return the value, or throw what escaped. + * + * @template T + * + * @param Closure(): T $callback + * @return T + */ + public function run(Closure $callback): mixed + { + $outcome = $this->attempt($callback); + + if ($outcome->status->isFailure() && $outcome->exception instanceof Throwable) { + throw $outcome->exception; + } + + return $outcome->value; + } + + /** + * Execute and return the Outcome. Never throws for what the operation did. + * + * @param Closure(): mixed $callback + */ + public function attempt(Closure $callback): Outcome + { + return Container::getInstance()->make(Runner::class)->execute($this, $callback); + } + + /** + * The policies that will run, profile first, ordered outermost first. + * + * @return list + */ + public function resolvedPolicies(): array + { + $policies = $this->profile !== null + ? array_merge(Profiles::policies($this->profile, $this->name), $this->policies) + : $this->policies; + + $list = array_values($policies); + + usort($list, fn (Policy $a, Policy $b): int => $a->order() <=> $b->order()); + + return $list; + } + + public function resolvedWithin(): ?Within + { + return $this->within ?? ($this->profile !== null ? Profiles::within($this->profile) : null); + } + + public function resolvedAttempts(): ?Attempts + { + return $this->attempts ?? ($this->profile !== null ? Profiles::attempts($this->profile) : null); + } + + /** + * @return list + */ + public function ensures(): array + { + return $this->ensures; + } + + /** + * @return list, handler: Closure|class-string}> + */ + public function risks(): array + { + return $this->risks; + } + + /** + * @return list> + */ + public function riskClasses(): array + { + return array_map(fn (array $risk): string => $risk['class'], $this->risks); + } + + public function hasCatchAll(): bool + { + return in_array(Throwable::class, $this->riskClasses(), true); + } + + /** + * @return Closure|class-string|null + */ + public function escalation(): Closure|string|null + { + return $this->escalation; + } + + public function hasEscalation(): bool + { + return $this->escalation !== null; + } + + /** + * The static description of this point: everything the inventory can know + * without running it. + * + * @return array + */ + public function describe(): array + { + $limits = []; + + if (($within = $this->resolvedWithin()) instanceof Within) { + $limits[] = $within->describe(); + } + + if (($attempts = $this->resolvedAttempts()) instanceof Attempts) { + $limits[] = $attempts->describe(); + } + + foreach ($this->ensures as $ensure) { + $limits[] = $ensure->describe(); + } + + $escalation = $this->escalation; + + return [ + 'point' => $this->name, + 'origin' => $this->origin, + 'domain' => $this->resolvedDomain(), + 'profile' => $this->profile, + 'policies' => array_map(fn (Policy $policy): array => $policy->describe(), $this->resolvedPolicies()), + 'limits' => $limits, + 'risks' => $this->riskClasses(), + 'corrections' => array_map(fn (array $risk): string => is_string($risk['handler']) ? $risk['handler'] : 'closure', $this->risks), + 'catch_all' => $this->hasCatchAll(), + 'escalation' => is_string($escalation) ? $escalation : ($escalation instanceof Closure ? 'closure' : null), + 'escalate_limits' => $this->escalateLimits, + 'escalation_throttle' => $this->escalationThrottle, + ]; + } + + private function originOf(string|object|null $origin): string + { + if ($origin === null) { + return Control::class; + } + + return is_object($origin) ? $origin::class : $origin; + } + + private function policyKey(Policy $policy): string + { + return match (true) { + $policy instanceof Retry => 'retry', + $policy instanceof Transaction => 'transaction', + $policy instanceof Breaker => 'breaker', + $policy instanceof Once => 'once', + default => $policy::class, + }; + } +} diff --git a/src/ControlPoint.php b/src/ControlPoint.php new file mode 100644 index 0000000..43b736a --- /dev/null +++ b/src/ControlPoint.php @@ -0,0 +1,183 @@ +recover(CardDeclined::class, fn ($e) => ChargeResult::declined($e->code)) + * ->escalate(PagePayments::class); + * } + * + * public function handle(StripeClient $stripe): ChargeResult { ... } + * } + * + * ChargeCard::run($invoice); // value or throws + * ChargeCard::attempt($invoice); // Outcome + * + * handle() is resolved through the container, so its parameters are injected. + * control() must not read constructor arguments: the inventory calls it on an + * instance built without the constructor, and says so when that fails. + */ +abstract class ControlPoint +{ + /** + * Declare risks, corrections, policies, limits and escalation. + */ + protected function control(Control $control): void {} + + /** + * Context recorded with every transition of this point. + * + * @return array + */ + public function context(): array + { + return []; + } + + /** + * Construct and execute; return the value or throw what escaped. + */ + public static function run(mixed ...$arguments): mixed + { + return static::make(...$arguments)->toControl()->run(fn (): mixed => static::invoke(static::make(...$arguments))); + } + + /** + * Construct and execute; return the Outcome. + */ + public static function attempt(mixed ...$arguments): Outcome + { + $instance = static::make(...$arguments); + + return $instance->toControl()->attempt(fn (): mixed => static::invoke($instance)); + } + + /** + * Construct the point and run it on the queue. The queue owns the job's + * retries; the point owns its policies, corrections and records. + */ + public static function dispatch(mixed ...$arguments): PendingDispatch + { + return dispatch(new RunControlPoint(static::make(...$arguments))); + } + + /** + * Construct the point and run it through the queue synchronously. + */ + public static function dispatchSync(mixed ...$arguments): mixed + { + return dispatch_sync(new RunControlPoint(static::make(...$arguments))); + } + + /** + * Execute this instance and return the Outcome. + */ + public function execute(): Outcome + { + return $this->toControl()->attempt(fn (): mixed => static::invoke($this)); + } + + /** + * The Control this point declares, ready to run. + */ + public function toControl(): Control + { + $point = static::point(); + + $control = new Control($point->name, static::class); + + if ($point->profile !== null) { + $control->profile($point->profile); + } + + if ($point->domain !== null) { + $control->domain($point->domain); + } + + $this->control($control); + + return $control->with($this->context()); + } + + /** + * The #[Point] attribute on this class. + */ + public static function point(): Point + { + $attributes = (new ReflectionClass(static::class))->getAttributes(Point::class); + + if ($attributes === []) { + throw new InvalidControlPoint(sprintf('[%s] must carry a #[Point] attribute.', static::class)); + } + + return $attributes[0]->newInstance(); + } + + /** + * The static description of this point for the inventory, without running + * or constructing it. + * + * @return array + */ + public static function describe(): array + { + $instance = (new ReflectionClass(static::class))->newInstanceWithoutConstructor(); + $point = static::point(); + $control = new Control($point->name, static::class, validate: false); + + if ($point->profile !== null) { + $control->profile($point->profile); + } + + if ($point->domain !== null) { + $control->domain($point->domain); + } + + $notes = []; + $unreadable = false; + + try { + $instance->control($control); + } catch (Throwable $e) { + $unreadable = true; + $notes[] = sprintf('control() could not be read statically: %s', $e->getMessage()); + } + + return $control->describe() + ['form' => 'class', 'notes' => $notes, 'unreadable' => $unreadable]; + } + + protected static function make(mixed ...$arguments): static + { + return (new ReflectionClass(static::class))->newInstanceArgs($arguments); + } + + protected static function invoke(self $instance): mixed + { + $handle = [$instance, 'handle']; + + if (! is_callable($handle)) { + throw new InvalidControlPoint(sprintf('[%s] must define a public handle() method.', static::class)); + } + + return Container::getInstance()->call($handle); + } +} diff --git a/src/ControlStack.php b/src/ControlStack.php new file mode 100644 index 0000000..2a60aa7 --- /dev/null +++ b/src/ControlStack.php @@ -0,0 +1,139 @@ +all(); + $stack[] = ['point' => $point, 'run_id' => $runId, 'origin' => $origin]; + + Context::addHidden(self::KEY, $stack); + Context::add(self::CURRENT, $point); + } + + public function pop(): void + { + $stack = $this->all(); + array_pop($stack); + + if ($stack === []) { + Context::forgetHidden(self::KEY); + Context::forget(self::CURRENT); + + return; + } + + Context::addHidden(self::KEY, $stack); + Context::add(self::CURRENT, $stack[array_key_last($stack)]['point']); + } + + /** + * @return array{point: string, run_id: string, origin: string|null}|null + */ + public function current(): ?array + { + $stack = $this->all(); + + return $stack === [] ? null : $stack[array_key_last($stack)]; + } + + public function currentRunId(): ?string + { + return $this->current()['run_id'] ?? null; + } + + public function depth(): int + { + return count($this->all()); + } + + public function isInside(): bool + { + return $this->all() !== []; + } + + /** + * @return list + */ + public function names(): array + { + return array_map(fn (array $entry): string => $entry['point'], $this->all()); + } + + /** + * The origin class of the innermost running point, if any. + */ + public function currentOrigin(): ?string + { + return $this->current()['origin'] ?? null; + } + + /** + * Forget the stack but remember which run dispatched this process's work, + * for a job that starts with a stack it inherited through Context. + */ + public function handOff(): void + { + $current = $this->currentRunId(); + + $this->clear(); + + if ($current !== null) { + Context::add(self::DISPATCHED_FROM, $current); + } + } + + public function dispatchedFrom(): ?string + { + $id = Context::get(self::DISPATCHED_FROM); + + return is_string($id) ? $id : null; + } + + /** + * @return list + */ + public function all(): array + { + $stack = Context::getHidden(self::KEY); + + if (! is_array($stack)) { + return []; + } + + $entries = []; + + foreach ($stack as $entry) { + if (is_array($entry) && is_string($entry['point'] ?? null) && is_string($entry['run_id'] ?? null)) { + $entries[] = ['point' => $entry['point'], 'run_id' => $entry['run_id'], 'origin' => is_string($entry['origin'] ?? null) ? $entry['origin'] : null]; + } + } + + return $entries; + } + + public function clear(): void + { + Context::forgetHidden(self::KEY); + Context::forget(self::CURRENT); + } +} diff --git a/src/Controlled.php b/src/Controlled.php deleted file mode 100644 index d0a0dc0..0000000 --- a/src/Controlled.php +++ /dev/null @@ -1,400 +0,0 @@ -, \Closure> */ - protected array $exceptionHandlers = []; - - protected ?Closure $onUncaughtCallback = null; - - /** @var array */ - protected array $context = []; - - protected ?string $breakerName = null; - - protected int $breakerThreshold = 5; - - protected int $breakerDecaySeconds = 300; - - protected bool $withTransaction = false; - - protected int $transactionRetries = 0; - - /** @var array> */ - protected array $retryOnlyExceptions = []; - - /** @var array> */ - protected array $retryExcludeExceptions = []; - - protected int $attempt = 1; - - protected ?string $traceIdOverride = null; - - protected ?StructuredLogger $logger = null; - - public function __construct(string $name, string|object|null $origin = null) - { - $this->name = $name; - - if ($origin !== null) { - $this->withStructuredLogger(StructuredLogger::from($origin)); - } - } - - public static function for(string $name, string|object|null $origin = null): self - { - return new self($name, $origin); - } - - /** - * Replace the entire context. - * - * @param array $context - */ - public function overrideContext(array $context): self - { - $this->context = $context; - - return $this; - } - - /** - * Merge additional context into existing context. - * - * @param array $context - */ - public function addContext(array $context): self - { - $this->context = array_merge($this->context, $context); - - return $this; - } - - /** - * Define exception-specific handlers - * - * @param array, \Closure> $handlers - */ - public function catching(array $handlers): self - { - $this->exceptionHandlers = $handlers; - - return $this; - } - - public function onUncaughtException(Closure $callback): self - { - $this->onUncaughtCallback = $callback; - - return $this; - } - - public function withCircuitBreaker(string $name, int $threshold = 5, int $decaySeconds = 300): self - { - $this->breakerName = $name; - $this->breakerThreshold = $threshold; - $this->breakerDecaySeconds = $decaySeconds; - - return $this; - } - - /** - * @param array> $only - * @param array> $exclude - */ - public function withDatabaseTransaction(int $retries = 0, array $only = [], array $exclude = []): self - { - $this->withTransaction = true; - $this->transactionRetries = $retries; - $this->retryOnlyExceptions = $only; - $this->retryExcludeExceptions = $exclude; - - return $this; - } - - public function overrideTraceId(string $traceId): self - { - $this->traceIdOverride = $traceId; - - return $this; - } - - public function withStructuredLogger(StructuredLogger $logger): self - { - $this->logger = $logger; - - return $this; - } - - public function run(Closure $callback): mixed - { - return $this->execute($callback); - } - - protected function execute(Closure $callback): mixed - { - if ($this->traceIdOverride) { - Monitor::trace()->override($this->traceIdOverride); - } - - $trace = Monitor::trace()->pickup(); - $timer = new LogTimer; - $blockId = Str::uuid()->toString(); - - // Check for nested Controlled blocks - $contextTracker = app(ControlledContext::class); - $contextTracker->enter($this->name, $blockId); - - try { - // Check circuit breaker - if ($this->breakerName && $this->isCircuitBreakerOpen($this->breakerName)) { - $circuitBreakerException = new \RuntimeException("Circuit breaker '{$this->breakerName}' is open"); - $this->handleUncaughtException($circuitBreakerException, $blockId, $trace->id(), $timer->elapsed()); - throw $circuitBreakerException; - } - - /** @var array $startContext */ - $startContext = [ - 'controlled_block' => $this->name, - 'controlled_block_id' => $blockId, - 'trace_id' => $trace->id(), - 'attempt' => $this->attempt, - 'block_context' => $this->context, - ]; - - $this->getLogger()->info('STARTED', $startContext); - - try { - $result = $this->withTransaction - ? $this->executeWithTransaction($callback) - : $callback(); - - /** @var array $endContext */ - $endContext = [ - 'controlled_block' => $this->name, - 'controlled_block_id' => $blockId, - 'trace_id' => $trace->id(), - 'status' => 'ok', - 'duration_ms' => $timer->elapsed(), - 'attempt' => $this->attempt, - 'block_context' => $this->context, - ]; - - $this->getLogger()->info('ENDED', $endContext); - - // Reset circuit breaker on success - if ($this->breakerName) { - $this->resetCircuitBreaker($this->breakerName); - } - - return $result; - } catch (Throwable $e) { - // Handle circuit breaker - if ($this->breakerName) { - $this->recordCircuitBreakerFailure($this->breakerName); - } - - // Try to handle the exception with catching handlers - $handled = $this->handleCaughtException($e, $blockId, $trace->id(), $timer->elapsed()); - - if ($handled) { - // Exception was caught and handled, don't re-throw - return $handled; - } - - // Exception was not caught by any handler - this is an uncaught exception - $this->handleUncaughtException($e, $blockId, $trace->id(), $timer->elapsed()); - - throw $e; - } - } finally { - $contextTracker->exit(); - } - } - - protected function executeWithTransaction(Closure $callback): mixed - { - $attempts = 0; - $maxAttempts = $this->transactionRetries + 1; - $lastException = null; - - while ($attempts < $maxAttempts) { - $this->attempt = $attempts + 1; - - try { - return DB::transaction(function () use ($callback) { - return $callback(); - }); - } catch (Throwable $e) { - $lastException = $e; - $attempts++; - - if (! $this->shouldRetryException($e)) { - throw $e; - } - - if ($attempts < $maxAttempts) { - // Log retry attempt - $this->getLogger()->warning('RETRY', [ - 'controlled_block' => $this->name, - 'attempt' => $this->attempt, - 'max_attempts' => $maxAttempts, - 'error' => $e->getMessage(), - ]); - } - } - } - - throw $lastException ?? new \RuntimeException('Transaction failed with unknown error.'); - } - - protected function handleCaughtException(Throwable $e, string $blockId, string $traceId, float $durationMs): mixed - { - // Check if we have any exception handlers that match this exception - foreach ($this->exceptionHandlers as $exceptionClass => $handler) { - if ($e instanceof $exceptionClass) { - $meta = new ControlledFailureMeta( - name: $this->name, - id: $blockId, - traceId: $traceId, - attempt: $this->attempt, - durationMs: $durationMs, - exception: $e, - context: $this->context, - breakerTripped: false, - uncaught: false, - ); - - $this->getLogger()->warning('CAUGHT', $meta->toArray()); - - try { - $result = $handler($e, $meta->toArray()); - - // If handler returns a value, use it as recovery value - if ($result !== null) { - $this->getLogger()->info('RECOVERED', [ - 'controlled_block' => $this->name, - 'controlled_block_id' => $blockId, - 'exception_class' => get_class($e), - 'recovery_value' => is_scalar($result) ? $result : gettype($result), - ]); - - return $result; - } - - return true; // Indicate exception was handled - } catch (Throwable $handlerException) { - $this->getLogger()->error('EXCEPTION_HANDLER_ERROR', [ - 'controlled_block' => $this->name, - 'controlled_block_id' => $blockId, - 'exception_class' => $exceptionClass, - 'handler_exception' => $handlerException->getMessage(), - 'original_exception' => $e->getMessage(), - ]); - - // If handler throws, treat as unhandled - return false; - } - } - } - - return false; // No handler found - } - - protected function handleUncaughtException(Throwable $e, string $blockId, string $traceId, float $durationMs): void - { - $meta = new ControlledFailureMeta( - name: $this->name, - id: $blockId, - traceId: $traceId, - attempt: $this->attempt, - durationMs: $durationMs, - exception: $e, - context: $this->context, - breakerTripped: false, - uncaught: true, - ); - - $this->getLogger()->critical('UNCAUGHT', $meta->toArray()); - - // Only call uncaught exception callback - if ($this->onUncaughtCallback) { - try { - ($this->onUncaughtCallback)($e, $meta->toArray()); - } catch (Throwable $uncaughtCallbackException) { - $this->getLogger()->error('UNCAUGHT_CALLBACK_ERROR', [ - 'controlled_block' => $this->name, - 'controlled_block_id' => $blockId, - 'callback_exception' => $uncaughtCallbackException->getMessage(), - 'original_exception' => $e->getMessage(), - ]); - } - } - } - - protected function isCircuitBreakerOpen(string $breakerName): bool - { - return app(CircuitBreaker::class)->isOpen( - $breakerName, - $this->breakerThreshold, - $this->breakerDecaySeconds - ); - } - - protected function recordCircuitBreakerFailure(string $breakerName): void - { - app(CircuitBreaker::class)->recordFailure( - $breakerName, - $this->breakerDecaySeconds - ); - } - - protected function resetCircuitBreaker(string $breakerName): void - { - app(CircuitBreaker::class)->recordSuccess($breakerName); - } - - protected function shouldRetryException(Throwable $e): bool - { - // If only specific exceptions are allowed, check if this one is included - if (! empty($this->retryOnlyExceptions)) { - foreach ($this->retryOnlyExceptions as $allowedClass) { - if ($e instanceof $allowedClass) { - break; - } - } - // If we didn't find a match, don't retry - if (! ($e instanceof $allowedClass)) { - return false; - } - } - - // If this exception is explicitly excluded, don't retry - foreach ($this->retryExcludeExceptions as $excludedClass) { - if ($e instanceof $excludedClass) { - return false; - } - } - - return true; - } - - protected function getLogger(): StructuredLogger - { - return $this->logger ?? Monitor::log("ControlledBlock:{$this->name}"); - } -} diff --git a/src/Data/CircuitBreakerState.php b/src/Data/CircuitBreakerState.php deleted file mode 100644 index 9cf688e..0000000 --- a/src/Data/CircuitBreakerState.php +++ /dev/null @@ -1,142 +0,0 @@ -failures < 0) { - throw new InvalidArgumentException('Failure count cannot be negative.'); - } - - if ($this->lastFailureAt < 0) { - throw new InvalidArgumentException('Last failure timestamp cannot be negative.'); - } - - if ($this->decaySeconds !== null && $this->decaySeconds < 1) { - throw new InvalidArgumentException('Decay seconds must be at least 1.'); - } - } - - public function recordFailure(?int $decaySeconds = null): self - { - return new self( - failures: $this->failures + 1, - lastFailureAt: time(), - decaySeconds: $decaySeconds ?? $this->decaySeconds, - ); - } - - public function hasExpired(int $decaySeconds): bool - { - if ($decaySeconds <= 0) { - return false; - } - - if ($this->lastFailureAt === 0) { - return true; - } - - return (time() - $this->lastFailureAt) >= $decaySeconds; - } - - public function exceedsThreshold(int $threshold): bool - { - return $this->failures >= max(1, $threshold); - } - - public function isHealthy(): bool - { - return $this->failures === 0; - } - - /** - * @return array{failures: int, last_failure_at: int, decay_seconds: int|null} - */ - public function toArray(): array - { - return [ - 'failures' => $this->failures, - 'last_failure_at' => $this->lastFailureAt, - 'decay_seconds' => $this->decaySeconds, - ]; - } - - /** - * @param array $data - */ - public static function fromArray(array $data): self - { - $failures = self::extractRequiredIntegerValue($data, 'failures', 0); - $lastFailureAt = self::extractRequiredIntegerValue($data, 'last_failure_at', 0); - $decaySeconds = self::extractOptionalIntegerValue($data, 'decay_seconds'); - - return new self( - failures: $failures, - lastFailureAt: $lastFailureAt, - decaySeconds: $decaySeconds, - ); - } - - public static function healthy(?int $decaySeconds = null): self - { - return new self(decaySeconds: $decaySeconds); - } - - public static function failed(int $threshold = 999, ?int $decaySeconds = null): self - { - return new self( - failures: max(1, $threshold), - lastFailureAt: time(), - decaySeconds: $decaySeconds, - ); - } - - /** - * @param array $data - */ - private static function extractRequiredIntegerValue(array $data, string $key, int $default): int - { - $value = $data[$key] ?? $default; - - if (is_int($value)) { - return $value; - } - - if (is_numeric($value)) { - return (int) $value; - } - - return $default; - } - - /** - * @param array $data - */ - private static function extractOptionalIntegerValue(array $data, string $key): ?int - { - $value = $data[$key] ?? null; - - if ($value === null) { - return null; - } - - if (is_int($value)) { - return $value; - } - - if (is_numeric($value)) { - return (int) $value; - } - - return null; - } -} diff --git a/src/Data/ControlledFailureMeta.php b/src/Data/ControlledFailureMeta.php deleted file mode 100644 index 46e1ab3..0000000 --- a/src/Data/ControlledFailureMeta.php +++ /dev/null @@ -1,76 +0,0 @@ - $context - */ - public function __construct( - public string $name, - public string $id, - public string $traceId, - public int $attempt, - public float $durationMs, - public ?Throwable $exception, - public array $context = [], - public bool $breakerTripped = false, - public bool $uncaught = false, - ) {} - - public function failed(): bool - { - return $this->exception !== null; - } - - public function succeeded(): bool - { - return $this->exception === null; - } - - public function wasRetried(): bool - { - return $this->attempt > 1; - } - - /** - * @return array - */ - public function toArray(): array - { - return [ - 'controlled_block' => $this->name, - 'controlled_block_id' => $this->id, - 'trace_id' => $this->traceId, - 'attempt' => $this->attempt, - 'duration_ms' => $this->durationMs, - 'status' => $this->failed() ? 'failed' : 'succeeded', - 'breaker_tripped' => $this->breakerTripped, - 'uncaught' => $this->uncaught, - 'exception' => $this->buildExceptionData(), - 'block_context' => $this->context, - ]; - } - - /** - * @return array{class: string, message: string, file: string, line: int}|null - */ - private function buildExceptionData(): ?array - { - if ($this->exception === null) { - return null; - } - - return [ - 'class' => get_class($this->exception), - 'message' => $this->exception->getMessage(), - 'file' => $this->exception->getFile(), - 'line' => $this->exception->getLine(), - ]; - } -} diff --git a/src/Enums/OriginWrapper.php b/src/Enums/OriginWrapper.php deleted file mode 100644 index 33fed27..0000000 --- a/src/Enums/OriginWrapper.php +++ /dev/null @@ -1,45 +0,0 @@ - - case DoubleQuotes = 'double'; // "Monitor:Foo" - case SingleQuotes = 'single'; // 'Monitor:Foo' - case Asterisks = 'asterisks'; // *Monitor:Foo* - - public function wrap(string $origin): string - { - return match ($this) { - self::SquareBrackets => "[{$origin}]", - self::CurlyBraces => "{{$origin}}", - self::Parentheses => "({$origin})", - self::AngleBrackets => "<{$origin}>", - self::DoubleQuotes => "\"{$origin}\"", - self::SingleQuotes => "'{$origin}'", - self::Asterisks => "*{$origin}*", - self::None => $origin, - }; - } - - public static function fromConfig(?string $value): self - { - return match ($value) { - 'square' => self::SquareBrackets, - 'curly' => self::CurlyBraces, - 'round' => self::Parentheses, - 'angle' => self::AngleBrackets, - 'double' => self::DoubleQuotes, - 'single' => self::SingleQuotes, - 'asterisks' => self::Asterisks, - default => self::None, - }; - } -} diff --git a/src/Events/BreakerClosed.php b/src/Events/BreakerClosed.php new file mode 100644 index 0000000..e991179 --- /dev/null +++ b/src/Events/BreakerClosed.php @@ -0,0 +1,12 @@ + outcomes(string|\BackedEnum|null $point = null) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertRan(string|\BackedEnum $point, ?\Closure $callback = null) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertNotRan(string|\BackedEnum $point, ?\Closure $callback = null) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertRanOnce(string|\BackedEnum $point) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertRanTimes(string|\BackedEnum $point, int $times) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertSucceeded(string|\BackedEnum $point, ?\Closure $callback = null) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertRecovered(string|\BackedEnum $point, \Closure|string|null $from = null) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertNotRecovered(string|\BackedEnum $point, \Closure|string|null $from = null) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertEscalated(string|\BackedEnum $point, \Closure|string|null $with = null) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertNotEscalated(string|\BackedEnum $point, \Closure|string|null $with = null) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertRefused(string|\BackedEnum $point) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertNotRefused(string|\BackedEnum $point) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertRetried(string|\BackedEnum $point, ?int $times = null) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertNotRetried(string|\BackedEnum $point, ?int $times = null) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertLimitBreached(string|\BackedEnum $point, string $limit) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertNothingEscalated() + * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertNothingRan() + * + * @see \Kirschbaum\Monitor\Monitor */ class Monitor extends Facade { @@ -20,4 +43,34 @@ protected static function getFacadeAccessor(): string { return \Kirschbaum\Monitor\Monitor::class; } + + /** + * Replace the runner with one that records every outcome. + * + * Control points still execute for real unless a canned value or failure + * is registered for them with returning() or failing(). Assert afterwards + * with assertRan() and friends. Calling this twice returns the same fake. + */ + public static function fake(): MonitorFake + { + $current = static::isFake() ? static::getFacadeRoot() : null; + + if ($current instanceof MonitorFake) { + return $current; + } + + $app = static::getFacadeApplication(); + + if ($app === null) { + throw new RuntimeException('A facade application has not been set.'); + } + + $fake = new MonitorFake($app, $app->make(Runner::class)); + + $app->instance(Runner::class, $fake); + + static::swap($fake); + + return $fake; + } } diff --git a/src/Formatters/StructuredJsonFormatter.php b/src/Formatters/StructuredJsonFormatter.php deleted file mode 100644 index 0bf8841..0000000 --- a/src/Formatters/StructuredJsonFormatter.php +++ /dev/null @@ -1,28 +0,0 @@ - $record->datetime->format('c'), - 'level' => $record->level->getName(), - 'message' => $record->message, - 'trace_id' => $record->context['trace_id'] ?? null, - 'context' => $record->context, - 'channel' => $record->channel, - 'event' => $record->extra['event'] ?? null, - 'duration_ms' => $record->context['duration_ms'] ?? null, - 'memory_mb' => $record->context['memory_mb'] ?? null, - ]; - - return json_encode($structured)."\n"; // newline-delimited for NDJSON - } -} diff --git a/src/Http/HttpBreaker.php b/src/Http/HttpBreaker.php new file mode 100644 index 0000000..e2cb6d2 --- /dev/null +++ b/src/Http/HttpBreaker.php @@ -0,0 +1,91 @@ +breaker('stripe'). + * + * The request is refused with BreakerOpen while the circuit is open, a + * connection failure or a 5xx response counts as a failure, and anything else + * counts as a success. It is the same circuit a control point's breaker() + * uses, so an outbound call and the point that wraps another call to the same + * dependency stop together. + */ +class HttpBreaker +{ + /** + * @param Closure(): CircuitBreaker $breaker resolves the breaker lazily, so nothing is built at boot + */ + public static function register(Closure $breaker): void + { + $middleware = static fn (string $name, ?int $after, ?int $within, ?int $for): Closure => static::middleware($breaker, $name, BreakerConfig::defaults()->with($after, $within, $for)); + + PendingRequest::macro('breaker', function (string $name, ?int $after = null, ?int $within = null, ?int $for = null) use ($middleware): PendingRequest { + /** @var PendingRequest $this */ + return $this->withMiddleware($middleware($name, $after, $within, $for)); + }); + + Factory::macro('breaker', function (string $name, ?int $after = null, ?int $within = null, ?int $for = null) use ($middleware): PendingRequest { + /** @var Factory $this */ + return $this->withMiddleware($middleware($name, $after, $within, $for)); + }); + } + + /** + * A Guzzle middleware that consults the circuit before sending and records the result. + * + * @param Closure(): CircuitBreaker $breaker + */ + public static function middleware(Closure $breaker, string $name, BreakerConfig $config): Closure + { + return static fn (callable $handler): Closure => static function (RequestInterface $request, array $options) use ($handler, $breaker, $name, $config): PromiseInterface { + $decision = $breaker()->permit($name, $config); + + if (! $decision->allowed) { + throw new BreakerOpen($name, $decision->retryAfterSeconds); + } + + try { + /** @var PromiseInterface $promise */ + $promise = $handler($request, $options); + } catch (Throwable $e) { + // A handler that fails before producing a promise, as a faked + // connection failure does, is a failure of the dependency too. + $breaker()->recordFailure($name, $config); + + throw $e; + } + + return $promise->then( + static function (ResponseInterface $response) use ($breaker, $name, $config): ResponseInterface { + if ($response->getStatusCode() >= 500) { + $breaker()->recordFailure($name, $config); + } else { + $breaker()->recordSuccess($name); + } + + return $response; + }, + static function (mixed $reason) use ($breaker, $name, $config): PromiseInterface { + $breaker()->recordFailure($name, $config); + + return Create::rejectionFor($reason instanceof Throwable ? $reason : (string) json_encode($reason)); + }, + ); + }; + } +} diff --git a/src/Http/Middleware/CheckBreakers.php b/src/Http/Middleware/CheckBreakers.php new file mode 100644 index 0000000..26f4aae --- /dev/null +++ b/src/Http/Middleware/CheckBreakers.php @@ -0,0 +1,49 @@ +middleware('breakers:stripe,efiling'); + * + * Retry-After is the real time left on the circuit. Checking does not consume + * the circuit's probe; the first control point through does that. + */ +class CheckBreakers +{ + public function __construct(private readonly CircuitBreaker $breaker) {} + + public function handle(Request $request, Closure $next, string ...$breakers): SymfonyResponse + { + foreach ($breakers as $name) { + if ($this->breaker->isOpen($name)) { + return $this->refuse($name); + } + } + + /** @var SymfonyResponse $response */ + $response = $next($request); + + return $response; + } + + private function refuse(string $name): Response + { + $retryAfter = max(1, $this->breaker->retryAfter($name)); + + return new Response('', 503, [ + 'Retry-After' => (string) $retryAfter, + 'X-Circuit-Breaker' => $name, + 'X-Circuit-Breaker-Status' => 'open', + ]); + } +} diff --git a/src/Http/Middleware/CheckCircuitBreakers.php b/src/Http/Middleware/CheckCircuitBreakers.php deleted file mode 100644 index 4510705..0000000 --- a/src/Http/Middleware/CheckCircuitBreakers.php +++ /dev/null @@ -1,54 +0,0 @@ -isOpen($breakerName)) { - return $this->deny($breakerName); - } - } - - /** @var Response $response */ - $response = $next($request); - - return $response; - } - - protected function deny(string $breaker): Response - { - $retryAfter = $this->retryAfter($breaker); - - return response('', 503) - ->header('Retry-After', (string) $retryAfter) - ->header('X-Circuit-Breaker', $breaker) - ->header('X-Circuit-Breaker-Status', 'open'); - } - - protected function retryAfter(string $breaker): int - { - $state = Monitor::breaker()->getState($breaker); - - if (! $state?->lastFailureAt) { - return 300; - } - - $elapsed = time() - $state->lastFailureAt; - - // Use the decay from the breaker state if available, otherwise fall back to config - $decay = $state->decaySeconds ?? Config::integer('monitor.circuit_breaker.default_decay_seconds', 300); - - return random_int(0, max(0, $decay - $elapsed)); - } -} diff --git a/src/Http/Middleware/StartMonitorTrace.php b/src/Http/Middleware/StartMonitorTrace.php deleted file mode 100644 index 0077732..0000000 --- a/src/Http/Middleware/StartMonitorTrace.php +++ /dev/null @@ -1,34 +0,0 @@ -hasNotStarted()) { - $headerValue = $request->header($header); - $traceId = is_string($headerValue) ? $headerValue : Str::uuid()->toString(); - Monitor::trace()->override($traceId); - } - - /** @var Response $response */ - $response = $next($request); - - $response->headers->set($header, Monitor::trace()->id()); - - return $response; - } -} diff --git a/src/Http/Middleware/StartTrace.php b/src/Http/Middleware/StartTrace.php new file mode 100644 index 0000000..ed9bb66 --- /dev/null +++ b/src/Http/Middleware/StartTrace.php @@ -0,0 +1,75 @@ +trace->hasStarted()) { + $this->trace->pickup($this->incoming($request, $header, $legacy)); + } + + /** @var Response $response */ + $response = $next($request); + + $response->headers->set($header, TraceParent::format($this->trace->id())); + $response->headers->set($legacy, $this->trace->id()); + + return $response; + } + + private function incoming(Request $request, string $header, string $legacy): ?string + { + $traceparent = $request->header($header); + + if (is_string($traceparent) && $traceparent !== '') { + $id = TraceParent::parse($traceparent); + + if ($id !== null) { + return $id; + } + + Log::debug('[Monitor] replaced an invalid incoming traceparent', ['header' => $header]); + + return null; + } + + $legacyValue = $request->header($legacy); + + if (is_string($legacyValue) && $legacyValue !== '') { + $id = Trace::normalise($legacyValue); + + if ($id !== null) { + return $id; + } + + Log::debug('[Monitor] replaced an invalid incoming trace id', ['header' => $legacy]); + } + + return null; + } +} diff --git a/src/Inventory/AstScanner.php b/src/Inventory/AstScanner.php new file mode 100644 index 0000000..30d1077 --- /dev/null +++ b/src/Inventory/AstScanner.php @@ -0,0 +1,102 @@ +createForNewestSupportedVersion()->parse($code) ?? []; + } catch (Error) { + return new ScannedFile($path, [], []); + } + + $visitor = new class extends NodeVisitorAbstract + { + /** @var list */ + public array $classes = []; + + /** @var list */ + public array $points = []; + + /** @var list */ + private array $classStack = []; + + public function enterNode(Node $node): null + { + if ($node instanceof Node\Stmt\ClassLike) { + $name = isset($node->namespacedName) ? $node->namespacedName->toString() : null; + + if ($name !== null && ($node instanceof Node\Stmt\Class_ || $node instanceof Node\Stmt\Enum_ || $node instanceof Node\Stmt\Trait_ || $node instanceof Node\Stmt\Interface_)) { + $this->classes[] = $name; + } + + $this->classStack[] = $name; + } + + if ($node instanceof Node\Expr\StaticCall && $node->name instanceof Node\Identifier && $node->name->toString() === 'control' && $node->class instanceof Node\Name) { + $class = $node->class->toString(); + + if (in_array($class, [MonitorFacade::class, Monitor::class], true)) { + $this->points[] = ['name' => $this->literal($node->args[0] ?? null), 'line' => $node->getStartLine(), 'class' => $this->currentClass()]; + } + } + + if ($node instanceof Node\Expr\New_ && $node->class instanceof Node\Name && $node->class->toString() === Control::class) { + $this->points[] = ['name' => $this->literal($node->args[0] ?? null), 'line' => $node->getStartLine(), 'class' => $this->currentClass()]; + } + + return null; + } + + public function leaveNode(Node $node): null + { + if ($node instanceof Node\Stmt\ClassLike) { + array_pop($this->classStack); + } + + return null; + } + + private function currentClass(): ?string + { + return $this->classStack === [] ? null : $this->classStack[array_key_last($this->classStack)]; + } + + private function literal(?Node $arg): ?string + { + return $arg instanceof Node\Arg && $arg->value instanceof Node\Scalar\String_ ? $arg->value->value : null; + } + }; + + $traverser = new NodeTraverser; + $traverser->addVisitor(new NameResolver); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + return new ScannedFile($path, $visitor->classes, $visitor->points); + } +} diff --git a/src/Inventory/Discovery.php b/src/Inventory/Discovery.php new file mode 100644 index 0000000..58f121d --- /dev/null +++ b/src/Inventory/Discovery.php @@ -0,0 +1,153 @@ +|null $paths absolute or relative to base_path(); defaults to config + * @param list|null $rules defaults to the configured rules + */ + public function build(?array $paths = null, ?array $rules = null): Inventory + { + $files = $this->scan($paths ?? $this->configuredPaths()); + $points = []; + + foreach ($files as $file) { + foreach ($file->classes as $class) { + if (! is_a($class, ControlPoint::class, true) || (new \ReflectionClass($class))->isAbstract()) { + continue; + } + + $points[] = $this->describeClass($class, $file->path); + } + + foreach ($file->inlinePoints as $inline) { + $points[] = $this->describeInline($inline, $file->path); + } + } + + $inventory = new Inventory($points, $files); + + foreach ($rules ?? Rules\Rules::configured() as $rule) { + foreach ($rule->check($inventory) as $finding) { + $inventory->addFinding($finding); + } + } + + return $inventory; + } + + /** + * @param list $paths + * @return list + */ + public function scan(array $paths): array + { + $directories = []; + + foreach ($paths as $path) { + $absolute = str_starts_with($path, '/') ? $path : base_path($path); + + if (is_dir($absolute)) { + $directories[] = $absolute; + } + } + + if ($directories === []) { + return []; + } + + $files = []; + + foreach (Finder::create()->files()->name('*.php')->in($directories)->sortByName() as $file) { + $files[] = $this->scanner->scan($file->getRealPath()); + } + + return $files; + } + + /** + * @return list + */ + public function configuredPaths(): array + { + $paths = Config::array('monitor.discovery.paths', ['app']); + + return array_values(array_filter($paths, is_string(...))); + } + + /** + * @param class-string $class + */ + private function describeClass(string $class, string $file): PointDescription + { + try { + $described = $class::describe(); + } catch (Throwable $e) { + return new PointDescription( + name: $class, + form: 'class', + origin: $class, + domain: Domain::resolve($class), + file: $file, + line: null, + notes: ['could not be described: '.$e->getMessage()], + dynamicName: true, + ); + } + + return new PointDescription( + name: is_string($described['point']) ? $described['point'] : $class, + form: 'class', + origin: $class, + domain: is_string($described['domain']) ? $described['domain'] : Domain::resolve($class), + file: $file, + line: (new \ReflectionClass($class))->getStartLine() ?: null, + profile: is_string($described['profile']) ? $described['profile'] : null, + policies: Lists::ofArrays($described['policies'] ?? null), + limits: Lists::ofArrays($described['limits'] ?? null), + risks: is_array($described['risks']) ? array_values(array_filter($described['risks'], is_string(...))) : [], + catchAll: (bool) ($described['catch_all'] ?? false), + escalation: is_string($described['escalation']) ? $described['escalation'] : null, + notes: is_array($described['notes']) ? array_values(array_filter($described['notes'], is_string(...))) : [], + unreadable: (bool) ($described['unreadable'] ?? false), + ); + } + + /** + * @param array{name: string|null, line: int, class: string|null} $inline + */ + private function describeInline(array $inline, string $file): PointDescription + { + $origin = $inline['class'] ?? Control::class; + + return new PointDescription( + name: $inline['name'] ?? '(dynamic)', + form: 'inline', + origin: $origin, + domain: Domain::resolve($origin), + file: $file, + line: $inline['line'], + notes: ['inline points are inventoried by name; their closures are not inspected'], + dynamicName: $inline['name'] === null, + ); + } +} diff --git a/src/Inventory/Explainer.php b/src/Inventory/Explainer.php new file mode 100644 index 0000000..27f08e3 --- /dev/null +++ b/src/Inventory/Explainer.php @@ -0,0 +1,97 @@ +isClassForm() + ? sprintf('%s is a control point class, %s, in the %s domain%s.', $point->name, $point->origin, $point->domain, $point->profile !== null ? " with the \"{$point->profile}\" profile" : '') + : sprintf('%s is declared inline in %s (%s domain)%s. Only its name is known statically.', $point->name, $point->origin, $point->domain, $point->line !== null ? " at line {$point->line}" : ''); + + if ($point->isClassForm()) { + $lines[] = $point->policies === [] ? 'It runs with no policies.' : 'Policies: '.implode('; ', array_map($this->policy(...), $point->policies)).'.'; + $lines[] = $point->limits === [] ? 'It has no limits.' : 'Limits: '.implode('; ', array_map($this->limit(...), $point->limits)).'.'; + $lines[] = $point->risks === [] ? 'It declares no risks: any failure escalates.' : 'It recovers from '.implode(', ', $point->risks).($point->catchAll ? ' (a catch-all)' : '').'.'; + $lines[] = match (true) { + $point->escalation === null => 'Nothing is told when it escalates.', + $point->escalation === 'closure' => 'A closure is called when it escalates.', + default => sprintf('%s is called when it escalates.', $point->escalation), + }; + } + + foreach ($point->notes as $note) { + $lines[] = 'Note: '.$note; + } + + if ($store instanceof OutcomeStore) { + $lines[] = $this->history($point, $store); + } + + return implode("\n", $lines); + } + + private function history(PointDescription $point, OutcomeStore $store): string + { + try { + $tally = $store->tally($point->name, Date::now()->subDay()); + } catch (Throwable) { + return 'History: the store could not be read.'; + } + + if ($tally === []) { + return 'History: no runs in the last 24 hours.'; + } + + $parts = []; + + foreach ($tally as $status => $count) { + $parts[] = "{$count} {$status}"; + } + + return 'Last 24 hours: '.implode(', ', $parts).'.'; + } + + /** + * @param array $policy + */ + private function policy(array $policy): string + { + return match ($policy['type'] ?? null) { + 'retry' => sprintf('retry %s time(s) with %sms backoff', $this->str($policy['times'] ?? 0), $this->str($policy['backoff_ms'] ?? 0)), + 'transaction' => sprintf('a database transaction retried %s time(s) on deadlock', $this->str($policy['retries'] ?? 0)), + 'breaker' => sprintf('the "%s" breaker opens after %s failures within %ss for %ss', $this->str($policy['name'] ?? ''), $this->str($policy['after'] ?? ''), $this->str($policy['within'] ?? ''), $this->str($policy['for'] ?? '')), + default => is_string($policy['type'] ?? null) ? $policy['type'] : 'a custom policy', + }; + } + + /** + * @param array $limit + */ + private function limit(array $limit): string + { + return match ($limit['type'] ?? null) { + 'within' => sprintf('should finish within %ss', $this->str($limit['seconds'] ?? '')), + 'attempts' => sprintf('at most %s attempt(s)', $this->str($limit['max'] ?? '')), + 'ensure' => sprintf('the result must satisfy "%s"', $this->str($limit['reason'] ?? '')), + default => 'a custom limit', + }; + } + + private function str(mixed $value): string + { + return is_scalar($value) ? (string) $value : ''; + } +} diff --git a/src/Inventory/Finding.php b/src/Inventory/Finding.php new file mode 100644 index 0000000..5fbec5b --- /dev/null +++ b/src/Inventory/Finding.php @@ -0,0 +1,55 @@ + + */ +final readonly class Finding implements Arrayable, JsonSerializable +{ + public const string ERROR = 'error'; + + public const string WARNING = 'warning'; + + public function __construct( + public string $rule, + public string $level, + public string $message, + public ?string $point = null, + public ?string $file = null, + public ?int $line = null, + ) {} + + public function isError(): bool + { + return $this->level === self::ERROR; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'rule' => $this->rule, + 'level' => $this->level, + 'message' => $this->message, + 'point' => $this->point, + 'file' => $this->file, + 'line' => $this->line, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Inventory/Inventory.php b/src/Inventory/Inventory.php new file mode 100644 index 0000000..bc3270f --- /dev/null +++ b/src/Inventory/Inventory.php @@ -0,0 +1,87 @@ + $points + * @param list $files + * @param list $findings + */ + public function __construct( + public readonly array $points, + public readonly array $files, + private array $findings = [], + ) {} + + /** + * @return list + */ + public function findings(): array + { + return $this->findings; + } + + public function addFinding(Finding $finding): void + { + $this->findings[] = $finding; + } + + public function hasErrors(): bool + { + return array_any($this->findings, fn (Finding $f): bool => $f->isError()); + } + + /** + * @return list + */ + public function classPoints(): array + { + return array_values(array_filter($this->points, fn (PointDescription $p): bool => $p->isClassForm())); + } + + public function find(string $name): ?PointDescription + { + foreach ($this->points as $point) { + if ($point->name === $name) { + return $point; + } + } + + return null; + } + + /** + * Fully qualified class names of every class-form point. + * + * @return list + */ + public function controlledClasses(): array + { + $classes = []; + + foreach ($this->points as $point) { + $classes[] = $point->origin; + } + + return array_values(array_unique($classes)); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'points' => array_map(fn (PointDescription $p): array => $p->toArray(), $this->points), + 'findings' => array_map(fn (Finding $f): array => $f->toArray(), $this->findings), + ]; + } +} diff --git a/src/Inventory/PointDescription.php b/src/Inventory/PointDescription.php new file mode 100644 index 0000000..a675192 --- /dev/null +++ b/src/Inventory/PointDescription.php @@ -0,0 +1,83 @@ + + */ +final readonly class PointDescription implements Arrayable, JsonSerializable +{ + /** + * @param list> $policies + * @param list> $limits + * @param list $risks + * @param list $notes + */ + public function __construct( + public string $name, + public string $form, + public string $origin, + public string $domain, + public ?string $file, + public ?int $line, + public ?string $profile = null, + public array $policies = [], + public array $limits = [], + public array $risks = [], + public bool $catchAll = false, + public ?string $escalation = null, + public array $notes = [], + public bool $dynamicName = false, + public bool $unreadable = false, + ) {} + + public function isClassForm(): bool + { + return $this->form === 'class'; + } + + public function hasEscalation(): bool + { + return $this->escalation !== null; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'point' => $this->name, + 'form' => $this->form, + 'origin' => $this->origin, + 'domain' => $this->domain, + 'file' => $this->file, + 'line' => $this->line, + 'profile' => $this->profile, + 'policies' => $this->policies, + 'limits' => $this->limits, + 'risks' => $this->risks, + 'catch_all' => $this->catchAll, + 'escalation' => $this->escalation, + 'dynamic_name' => $this->dynamicName, + 'unreadable' => $this->unreadable, + 'notes' => $this->notes, + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Inventory/Reports/JsonReport.php b/src/Inventory/Reports/JsonReport.php new file mode 100644 index 0000000..0a752e5 --- /dev/null +++ b/src/Inventory/Reports/JsonReport.php @@ -0,0 +1,15 @@ +toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } +} diff --git a/src/Inventory/Reports/SarifReport.php b/src/Inventory/Reports/SarifReport.php new file mode 100644 index 0000000..3d4d166 --- /dev/null +++ b/src/Inventory/Reports/SarifReport.php @@ -0,0 +1,60 @@ + $name, 'name' => $name, 'shortDescription' => ['text' => str_replace('_', ' ', $name)]]; + } + + $results = array_map(fn (Finding $f): array => $this->result($f, $basePath), $inventory->findings()); + + return (string) json_encode([ + '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', + 'version' => '2.1.0', + 'runs' => [[ + 'tool' => ['driver' => ['name' => 'monitor:points', 'informationUri' => 'https://github.com/kirschbaum-development/monitor', 'rules' => $rules]], + 'results' => $results, + ]], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * @return array + */ + private function result(Finding $finding, string $basePath): array + { + $result = [ + 'ruleId' => $finding->rule, + 'level' => $finding->isError() ? 'error' : 'warning', + 'message' => ['text' => $finding->message], + ]; + + if ($finding->file !== null) { + $uri = str_starts_with($finding->file, $basePath) ? ltrim(substr($finding->file, strlen($basePath)), '/') : $finding->file; + $location = ['physicalLocation' => ['artifactLocation' => ['uri' => $uri]]]; + + if ($finding->line !== null) { + $location['physicalLocation']['region'] = ['startLine' => $finding->line]; + } + + $result['locations'] = [$location]; + } + + return $result; + } +} diff --git a/src/Inventory/Reports/TableReport.php b/src/Inventory/Reports/TableReport.php new file mode 100644 index 0000000..9707c69 --- /dev/null +++ b/src/Inventory/Reports/TableReport.php @@ -0,0 +1,67 @@ + $lastSeen + */ + public function render(OutputStyle $output, Inventory $inventory, array $lastSeen = []): void + { + if ($inventory->points === []) { + $output->writeln('No control points found.'); + } else { + $output->table( + ['Point', 'Form', 'Domain', 'Origin', 'Profile', 'Policies', 'Risks', 'Escalation', 'Last seen'], + array_map(fn (PointDescription $p): array => $this->row($p, $lastSeen[$p->name] ?? null), $inventory->points), + ); + } + + foreach ($inventory->findings() as $finding) { + $tag = $finding->isError() ? 'error' : 'comment'; + $where = $finding->file !== null ? ' '.basename($finding->file).($finding->line !== null ? ':'.$finding->line : '').'' : ''; + $output->writeln(sprintf('<%s>%s [%s] %s%s', $tag, strtoupper($finding->level), $tag, $finding->rule, $finding->message, $where)); + } + + $errors = count(array_filter($inventory->findings(), fn (Finding $f): bool => $f->isError())); + $warnings = count($inventory->findings()) - $errors; + + $output->writeln(sprintf('%d control point(s), %d error(s), %d warning(s).', count($inventory->points), $errors, $warnings)); + } + + /** + * @param array{ended_at: string, status: string}|null $seen + * @return list + */ + private function row(PointDescription $point, ?array $seen): array + { + $policies = implode(', ', array_map(fn (array $p): string => is_string($p['type'] ?? null) ? $p['type'] : '?', $point->policies)); + $risks = implode(', ', array_map(fn (string $r): string => substr($r, (int) strrpos('\\'.$r, '\\')), $point->risks)); + + if ($point->catchAll) { + $risks .= ' (catch-all)'; + } + + $escalation = $point->escalation === null ? 'none' : ($point->escalation === 'closure' ? 'closure' : substr($point->escalation, (int) strrpos('\\'.$point->escalation, '\\'))); + + return [ + $point->name, + $point->form, + $point->domain, + substr($point->origin, (int) strrpos('\\'.$point->origin, '\\')), + $point->profile ?? '', + $point->isClassForm() ? $policies : 'not inspected', + $point->isClassForm() ? trim($risks) : 'not inspected', + $point->isClassForm() ? $escalation : 'not inspected', + $seen === null ? '' : $seen['status'].' @ '.$seen['ended_at'], + ]; + } +} diff --git a/src/Inventory/Rules/CatchAllWithoutEscalation.php b/src/Inventory/Rules/CatchAllWithoutEscalation.php new file mode 100644 index 0000000..91837ea --- /dev/null +++ b/src/Inventory/Rules/CatchAllWithoutEscalation.php @@ -0,0 +1,36 @@ +classPoints() as $point) { + if (! $point->catchAll || $point->hasEscalation()) { + continue; + } + + $findings[] = new Finding($this->name(), Finding::WARNING, sprintf('"%s" recovers from Throwable with no escalation; every failure is swallowed', $point->name), $point->name, $point->file, $point->line); + } + + return $findings; + } +} diff --git a/src/Inventory/Rules/CriticalNamespaceUncontrolled.php b/src/Inventory/Rules/CriticalNamespaceUncontrolled.php new file mode 100644 index 0000000..782e953 --- /dev/null +++ b/src/Inventory/Rules/CriticalNamespaceUncontrolled.php @@ -0,0 +1,90 @@ +controlledClasses(); + $findings = []; + + foreach ($inventory->files as $file) { + $inlineClasses = array_values(array_filter(array_map(fn (array $p): ?string => $p['class'], $file->inlinePoints))); + + foreach ($file->classes as $class) { + if (! $this->inNamespaces($class, $namespaces) || in_array($class, $controlled, true) || in_array($class, $inlineClasses, true)) { + continue; + } + + if ($this->exempt($class)) { + continue; + } + + $findings[] = new Finding($this->name(), Finding::ERROR, sprintf('%s sits in a critical namespace but is not a control point and calls none', $class), null, $file->path); + } + } + + return $findings; + } + + /** + * @param list $namespaces + */ + private function inNamespaces(string $class, array $namespaces): bool + { + foreach ($namespaces as $namespace) { + if (str_starts_with($class, rtrim($namespace, '\\').'\\')) { + return true; + } + } + + return false; + } + + /** + * Interfaces, traits, enums, abstract classes, escalations and policies + * are not operations. + */ + private function exempt(string $class): bool + { + if (! class_exists($class)) { + return true; + } + + $reflection = new \ReflectionClass($class); + + return $reflection->isAbstract() + || $reflection->isInterface() + || $reflection->isTrait() + || $reflection->isEnum() + || $reflection->isSubclassOf(ControlPoint::class) + || $reflection->implementsInterface(Escalation::class) + || $reflection->implementsInterface(Policy::class); + } +} diff --git a/src/Inventory/Rules/DuplicateNames.php b/src/Inventory/Rules/DuplicateNames.php new file mode 100644 index 0000000..7750cca --- /dev/null +++ b/src/Inventory/Rules/DuplicateNames.php @@ -0,0 +1,49 @@ +points as $point) { + if ($point->dynamicName) { + continue; + } + + $byName[$point->name][] = $point; + } + + $findings = []; + + foreach ($byName as $name => $points) { + if (count($points) < 2) { + continue; + } + + $where = implode(', ', array_map(fn (PointDescription $p): string => $p->origin.($p->line !== null ? ':'.$p->line : ''), $points)); + + $findings[] = new Finding($this->name(), Finding::ERROR, sprintf('"%s" is declared %d times: %s', $name, count($points), $where), (string) $name, $points[0]->file, $points[0]->line); + } + + return $findings; + } +} diff --git a/src/Inventory/Rules/DynamicName.php b/src/Inventory/Rules/DynamicName.php new file mode 100644 index 0000000..2d66844 --- /dev/null +++ b/src/Inventory/Rules/DynamicName.php @@ -0,0 +1,36 @@ +points as $point) { + if (! $point->dynamicName || $point->isClassForm()) { + continue; + } + + $findings[] = new Finding($this->name(), Finding::WARNING, sprintf('a control point in %s takes its name from an expression; use a string literal', $point->origin), null, $point->file, $point->line); + } + + return $findings; + } +} diff --git a/src/Inventory/Rules/MissingEscalation.php b/src/Inventory/Rules/MissingEscalation.php new file mode 100644 index 0000000..ab8531e --- /dev/null +++ b/src/Inventory/Rules/MissingEscalation.php @@ -0,0 +1,36 @@ +classPoints() as $point) { + if ($point->hasEscalation() || $point->catchAll || $point->unreadable || $point->dynamicName) { + continue; + } + + $findings[] = new Finding($this->name(), Finding::ERROR, sprintf('"%s" declares no escalation and no catch-all; an unexpected failure would leave silently', $point->name), $point->name, $point->file, $point->line); + } + + return $findings; + } +} diff --git a/src/Inventory/Rules/NamePattern.php b/src/Inventory/Rules/NamePattern.php new file mode 100644 index 0000000..e61941a --- /dev/null +++ b/src/Inventory/Rules/NamePattern.php @@ -0,0 +1,33 @@ +points as $point) { + if ($point->dynamicName || PointName::isValid($point->name)) { + continue; + } + + $findings[] = new Finding($this->name(), Finding::ERROR, sprintf('"%s" does not match the configured point name pattern', $point->name), $point->name, $point->file, $point->line); + } + + return $findings; + } +} diff --git a/src/Inventory/Rules/Rules.php b/src/Inventory/Rules/Rules.php new file mode 100644 index 0000000..6071c68 --- /dev/null +++ b/src/Inventory/Rules/Rules.php @@ -0,0 +1,48 @@ +> + */ + public static function all(): array + { + return [ + 'duplicate_names' => DuplicateNames::class, + 'name_pattern' => NamePattern::class, + 'missing_escalation' => MissingEscalation::class, + 'catch_all_without_escalation' => CatchAllWithoutEscalation::class, + 'critical_namespace_uncontrolled' => CriticalNamespaceUncontrolled::class, + 'dynamic_name' => DynamicName::class, + 'unreadable_control' => UnreadableControl::class, + ]; + } + + /** + * The rules config('monitor.inventory.rules') leaves enabled; all by default. + * + * @return list + */ + public static function configured(): array + { + $toggles = Config::array('monitor.inventory.rules', []); + $rules = []; + + foreach (self::all() as $name => $class) { + if (($toggles[$name] ?? true) === false) { + continue; + } + + $rules[] = new $class; + } + + return $rules; + } +} diff --git a/src/Inventory/Rules/UnreadableControl.php b/src/Inventory/Rules/UnreadableControl.php new file mode 100644 index 0000000..725f12e --- /dev/null +++ b/src/Inventory/Rules/UnreadableControl.php @@ -0,0 +1,38 @@ +classPoints() as $point) { + if (! $point->unreadable) { + continue; + } + + foreach ($point->notes as $note) { + $findings[] = new Finding($this->name(), Finding::ERROR, sprintf('"%s": %s', $point->name, $note), $point->name, $point->file, $point->line); + } + } + + return $findings; + } +} diff --git a/src/Inventory/ScannedFile.php b/src/Inventory/ScannedFile.php new file mode 100644 index 0000000..636b9dd --- /dev/null +++ b/src/Inventory/ScannedFile.php @@ -0,0 +1,25 @@ + $classes fully qualified class names declared in the file + * @param list $inlinePoints + */ + public function __construct( + public string $path, + public array $classes, + public array $inlinePoints, + ) {} +} diff --git a/src/Limits/Attempts.php b/src/Limits/Attempts.php new file mode 100644 index 0000000..c0f33a0 --- /dev/null +++ b/src/Limits/Attempts.php @@ -0,0 +1,20 @@ + 'attempts', 'max' => $this->max]; + } +} diff --git a/src/Limits/Ensure.php b/src/Limits/Ensure.php new file mode 100644 index 0000000..da4845f --- /dev/null +++ b/src/Limits/Ensure.php @@ -0,0 +1,31 @@ +check)($value) === true; + } + + public function describe(): array + { + return ['type' => 'ensure', 'reason' => $this->reason]; + } +} diff --git a/src/Limits/Within.php b/src/Limits/Within.php new file mode 100644 index 0000000..17a0fbb --- /dev/null +++ b/src/Limits/Within.php @@ -0,0 +1,33 @@ +seconds * 1000; + } + + public function breachedBy(float $durationMs): bool + { + return $durationMs > $this->thresholdMs(); + } + + public function describe(): array + { + return ['type' => 'within', 'seconds' => $this->seconds]; + } +} diff --git a/src/LiveRunner.php b/src/LiveRunner.php new file mode 100644 index 0000000..a38ffd7 --- /dev/null +++ b/src/LiveRunner.php @@ -0,0 +1,26 @@ +container, $this->events, $this->trace, $this->stack))->execute(); + } +} diff --git a/src/LogTimer.php b/src/LogTimer.php deleted file mode 100644 index 6dbc230..0000000 --- a/src/LogTimer.php +++ /dev/null @@ -1,20 +0,0 @@ -start = microtime(true); - } - - public function elapsed(): int - { - return (int) round((microtime(true) - $this->start) * 1000); // in ms - } -} diff --git a/src/Logging/JsonTap.php b/src/Logging/JsonTap.php new file mode 100644 index 0000000..da41355 --- /dev/null +++ b/src/Logging/JsonTap.php @@ -0,0 +1,31 @@ +getLogger(); + + if (! $monolog instanceof Monolog) { + return; + } + + foreach ($monolog->getHandlers() as $handler) { + if ($handler instanceof FormattableHandlerInterface) { + $handler->setFormatter(new RecordFormatter); + } + } + } +} diff --git a/src/Logging/RecordFormatter.php b/src/Logging/RecordFormatter.php new file mode 100644 index 0000000..fc3c73e --- /dev/null +++ b/src/Logging/RecordFormatter.php @@ -0,0 +1,50 @@ + $normalized */ + $normalized = parent::format($record); + + $line = [ + 'ts' => $normalized['datetime'] ?? null, + 'level' => strtolower($record->level->getName()), + 'channel' => $normalized['channel'] ?? $record->channel, + 'message' => $normalized['message'] ?? $record->message, + ]; + + $extra = is_array($normalized['extra'] ?? null) ? $normalized['extra'] : []; + $context = is_array($normalized['context'] ?? null) ? $normalized['context'] : []; + + foreach ($extra as $key => $value) { + $line[(string) $key] ??= $value; + } + + foreach ($context as $key => $value) { + $line[(string) $key] = $value; + } + + $json = json_encode($line, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR); + + return ($json === false ? '{"message":"[monitor] record could not be encoded"}' : $json)."\n"; + } +} diff --git a/src/Logging/StructuredLogger.php b/src/Logging/StructuredLogger.php new file mode 100644 index 0000000..6150642 --- /dev/null +++ b/src/Logging/StructuredLogger.php @@ -0,0 +1,88 @@ + */ + private array $context = []; + + public function __construct(string|object $origin, private ?string $channel = null) + { + $this->origin = is_object($origin) ? $origin::class : $origin; + $this->domain = Domain::resolve($this->origin); + } + + public function origin(): string + { + return $this->origin; + } + + public function domain(): string + { + return $this->domain; + } + + /** + * Context added to every record from this logger. + * + * @param array $context + */ + public function with(array $context): self + { + $clone = clone $this; + $clone->context = array_merge($this->context, $context); + + return $clone; + } + + public function channel(string $channel): self + { + $clone = clone $this; + $clone->channel = $channel; + + return $clone; + } + + /** + * @param array $context + */ + public function log($level, string|Stringable $message, array $context = []): void + { + $writer = Log::channel($this->channel); + + $writer->log(is_string($level) || $level instanceof Stringable ? (string) $level : 'info', $this->prefix().' '.$message, array_merge( + $this->context, + $context, + ['origin' => $this->origin, 'domain' => $this->domain], + )); + } + + public function prefix(): string + { + $short = substr($this->origin, (int) strrpos('\\'.$this->origin, '\\')); + + return sprintf('[%s:%s]', $this->domain, $short); + } +} diff --git a/src/Mcp/MonitorServer.php b/src/Mcp/MonitorServer.php new file mode 100644 index 0000000..d0ee390 --- /dev/null +++ b/src/Mcp/MonitorServer.php @@ -0,0 +1,65 @@ +|JsonRpcResponse + */ + protected function runMethodHandle(JsonRpcRequest $request, ServerContext $context): iterable|JsonRpcResponse + { + $profile = Config::get('monitor.records.redaction'); + + return (new ResponseRedactor(is_string($profile) && $profile !== '' ? $profile : null)) + ->redact(parent::runMethodHandle($request, $context)); + } +} diff --git a/src/Mcp/Prompts/WrapOperation.php b/src/Mcp/Prompts/WrapOperation.php new file mode 100644 index 0000000..08219c8 --- /dev/null +++ b/src/Mcp/Prompts/WrapOperation.php @@ -0,0 +1,68 @@ +get('path'); + $point = $request->get('point'); + $source = ''; + $path = is_string($path) ? $path : ''; + + if ($path !== '') { + $absolute = str_starts_with($path, '/') ? $path : base_path($path); + $source = is_file($absolute) ? (string) file_get_contents($absolute) : ''; + } + + $ask = is_string($point) && $point !== '' ? "Name it \"{$point}\"." : 'Propose a dotted lowercase name of the form domain.operation.'; + + $body = <<guidelines()} + + ## Source + + ```php + {$source} + ``` + MARKDOWN; + + return Response::text($body); + } + + private function guidelines(): string + { + return Guidelines::content(); + } +} diff --git a/src/Mcp/Resources/Guidelines.php b/src/Mcp/Resources/Guidelines.php new file mode 100644 index 0000000..dfe037a --- /dev/null +++ b/src/Mcp/Resources/Guidelines.php @@ -0,0 +1,29 @@ +|JsonRpcResponse $response + * @return iterable|JsonRpcResponse + */ + public function redact(iterable|JsonRpcResponse $response): iterable|JsonRpcResponse + { + if ($this->profile === null) { + return $response; + } + + if ($response instanceof JsonRpcResponse) { + return $this->one($response); + } + + return $this->each($response); + } + + /** + * @param iterable $responses + * @return Generator + */ + private function each(iterable $responses): Generator + { + foreach ($responses as $response) { + yield $this->one($response); + } + } + + private function one(JsonRpcResponse $response): JsonRpcResponse + { + $content = $response->content; + + if (isset($content['result']) && is_array($content['result'])) { + $content['result'] = $this->result($content['result']); + } + + if (isset($content['params']) && is_array($content['params']) && isset($content['params']['content'])) { + $content['params'] = $this->result($content['params']); + } + + if (isset($content['error']) && is_array($content['error']) && is_string($content['error']['message'] ?? null)) { + $content['error']['message'] = $this->text($content['error']['message']); + } + + $response->content = $content; + + return $response; + } + + /** + * @param array $result + * @return array + */ + private function result(array $result): array + { + if (isset($result['content']) && is_array($result['content'])) { + $result['content'] = array_map($this->item(...), $result['content']); + } + + if (isset($result['structuredContent']) && is_array($result['structuredContent'])) { + $result['structuredContent'] = $this->data($result['structuredContent']); + } + + if (isset($result['messages']) && is_array($result['messages'])) { + $result['messages'] = array_map(function (mixed $message): mixed { + if (is_array($message) && isset($message['content'])) { + $message['content'] = is_array($message['content']) ? $this->item($message['content']) : (is_string($message['content']) ? $this->text($message['content']) : $message['content']); + } + + return $message; + }, $result['messages']); + } + + return $result; + } + + private function item(mixed $item): mixed + { + if (is_array($item) && is_string($item['text'] ?? null)) { + $item['text'] = $this->text($item['text']); + } + + return $item; + } + + public function text(string $text): string + { + if ($this->profile === null) { + return $text; + } + + $decoded = json_decode($text, true); + + if (is_array($decoded) && $decoded !== []) { + return (string) json_encode($this->data($decoded), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + $lines = array_map(function (string $line): string { + $out = Redactor::redactSafely($line, $this->profile); + + return is_string($out) ? $out : (string) json_encode($out); + }, explode("\n", $text)); + + return implode("\n", $lines); + } + + /** + * @param array $data + * @return array + */ + public function data(array $data): array + { + if ($this->profile === null) { + return $data; + } + + try { + $out = Redactor::inspect($data, $this->profile, mark: false)->value; + } catch (Throwable) { + return ['redaction' => 'failed']; + } + + return is_array($out) ? $out : ['redaction' => 'failed']; + } +} diff --git a/src/Mcp/Tools/Escalations.php b/src/Mcp/Tools/Escalations.php new file mode 100644 index 0000000..1abd59f --- /dev/null +++ b/src/Mcp/Tools/Escalations.php @@ -0,0 +1,66 @@ + $schema->string()->description('How far back: 15m, 2h, 7d (default 24h)'), + 'domain' => $schema->string()->description('Only this domain'), + ]; + } + + public function handle(Request $request, OutcomeStore $store): Response + { + if (! $store->enabled()) { + return Response::error('The outcome store is disabled. Set MONITOR_STORE_ENABLED=true and run the migration; records are still in the log.'); + } + + $since = $request->get('since'); + $window = OutcomesCommand::parseSince(is_string($since) && $since !== '' ? $since : '24h'); + + if (! $window instanceof Carbon) { + return Response::error('"since" must look like 15m, 2h or 7d.'); + } + + $domain = $request->get('domain'); + $filters = ['since' => $window, 'domain' => is_string($domain) && $domain !== '' ? $domain : null]; + + $rows = array_merge( + $store->recent($filters + ['status' => 'escalated'], 500), + $store->recent($filters + ['status' => 'refused'], 500), + ); + + $grouped = []; + + foreach ($rows as $row) { + $point = is_string($row['point']) ? $row['point'] : 'unknown'; + $grouped[$point] ??= ['point' => $point, 'domain' => $row['domain'], 'escalated' => 0, 'refused' => 0, 'exceptions' => [], 'last_at' => $row['ended_at'], 'last_trace_id' => $row['trace_id']]; + $grouped[$point][$row['status'] === 'refused' ? 'refused' : 'escalated']++; + + if (is_string($row['exception_class'] ?? null)) { + $grouped[$point]['exceptions'][$row['exception_class']] = ($grouped[$point]['exceptions'][$row['exception_class']] ?? 0) + 1; + } + } + + usort($grouped, fn (array $a, array $b): int => ($b['escalated'] + $b['refused']) <=> ($a['escalated'] + $a['refused'])); + + return Response::json(['since' => $window->toIso8601String(), 'points' => $grouped]); + } +} diff --git a/src/Mcp/Tools/ExplainPoint.php b/src/Mcp/Tools/ExplainPoint.php new file mode 100644 index 0000000..fefa604 --- /dev/null +++ b/src/Mcp/Tools/ExplainPoint.php @@ -0,0 +1,45 @@ + $schema->string()->description('The control point name, e.g. payment.charge')->required(), + ]; + } + + public function handle(Request $request, Discovery $discovery, Explainer $explainer, OutcomeStore $store): Response + { + $name = $request->get('point'); + + if (! is_string($name) || $name === '') { + return Response::error('Pass the control point name as "point".'); + } + + $point = $discovery->build()->find($name); + + if (! $point instanceof PointDescription) { + return Response::error(sprintf('No control point named "%s". Use list_points to see what exists.', $name)); + } + + return Response::text($explainer->explain($point, $store->enabled() ? $store : null)); + } +} diff --git a/src/Mcp/Tools/ListPoints.php b/src/Mcp/Tools/ListPoints.php new file mode 100644 index 0000000..32e3d46 --- /dev/null +++ b/src/Mcp/Tools/ListPoints.php @@ -0,0 +1,50 @@ + $schema->string()->description('Only points in this domain'), + ]; + } + + public function handle(Request $request, Discovery $discovery): Response + { + $inventory = $discovery->build(); + $domain = $request->get('domain'); + $filtered = is_string($domain) && $domain !== ''; + + $points = array_values(array_filter( + $inventory->points, + fn (PointDescription $p): bool => ! $filtered || $p->domain === $domain, + )); + $names = array_map(fn (PointDescription $p): string => $p->name, $points); + + $findings = array_values(array_filter( + $inventory->findings(), + fn (Finding $f): bool => ! $filtered || $f->point === null || in_array($f->point, $names, true), + )); + + return Response::json([ + 'points' => array_map(fn (PointDescription $p): array => $p->toArray(), $points), + 'findings' => array_map(fn (Finding $f): array => $f->toArray(), $findings), + ]); + } +} diff --git a/src/Mcp/Tools/RecentOutcomes.php b/src/Mcp/Tools/RecentOutcomes.php new file mode 100644 index 0000000..defef2a --- /dev/null +++ b/src/Mcp/Tools/RecentOutcomes.php @@ -0,0 +1,63 @@ + $schema->string()->description('Only this control point'), + 'domain' => $schema->string()->description('Only this domain'), + 'status' => $schema->string()->description('succeeded, recovered, escalated or refused'), + 'trace' => $schema->string()->description('Only this trace id'), + 'since' => $schema->string()->description('How far back: 15m, 2h, 7d (default 24h)'), + 'limit' => $schema->integer()->description('Rows to return (default 50, max 500)'), + ]; + } + + public function handle(Request $request, OutcomeStore $store): Response + { + if (! $store->enabled()) { + return Response::error('The outcome store is disabled. Set MONITOR_STORE_ENABLED=true and run the migration; records are still in the log.'); + } + + $since = $request->get('since'); + $window = OutcomesCommand::parseSince(is_string($since) && $since !== '' ? $since : '24h'); + + if (! $window instanceof Carbon) { + return Response::error('"since" must look like 15m, 2h or 7d.'); + } + + $limit = $request->get('limit'); + + $rows = $store->recent([ + 'point' => $this->string($request->get('point')), + 'domain' => $this->string($request->get('domain')), + 'status' => $this->string($request->get('status')), + 'trace' => $this->string($request->get('trace')), + 'since' => $window, + ], min(500, max(1, is_numeric($limit) ? (int) $limit : 50))); + + return Response::json(['since' => $window->toIso8601String(), 'count' => count($rows), 'outcomes' => $rows]); + } + + private function string(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/Monitor.php b/src/Monitor.php index 6d05b8b..9af1db9 100644 --- a/src/Monitor.php +++ b/src/Monitor.php @@ -4,37 +4,47 @@ namespace Kirschbaum\Monitor; -use Kirschbaum\Monitor\Support\LogRedactor; +use BackedEnum; +use Illuminate\Contracts\Container\Container; +use Kirschbaum\Monitor\Breaker\CircuitBreaker; +use Kirschbaum\Monitor\Logging\StructuredLogger; +use Kirschbaum\Monitor\Trace\Trace; class Monitor { - public function trace(): Trace - { - return app(Trace::class); - } + public function __construct(protected Container $container) {} - public function log(string|object $origin = 'Monitor'): StructuredLogger + /** + * Declare a control point. + */ + public function control(string|BackedEnum $name, string|object|null $origin = null): Control { - return StructuredLogger::from($origin); + return new Control($name, $origin); } - public function time(): LogTimer + /** + * The control points currently executing. + */ + public function stack(): ControlStack { - return app(LogTimer::class); + return $this->container->make(ControlStack::class); } - public function breaker(): CircuitBreaker + public function trace(): Trace { - return app(CircuitBreaker::class); + return $this->container->make(Trace::class); } - public function controlled(string $name, string|object|null $origin = null): Controlled + public function breaker(): CircuitBreaker { - return Controlled::for($name, $origin); + return $this->container->make(CircuitBreaker::class); } - public function redactor(): LogRedactor + /** + * A PSR-3 logger bound to an origin, so its records carry origin and domain. + */ + public function log(string|object|null $origin = null): StructuredLogger { - return app(LogRedactor::class); + return new StructuredLogger($origin ?? $this->stack()->currentOrigin() ?? Monitor::class); } } diff --git a/src/MonitorServiceProvider.php b/src/MonitorServiceProvider.php index c273903..758a4bf 100644 --- a/src/MonitorServiceProvider.php +++ b/src/MonitorServiceProvider.php @@ -4,45 +4,184 @@ namespace Kirschbaum\Monitor; +use Composer\InstalledVersions; +use Illuminate\Console\Events\CommandStarting; +use Illuminate\Contracts\Console\Kernel as ConsoleKernel; +use Illuminate\Contracts\Container\Container; +use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Contracts\Http\Kernel as HttpKernel; +use Illuminate\Foundation\Console\AboutCommand; +use Illuminate\Queue\Events\JobProcessing; +use Illuminate\Routing\Router; use Illuminate\Support\ServiceProvider; -use Kirschbaum\Monitor\Support\ControlledContext; +use Kirschbaum\Monitor\Breaker\CircuitBreaker; +use Kirschbaum\Monitor\Console\Commands\ExplainCommand; +use Kirschbaum\Monitor\Console\Commands\MakeControlPointCommand; +use Kirschbaum\Monitor\Console\Commands\OutcomesCommand; +use Kirschbaum\Monitor\Console\Commands\PointsCommand; +use Kirschbaum\Monitor\Console\Commands\PruneCommand; +use Kirschbaum\Monitor\Contracts\Runner; +use Kirschbaum\Monitor\Http\HttpBreaker; +use Kirschbaum\Monitor\Http\Middleware\CheckBreakers; +use Kirschbaum\Monitor\Http\Middleware\StartTrace; +use Kirschbaum\Monitor\Mcp\MonitorServer; +use Kirschbaum\Monitor\Records\Recorder; +use Kirschbaum\Monitor\Store\OutcomeStore; +use Kirschbaum\Monitor\Store\StoreOutcomes; +use Kirschbaum\Monitor\Support\Profiles; +use Kirschbaum\Monitor\Trace\PicksUpCommandTrace; +use Kirschbaum\Monitor\Trace\PicksUpJobTrace; +use Kirschbaum\Monitor\Trace\PropagatesTrace; +use Kirschbaum\Monitor\Trace\Trace; +use Laravel\Mcp\Facades\Mcp; +use Laravel\Mcp\Server; class MonitorServiceProvider extends ServiceProvider { + /** + * Register the package's services. + */ public function register(): void { $this->mergeConfigFrom(__DIR__.'/../config/monitor.php', 'monitor'); - $this->app->scoped(Monitor::class, fn () => new Monitor); - $this->app->scoped(Trace::class, fn () => new Trace); - $this->app->scoped(LogTimer::class, fn () => new LogTimer); - $this->app->scoped(CircuitBreaker::class, fn () => new CircuitBreaker); - $this->app->scoped(ControlledContext::class, fn () => new ControlledContext); - - // Remove automatic logging channel merging - users should configure their own channels + $this->registerServices(); } + /** + * Bootstrap the package. + */ public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->registerPublishing(); + $this->registerCommands(); + $this->registerAbout(); + } + + $this->registerMiddleware(); + $this->registerListeners(); + $this->registerMacros(); + $this->registerMcpServer(); + } + + /** + * Bind the package's services into the container. + */ + protected function registerServices(): void + { + $this->app->singleton(Monitor::class, fn (Container $app): Monitor => new Monitor($app)); + $this->app->singleton(Trace::class); + $this->app->singleton(ControlStack::class); + $this->app->singleton(CircuitBreaker::class); + $this->app->singleton(OutcomeStore::class); + $this->app->singleton(StoreOutcomes::class); + $this->app->bind(Runner::class, LiveRunner::class); + } + + /** + * Register the package's publishable resources. + */ + protected function registerPublishing(): void { $this->publishes([ __DIR__.'/../config/monitor.php' => config_path('monitor.php'), ], 'monitor-config'); - $this->publishes([ - __DIR__.'/../config/logging-monitor.php' => config_path('logging-monitor.php'), - ], 'monitor-logging'); + $this->publishesMigrations([ + __DIR__.'/../database/migrations' => database_path('migrations'), + ], 'monitor-migrations'); + } - // Console auto-trace logic using configurable settings - $consoleAutoTraceEnabled = config('monitor.console_auto_trace.enabled', true); - $enableInTesting = config('monitor.console_auto_trace.enable_in_testing', false); + /** + * Register the package's console commands. + */ + protected function registerCommands(): void + { + $this->commands([ + PointsCommand::class, + ExplainCommand::class, + OutcomesCommand::class, + PruneCommand::class, + MakeControlPointCommand::class, + ]); + } - $shouldAutoTrace = $consoleAutoTraceEnabled - && $this->app->runningInConsole() - && ($enableInTesting || ! $this->app->environment('testing')) - && ! app(Trace::class)->hasStarted(); + /** + * Register the package's section of `php artisan about`. + */ + protected function registerAbout(): void + { + AboutCommand::add('Monitor', fn (): array => [ + 'Version' => InstalledVersions::isInstalled('kirschbaum-development/monitor') + ? InstalledVersions::getPrettyVersion('kirschbaum-development/monitor') + : 'dev', + 'Outcome Store' => AboutCommand::format(config('monitor.records.store.enabled'), console: fn ($value): string => $value ? 'ENABLED' : 'OFF'), + 'MCP Server' => AboutCommand::format(config('monitor.mcp.enabled'), console: fn ($value): string => $value ? 'ENABLED' : 'OFF'), + 'Profiles' => implode(', ', Profiles::names()), + ]); + } - if ($shouldAutoTrace) { - app(Trace::class)->start(); + /** + * Register the route middleware aliases. + */ + protected function registerMiddleware(): void + { + $this->callAfterResolving('router', function (Router $router): void { + $router->aliasMiddleware('monitor.trace', StartTrace::class); + $router->aliasMiddleware('monitor.breakers', CheckBreakers::class); + }); + } + + /** + * Listen for the framework events the package reacts to, and flush the + * outcome store at the end of every request, command and job. + */ + protected function registerListeners(): void + { + $this->callAfterResolving(Dispatcher::class, function (Dispatcher $events): void { + $events->subscribe(Recorder::class); + $events->subscribe(StoreOutcomes::class); + $events->listen(JobProcessing::class, PicksUpJobTrace::class); + $events->listen(CommandStarting::class, PicksUpCommandTrace::class); + }); + + $this->app->booted(function (): void { + $this->callAfterResolving(HttpKernel::class, function (HttpKernel $kernel, Application $app): void { + $kernel->whenRequestLifecycleIsLongerThan(-1, function () use ($app): void { + $app->make(StoreOutcomes::class)->flush(); + }); + }); + + $this->callAfterResolving(ConsoleKernel::class, function (ConsoleKernel $kernel, Application $app): void { + $kernel->whenCommandLifecycleIsLongerThan(-1, function () use ($app): void { + $app->make(StoreOutcomes::class)->flush(); + }); + }); + }); + } + + /** + * Register Http::traced(), Http::breaker() and their PendingRequest forms. + */ + protected function registerMacros(): void + { + PropagatesTrace::register(fn (): Trace => $this->app->make(Trace::class)); + HttpBreaker::register(fn (): CircuitBreaker => $this->app->make(CircuitBreaker::class)); + } + + /** + * Register the MCP server when laravel/mcp is installed and the server is enabled. + */ + protected function registerMcpServer(): void + { + if (! class_exists(Server::class) || ! config('monitor.mcp.enabled', false)) { + return; } + + $handle = config('monitor.mcp.handle', 'monitor'); + + Mcp::local(is_string($handle) && $handle !== '' ? $handle : 'monitor', MonitorServer::class); } } diff --git a/src/Outcome.php b/src/Outcome.php new file mode 100644 index 0000000..6241d50 --- /dev/null +++ b/src/Outcome.php @@ -0,0 +1,140 @@ + + */ +final readonly class Outcome implements Arrayable, JsonSerializable +{ + /** + * @param array $limitsBreached + * @param list> $policies + * @param array $context + * @param list}> $timeline + * @param list $stack + */ + public function __construct( + public string $point, + public string $id, + public ?string $parentId, + public string $traceId, + public string $domain, + public string $origin, + public ?string $profile, + public Status $status, + public mixed $value, + public ?Throwable $exception, + public ?string $recoveredFrom, + public int $attempts, + public float $durationMs, + public array $limitsBreached, + public array $policies, + public array $context, + public array $timeline, + public array $stack, + public CarbonImmutable $startedAt, + public CarbonImmutable $endedAt, + ) {} + + public function succeeded(): bool + { + return $this->status === Status::Succeeded; + } + + public function recovered(): bool + { + return $this->status === Status::Recovered; + } + + public function escalated(): bool + { + return $this->status === Status::Escalated; + } + + public function refused(): bool + { + return $this->status === Status::Refused; + } + + /** + * Whether the run produced a usable value: it succeeded or was recovered. + */ + public function hasValue(): bool + { + return $this->succeeded() || $this->recovered(); + } + + public function breachedLimit(string $name): bool + { + return array_key_exists($name, $this->limitsBreached); + } + + public function info(): RunInfo + { + return new RunInfo( + point: $this->point, + id: $this->id, + parentId: $this->parentId, + traceId: $this->traceId, + domain: $this->domain, + origin: $this->origin, + profile: $this->profile, + stack: $this->stack, + context: $this->context, + ); + } + + /** + * The scalar view of the outcome, without the value and with the exception + * summarised. This is what records, the store and the fake expose. + * + * @return array + */ + public function toArray(): array + { + return [ + 'point' => $this->point, + 'run_id' => $this->id, + 'parent_run_id' => $this->parentId, + 'trace_id' => $this->traceId, + 'domain' => $this->domain, + 'origin' => $this->origin, + 'profile' => $this->profile, + 'status' => $this->status->value, + 'recovered_from' => $this->recoveredFrom, + 'exception' => $this->exception instanceof Throwable ? [ + 'class' => $this->exception::class, + 'message' => $this->exception->getMessage(), + ] : null, + 'attempts' => $this->attempts, + 'duration_ms' => $this->durationMs, + 'limits_breached' => $this->limitsBreached, + 'policies' => $this->policies, + 'context' => $this->context, + 'stack' => $this->stack, + 'started_at' => $this->startedAt->toIso8601String(), + 'ended_at' => $this->endedAt->toIso8601String(), + ]; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/src/Policies/Breaker.php b/src/Policies/Breaker.php new file mode 100644 index 0000000..dbc2fa3 --- /dev/null +++ b/src/Policies/Breaker.php @@ -0,0 +1,130 @@ +name; + } + + /** + * Open after this many failures inside the window (seconds). + */ + public function after(int $failures, ?int $within = null): self + { + $this->after = max(1, $failures); + + if ($within !== null) { + $this->within = max(1, $within); + } + + return $this; + } + + /** + * Stay open for this many seconds before letting a probe through. + */ + public function for(int $seconds): self + { + $this->for = max(1, $seconds); + + return $this; + } + + public function around(Run $run, Closure $next): mixed + { + $circuit = Container::getInstance()->make(CircuitBreaker::class); + $config = $this->config(); + + $decision = $circuit->permit($this->name, $config); + + if (! $decision->allowed) { + $run->note('breaker.refused', ['breaker' => $this->name, 'retry_after_s' => $decision->retryAfterSeconds]); + $refusal = new BreakerOpen($this->name, $decision->retryAfterSeconds); + $run->refused($refusal); + + throw $refusal; + } + + if ($decision->probe) { + $run->note('breaker.probe', ['breaker' => $this->name]); + } + + try { + $value = $next(); + } catch (Throwable $e) { + if ($this->applies($e)) { + $circuit->recordFailure($this->name, $config); + $run->note('breaker.failure', ['breaker' => $this->name, 'state' => $circuit->state($this->name)->state->value]); + } + + throw $e; + } + + $circuit->recordSuccess($this->name); + + return $value; + } + + public function order(): int + { + return Policy::ORDER_BREAKER; + } + + public function describe(): array + { + $config = $this->config(); + + return [ + 'type' => 'breaker', + 'name' => $this->name, + 'after' => $config->after, + 'within' => $config->within, + 'for' => $config->for, + 'on' => $this->only, + 'except' => $this->except, + ]; + } + + public function config(): BreakerConfig + { + return BreakerConfig::defaults()->with($this->after, $this->within, $this->for); + } +} diff --git a/src/Policies/Concerns/FiltersExceptions.php b/src/Policies/Concerns/FiltersExceptions.php new file mode 100644 index 0000000..031b3b7 --- /dev/null +++ b/src/Policies/Concerns/FiltersExceptions.php @@ -0,0 +1,61 @@ +> */ + protected array $only = []; + + /** @var list> */ + protected array $except = []; + + /** + * Only these exception classes are subject to the policy. + * + * @param list> $classes + */ + public function on(array $classes): static + { + $this->only = $classes; + + return $this; + } + + /** + * These exception classes are never subject to the policy. + * + * @param list> $classes + */ + public function except(array $classes): static + { + $this->except = $classes; + + return $this; + } + + protected function applies(Throwable $e): bool + { + foreach ($this->except as $class) { + if ($e instanceof $class) { + return false; + } + } + + if ($this->only === []) { + return true; + } + + foreach ($this->only as $class) { + if ($e instanceof $class) { + return true; + } + } + + return false; + } +} diff --git a/src/Policies/Once.php b/src/Policies/Once.php new file mode 100644 index 0000000..4323625 --- /dev/null +++ b/src/Policies/Once.php @@ -0,0 +1,75 @@ +cacheKey($run->info()->point); + $cache = Cache::store(self::store()); + + if (! $cache->add($cacheKey, $run->info()->id, max(1, $this->ttl))) { + $owner = $cache->get($cacheKey); + $run->note('once.duplicate', ['key' => $this->key]); + + throw new Duplicate($this->key, is_string($owner) ? $owner : null); + } + + try { + return $next(); + } catch (Throwable $e) { + $cache->forget($cacheKey); + + throw $e; + } + } + + public function order(): int + { + return Policy::ORDER_ONCE; + } + + public function describe(): array + { + return ['type' => 'once', 'key' => $this->key, 'ttl' => $this->ttl]; + } + + protected function cacheKey(string $point): string + { + return Config::string('monitor.once.prefix', 'monitor:once:').$point.':'.$this->key; + } + + protected static function store(): ?string + { + $store = Config::get('monitor.once.store'); + + return is_string($store) && $store !== '' ? $store : null; + } +} diff --git a/src/Policies/Retry.php b/src/Policies/Retry.php new file mode 100644 index 0000000..733a774 --- /dev/null +++ b/src/Policies/Retry.php @@ -0,0 +1,114 @@ +times = max(0, $times); + } + + public static function times(int $times): static + { + return new static($times); + } + + public function backoff(int $ms, float $multiplier = 2.0, bool $jitter = true): self + { + $this->backoffMs = max(0, $ms); + $this->multiplier = max(1.0, $multiplier); + $this->jitter = $jitter; + + return $this; + } + + public function around(Run $run, Closure $next): mixed + { + $tries = 0; + + while (true) { + try { + return $next(); + } catch (Throwable $e) { + $tries++; + + if (! $this->applies($e) || ChildEscalations::contains($e) || $tries > $this->times || $run->attempt() >= $run->maxAttempts()) { + throw $e; + } + + $delay = $this->delayFor($tries); + + if ($delay > 0) { + Sleep::for($delay)->milliseconds(); + } + + $run->retried($e, $delay); + } + } + } + + public function order(): int + { + return Policy::ORDER_RETRY; + } + + public function describe(): array + { + return [ + 'type' => 'retry', + 'times' => $this->times, + 'backoff_ms' => $this->backoffMs, + 'multiplier' => $this->multiplier, + 'jitter' => $this->jitter, + 'on' => $this->only, + 'except' => $this->except, + ]; + } + + /** + * The delay after the given number of failed tries, in milliseconds. + */ + private function delayFor(int $tries): int + { + if ($this->backoffMs === 0) { + return 0; + } + + $delay = (int) round($this->backoffMs * ($this->multiplier ** ($tries - 1))); + + if ($this->jitter) { + return random_int((int) ($delay / 2), $delay); + } + + return $delay; + } +} diff --git a/src/Policies/Transaction.php b/src/Policies/Transaction.php new file mode 100644 index 0000000..740feca --- /dev/null +++ b/src/Policies/Transaction.php @@ -0,0 +1,84 @@ +retries = max(0, $retries); + $this->only = [DeadlockException::class]; + } + + public static function retries(int $retries): static + { + return new static($retries); + } + + public function connection(?string $name): self + { + $this->connection = $name; + + return $this; + } + + public function backoff(int $ms): self + { + $this->backoffMs = max(0, $ms); + + return $this; + } + + public function around(Run $run, Closure $next): mixed + { + $retry = Retry::times($this->retries) + ->backoff($this->backoffMs, 1.0, true) + ->on($this->only) + ->except($this->except); + + return $retry->around($run, fn (): mixed => DB::connection($this->connection)->transaction( + fn (): mixed => $next(), + )); + } + + public function order(): int + { + return Policy::ORDER_TRANSACTION; + } + + public function describe(): array + { + return [ + 'type' => 'transaction', + 'retries' => $this->retries, + 'connection' => $this->connection, + 'backoff_ms' => $this->backoffMs, + 'on' => $this->only, + 'except' => $this->except, + ]; + } +} diff --git a/src/Queue/Middleware/WaitForBreaker.php b/src/Queue/Middleware/WaitForBreaker.php new file mode 100644 index 0000000..84b848a --- /dev/null +++ b/src/Queue/Middleware/WaitForBreaker.php @@ -0,0 +1,44 @@ +make(CircuitBreaker::class); + + if ($circuit->isOpen($this->breaker)) { + if (method_exists($job, 'release')) { + $job->release(max($this->minimumDelay, $circuit->retryAfter($this->breaker))); + } + + return null; + } + + return $next($job); + } +} diff --git a/src/Queue/RunControlPoint.php b/src/Queue/RunControlPoint.php new file mode 100644 index 0000000..20dd4cf --- /dev/null +++ b/src/Queue/RunControlPoint.php @@ -0,0 +1,75 @@ +point->execute(); + + if ($outcome->refused() && $this->releaseWhenRefused && $outcome->exception instanceof BreakerOpen) { + $this->release(max(1, $outcome->exception->retryAfterSeconds)); + + return $outcome; + } + + if ($outcome->status->isFailure() && $outcome->exception instanceof Throwable) { + throw $outcome->exception; + } + + return $outcome; + } + + /** + * The point name, so the queue and Horizon show the operation rather than the wrapper. + */ + public function displayName(): string + { + return $this->point::point()->name; + } + + /** + * @return list + */ + public function tags(): array + { + return [ + 'monitor:'.$this->point::point()->name, + 'domain:'.Domain::resolve($this->point::class), + ]; + } +} diff --git a/src/Records/ExceptionSummary.php b/src/Records/ExceptionSummary.php new file mode 100644 index 0000000..c9f6793 --- /dev/null +++ b/src/Records/ExceptionSummary.php @@ -0,0 +1,59 @@ + + */ + public static function from(Throwable $e): array + { + $summary = [ + 'class' => $e::class, + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'code' => $e->getCode(), + ]; + + $previous = $e->getPrevious(); + + if ($previous instanceof Throwable) { + $summary['previous'] = ['class' => $previous::class, 'message' => $previous->getMessage()]; + } + + if (self::includeTrace()) { + $lines = explode("\n", $e->getTraceAsString()); + $max = max(1, Config::integer('monitor.records.exception_trace_lines', 15)); + + $summary['trace'] = array_slice($lines, 0, $max); + + if (count($lines) > $max) { + $summary['trace_truncated'] = count($lines) - $max; + } + } + + return $summary; + } + + private static function includeTrace(): bool + { + return match (Config::string('monitor.records.exception_trace', 'never')) { + 'always' => true, + 'debug' => Config::boolean('app.debug', false), + default => false, + }; + } +} diff --git a/src/Records/Record.php b/src/Records/Record.php new file mode 100644 index 0000000..0c11665 --- /dev/null +++ b/src/Records/Record.php @@ -0,0 +1,193 @@ + + */ + public static function started(RunInfo $run): array + { + return self::base('point.started', $run) + [ + 'attempt' => 1, + 'message' => sprintf('%s %s started', self::prefix($run), $run->point), + ]; + } + + /** + * @return array + */ + public static function retried(PointRetried $event): array + { + return self::base('point.retried', $event->run) + [ + 'attempt' => $event->attempt, + 'backoff_ms' => $event->backoffMs, + 'exception' => ExceptionSummary::from($event->exception), + 'message' => sprintf('%s %s attempt %d failed with %s, retrying after %dms', self::prefix($event->run), $event->run->point, $event->attempt, $event->exception::class, $event->backoffMs), + ]; + } + + /** + * @return array + */ + public static function limit(PointLimitBreached $event): array + { + return self::base('point.limit', $event->run) + [ + 'limit' => ['name' => $event->limit, 'threshold' => $event->threshold, 'actual' => $event->actual], + 'message' => sprintf('%s %s breached the %s limit: %s against %s', self::prefix($event->run), $event->run->point, $event->limit, self::number($event->actual), self::number($event->threshold)), + ]; + } + + /** + * @return array + */ + public static function recovered(Outcome $outcome): array + { + return self::fromOutcome('point.recovered', $outcome) + [ + 'risk' => $outcome->recoveredFrom, + 'message' => sprintf('%s %s recovered from %s in %sms', self::prefix($outcome->info()), $outcome->point, $outcome->recoveredFrom, self::number($outcome->durationMs)), + ]; + } + + /** + * @return array + */ + public static function escalated(Outcome $outcome): array + { + $class = $outcome->exception instanceof Throwable ? $outcome->exception::class : 'unknown'; + + return self::fromOutcome('point.escalated', $outcome) + [ + 'message' => sprintf('%s %s escalated %s after %d attempt(s) in %sms', self::prefix($outcome->info()), $outcome->point, $class, $outcome->attempts, self::number($outcome->durationMs)), + ]; + } + + /** + * @return array + */ + public static function refused(Outcome $outcome): array + { + $breaker = $outcome->exception instanceof BreakerOpen ? $outcome->exception : null; + + return self::fromOutcome('point.refused', $outcome) + [ + 'breaker' => $breaker instanceof BreakerOpen ? ['name' => $breaker->breaker, 'state' => 'open', 'retry_after_s' => $breaker->retryAfterSeconds] : null, + 'message' => sprintf('%s %s refused: breaker %s is open', self::prefix($outcome->info()), $outcome->point, $breaker instanceof BreakerOpen ? $breaker->breaker : 'unknown'), + ]; + } + + /** + * @return array + */ + public static function ended(Outcome $outcome): array + { + return self::fromOutcome('point.ended', $outcome) + [ + 'message' => sprintf('%s %s %s in %sms', self::prefix($outcome->info()), $outcome->point, $outcome->status->value, self::number($outcome->durationMs)), + ]; + } + + /** + * @return array + */ + public static function escalationFailed(EscalationFailed $event): array + { + return self::fromOutcome('escalation.failed', $event->outcome) + [ + 'escalation_exception' => ExceptionSummary::from($event->exception), + 'message' => sprintf('%s %s escalation handler threw %s; the original failure still propagates', self::prefix($event->outcome->info()), $event->outcome->point, $event->exception::class), + ]; + } + + /** + * @return array + */ + public static function escalationThrottled(EscalationThrottled $event): array + { + return self::fromOutcome('escalation.throttled', $event->outcome) + [ + 'throttle_seconds' => $event->seconds, + 'message' => sprintf('%s %s escalation skipped: one already fired inside the last %ds', self::prefix($event->outcome->info()), $event->outcome->point, $event->seconds), + ]; + } + + /** + * @return array + */ + public static function breaker(string $event, string $name, BreakerState $state): array + { + return [ + 'schema' => self::SCHEMA, + 'event' => $event, + 'breaker' => ['name' => $name, 'state' => $state->state->value, 'failures' => $state->failureCount(), 'open_for' => $state->openFor], + 'message' => sprintf('[Monitor] breaker %s is now %s', $name, $state->state->value), + ]; + } + + /** + * @return array + */ + private static function fromOutcome(string $event, Outcome $outcome): array + { + $record = self::base($event, $outcome->info()) + [ + 'status' => $outcome->status->value, + 'attempts' => $outcome->attempts, + 'duration_ms' => $outcome->durationMs, + 'limits_breached' => $outcome->limitsBreached, + ]; + + if ($outcome->exception instanceof Throwable) { + $record['exception'] = ExceptionSummary::from($outcome->exception); + } + + return $record; + } + + /** + * @return array + */ + private static function base(string $event, RunInfo $run): array + { + return [ + 'schema' => self::SCHEMA, + 'event' => $event, + 'point' => $run->point, + 'run_id' => $run->id, + 'parent_run_id' => $run->parentId, + 'stack' => $run->stack, + 'trace_id' => $run->traceId, + 'domain' => $run->domain, + 'origin' => $run->origin, + 'profile' => $run->profile, + 'context' => $run->context, + ]; + } + + private static function prefix(RunInfo $run): string + { + $short = substr($run->origin, (int) strrpos('\\'.$run->origin, '\\')); + + return sprintf('[%s:%s]', $run->domain, $short); + } + + private static function number(int|float $value): string + { + return is_int($value) ? (string) $value : rtrim(rtrim(number_format($value, 3, '.', ''), '0'), '.'); + } +} diff --git a/src/Records/Recorder.php b/src/Records/Recorder.php new file mode 100644 index 0000000..beb50b8 --- /dev/null +++ b/src/Records/Recorder.php @@ -0,0 +1,138 @@ + + */ + public function subscribe(): array + { + return [ + PointStarted::class => 'started', + PointRetried::class => 'retried', + PointLimitBreached::class => 'limit', + PointRecovered::class => 'recovered', + PointEscalated::class => 'escalated', + PointRefused::class => 'refused', + PointEnded::class => 'ended', + EscalationFailed::class => 'escalationFailed', + EscalationThrottled::class => 'escalationThrottled', + BreakerOpened::class => 'breakerOpened', + BreakerHalfOpen::class => 'breakerHalfOpen', + BreakerClosed::class => 'breakerClosed', + ]; + } + + public function started(PointStarted $event): void + { + $this->write(Record::started($event->run)); + } + + public function retried(PointRetried $event): void + { + $this->write(Record::retried($event)); + } + + public function limit(PointLimitBreached $event): void + { + $this->write(Record::limit($event)); + } + + public function recovered(PointRecovered $event): void + { + $this->write(Record::recovered($event->outcome)); + } + + public function escalated(PointEscalated $event): void + { + $this->write(Record::escalated($event->outcome)); + } + + public function refused(PointRefused $event): void + { + $this->write(Record::refused($event->outcome)); + } + + public function ended(PointEnded $event): void + { + $this->write(Record::ended($event->outcome)); + } + + public function escalationFailed(EscalationFailed $event): void + { + $this->write(Record::escalationFailed($event)); + } + + public function escalationThrottled(EscalationThrottled $event): void + { + $this->write(Record::escalationThrottled($event)); + } + + public function breakerOpened(BreakerOpened $event): void + { + $this->write(Record::breaker('breaker.opened', $event->breaker, $event->state)); + } + + public function breakerHalfOpen(BreakerHalfOpen $event): void + { + $this->write(Record::breaker('breaker.half_open', $event->breaker, $event->state)); + } + + public function breakerClosed(BreakerClosed $event): void + { + $this->write(Record::breaker('breaker.closed', $event->breaker, $event->state)); + } + + /** + * @param array $record + */ + private function write(array $record): void + { + $event = is_string($record['event'] ?? null) ? $record['event'] : 'point.ended'; + $message = is_string($record['message'] ?? null) ? $record['message'] : $event; + unset($record['message']); + + $this->logger()->log(self::level($event), $message, Redaction::record($record)); + } + + public static function level(string $event): string + { + // Event names contain dots, so the map is read whole rather than by key path. + $levels = Config::array('monitor.records.levels', []); + $level = $levels[$event] ?? null; + + return is_string($level) && $level !== '' ? $level : 'info'; + } + + private function logger(): LoggerInterface + { + $channel = Config::get('monitor.records.channel'); + + return Log::channel(is_string($channel) && $channel !== '' ? $channel : null); + } +} diff --git a/src/Risks/BreakerOpen.php b/src/Risks/BreakerOpen.php new file mode 100644 index 0000000..05345ad --- /dev/null +++ b/src/Risks/BreakerOpen.php @@ -0,0 +1,16 @@ +}> */ + private array $timeline = []; + + /** + * @param Closure(): mixed $callback + */ + public function __construct( + private readonly Control $control, + private readonly Closure $callback, + private readonly Container $container, + private readonly Dispatcher $events, + private readonly Trace $trace, + private readonly ControlStack $stack, + ) {} + + public function execute(): Outcome + { + $this->begin(); + + try { + $value = $this->pipeline()(); + $this->assertEnsures($value); + + $outcome = $this->outcome(Status::Succeeded, $value, null, null); + } catch (Throwable $e) { + $outcome = $this->handle($e); + } finally { + $this->stack->pop(); + } + + $this->dispatchEnd($outcome); + + return $outcome; + } + + /** + * The identity of this run, for policies that need the point or run id. + */ + public function info(): RunInfo + { + return $this->info; + } + + public function attempt(): int + { + return $this->attempt; + } + + /** + * The hard cap on attempts for this run. + */ + public function maxAttempts(): int + { + return $this->control->resolvedAttempts()->max ?? PHP_INT_MAX; + } + + /** + * Called by a retry policy after a failed attempt and its backoff. + */ + public function retried(Throwable $exception, int $backoffMs): void + { + $failed = $this->attempt; + $this->attempt++; + + $this->note('retried', ['attempt' => $failed, 'exception' => $exception::class, 'backoff_ms' => $backoffMs]); + $this->events->dispatch(new PointRetried($this->info, $exception, $failed, $backoffMs)); + } + + /** + * Called by the breaker policy when it refuses the attempt. + */ + public function refused(BreakerOpen $refusal): void + { + $this->refusal = $refusal; + } + + /** + * @param array $detail + */ + public function note(string $event, array $detail = []): void + { + $this->timeline[] = ['at_ms' => $this->elapsedMs(), 'event' => $event, 'detail' => $detail]; + } + + private function begin(): void + { + $this->startedAt = hrtime(true) / 1e6; + $this->startedAtClock = CarbonImmutable::now(); + $parentId = $this->stack->currentRunId(); + $id = (string) Str::ulid(); + + $this->stack->push($this->control->name(), $id, $this->control->origin()); + + $this->info = new RunInfo( + point: $this->control->name(), + id: $id, + parentId: $parentId, + traceId: $this->trace->id(), + domain: $this->control->resolvedDomain(), + origin: $this->control->origin(), + profile: $this->control->profileName(), + stack: $this->stack->names(), + context: $this->control->context(), + ); + + $this->note('started'); + $this->events->dispatch(new PointStarted($this->info)); + } + + /** + * Policies wrap the callback outermost first. + * + * @return Closure(): mixed + */ + private function pipeline(): Closure + { + $next = $this->callback; + + foreach (array_reverse($this->control->resolvedPolicies()) as $policy) { + $next = fn (): mixed => $policy->around($this, $next); + } + + return $next; + } + + private function assertEnsures(mixed $value): void + { + foreach ($this->control->ensures() as $ensure) { + if (! $ensure->holdsFor($value)) { + $this->note('ensure.failed', ['reason' => $ensure->reason]); + + throw new EnsureFailed($ensure->reason, $value); + } + } + } + + private function handle(Throwable $e): Outcome + { + $refused = $this->refusal instanceof BreakerOpen && $e === $this->refusal; + + foreach ($this->control->risks() as $risk) { + if (! $e instanceof $risk['class']) { + continue; + } + + $this->note('recovering', ['risk' => $risk['class']]); + + try { + $handler = $risk['handler']; + + if (is_string($handler)) { + $correction = $this->container->make($handler); + $value = $correction instanceof Correction ? $correction($e, $this->outcome(Status::Escalated, null, $e, null)) : null; + } else { + $value = $handler($e, $this->outcome(Status::Escalated, null, $e, null)); + } + } catch (Throwable $fromHandler) { + $this->note('correction.threw', ['risk' => $risk['class'], 'exception' => $fromHandler::class]); + + return $this->escalate($fromHandler); + } + + return $this->outcome(Status::Recovered, $value, $e, $risk['class']); + } + + if ($refused) { + $this->note('refused'); + + return $this->outcome(Status::Refused, null, $e, null); + } + + return $this->escalate($e); + } + + private function escalate(Throwable $e): Outcome + { + $this->note('escalated', ['exception' => $e::class]); + + if ($this->info->parentId !== null) { + ChildEscalations::record($e, $this->info->point); + } + + return $this->outcome(Status::Escalated, null, $e, null); + } + + private function dispatchEnd(Outcome $outcome): void + { + foreach ($outcome->limitsBreached as $name => $breach) { + $this->events->dispatch(new PointLimitBreached($this->info, $name, $breach['threshold'], $breach['actual'])); + } + + match ($outcome->status) { + Status::Recovered => $this->events->dispatch(new PointRecovered($outcome)), + Status::Escalated => $this->events->dispatch(new PointEscalated($outcome)), + Status::Refused => $this->events->dispatch(new PointRefused($outcome)), + Status::Succeeded => null, + }; + + if ($outcome->escalated() || ($this->control->escalatesLimits() && $outcome->limitsBreached !== [])) { + $this->notifyEscalation($outcome); + } + + $this->events->dispatch(new PointEnded($outcome)); + } + + private function notifyEscalation(Outcome $outcome): void + { + $escalation = $this->control->escalation(); + + if ($escalation === null) { + return; + } + + $throttle = $this->control->escalationThrottle(); + + if ($throttle !== null && ! Cache::add('monitor:escalation:'.$this->info->point, $this->info->id, $throttle)) { + $this->note('escalation.throttled', ['seconds' => $throttle]); + $this->events->dispatch(new EscalationThrottled($outcome, $throttle)); + + return; + } + + try { + if ($escalation instanceof Closure) { + $escalation($outcome); + } else { + $handler = $this->container->make($escalation); + + if ($handler instanceof Escalation) { + $handler->handle($outcome); + } + } + } catch (Throwable $fromEscalation) { + // Recorded on the outcome's timeline through the ended event's + // listeners is too late; note it where the recorder can see it. + $this->events->dispatch(new EscalationFailed($outcome, $fromEscalation)); + } + } + + private function outcome(Status $status, mixed $value, ?Throwable $exception, ?string $recoveredFrom): Outcome + { + $durationMs = $this->elapsedMs(); + $limits = []; + + $within = $this->control->resolvedWithin(); + + if ($within instanceof Within && $within->breachedBy($durationMs)) { + $limits['duration'] = ['threshold' => $within->thresholdMs(), 'actual' => round($durationMs, 3)]; + } + + $attemptsLimit = $this->control->resolvedAttempts(); + + if ($attemptsLimit instanceof Attempts && $this->attempt >= $attemptsLimit->max && $status->isFailure()) { + $limits['attempts'] = ['threshold' => $attemptsLimit->max, 'actual' => $this->attempt]; + } + + return new Outcome( + point: $this->info->point, + id: $this->info->id, + parentId: $this->info->parentId, + traceId: $this->info->traceId, + domain: $this->info->domain, + origin: $this->info->origin, + profile: $this->info->profile, + status: $status, + value: $value, + exception: $exception, + recoveredFrom: $recoveredFrom, + attempts: $this->attempt, + durationMs: round($durationMs, 3), + limitsBreached: $limits, + policies: array_map(fn (Policy $policy): array => $policy->describe(), $this->control->resolvedPolicies()), + context: $this->info->context, + timeline: $this->timeline, + stack: $this->info->stack, + startedAt: $this->startedAtClock, + endedAt: CarbonImmutable::now(), + ); + } + + private function elapsedMs(): float + { + return hrtime(true) / 1e6 - $this->startedAt; + } +} diff --git a/src/RunInfo.php b/src/RunInfo.php new file mode 100644 index 0000000..aa458a8 --- /dev/null +++ b/src/RunInfo.php @@ -0,0 +1,29 @@ + $stack + * @param array $context + */ + public function __construct( + public string $point, + public string $id, + public ?string $parentId, + public string $traceId, + public string $domain, + public string $origin, + public ?string $profile, + public array $stack, + public array $context, + ) {} + +} diff --git a/src/Status.php b/src/Status.php new file mode 100644 index 0000000..a0b75c8 --- /dev/null +++ b/src/Status.php @@ -0,0 +1,28 @@ + $outcomes + */ + public function write(array $outcomes): int + { + if ($outcomes === []) { + return 0; + } + + $rows = array_map($this->row(...), $outcomes); + + $this->query()->upsert($rows, ['run_id'], ['status', 'attempts', 'duration_ms', 'started_at', 'ended_at']); + + return count($rows); + } + + /** + * A query over stored outcomes, newest first. + */ + public function query(): Builder + { + return $this->db->connection($this->connection())->table($this->table()); + } + + /** + * @param array{point?: string|null, domain?: string|null, status?: string|null, since?: \DateTimeInterface|null, trace?: string|null} $filters + * @return list> + */ + public function recent(array $filters = [], int $limit = 50): array + { + $query = $this->query()->latest('ended_at')->orderByDesc('id')->limit(max(1, $limit)); + + if (! empty($filters['point'])) { + $query->where('point', $filters['point']); + } + + if (! empty($filters['domain'])) { + $query->where('domain', $filters['domain']); + } + + if (! empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (! empty($filters['trace'])) { + $query->where('trace_id', $filters['trace']); + } + + if (isset($filters['since'])) { + $query->where('ended_at', '>=', Date::instance($filters['since'])); + } + + return array_values(array_map($this->hydrate(...), $query->get()->all())); + } + + /** + * Counts per status for one point or for all, since a moment. + * + * @return array + */ + public function tally(?string $point = null, ?\DateTimeInterface $since = null): array + { + $query = $this->query()->selectRaw('status, count(*) as total')->groupBy('status'); + + if ($point !== null) { + $query->where('point', $point); + } + + if ($since instanceof \DateTimeInterface) { + $query->where('ended_at', '>=', Date::instance($since)); + } + + $tally = []; + + foreach ($query->get() as $row) { + $status = is_string($row->status) ? $row->status : 'unknown'; + $tally[$status] = is_numeric($row->total) ? (int) $row->total : 0; + } + + return $tally; + } + + /** + * When each point last ran and how it ended. + * + * @return array + */ + public function lastSeen(): array + { + $rows = $this->query()->selectRaw('point, max(ended_at) as last_at')->groupBy('point')->get(); + $seen = []; + + foreach ($rows as $row) { + $point = is_string($row->point) ? $row->point : ''; + $lastAt = is_string($row->last_at) ? $row->last_at : ''; + $last = $this->query()->where('point', $point)->where('ended_at', $lastAt)->orderByDesc('id')->first(); + + $seen[$point] = [ + 'ended_at' => $lastAt, + 'status' => is_object($last) && is_string($last->status) ? $last->status : 'unknown', + ]; + } + + return $seen; + } + + public function prune(?int $days = null): int + { + $cutoff = Date::now()->subDays($days ?? $this->retentionDays()); + + return $this->query()->where('ended_at', '<', $cutoff)->delete(); + } + + /** + * @return array + */ + protected function row(Outcome $outcome): array + { + $exception = $outcome->exception instanceof \Throwable ? Redaction::exception(ExceptionSummary::from($outcome->exception)) : null; + + return [ + 'run_id' => $outcome->id, + 'parent_run_id' => $outcome->parentId, + 'trace_id' => $outcome->traceId, + 'point' => $outcome->point, + 'domain' => $outcome->domain, + 'origin' => $outcome->origin, + 'profile' => $outcome->profile, + 'status' => $outcome->status->value, + 'recovered_from' => $outcome->recoveredFrom, + 'exception_class' => is_array($exception) && is_string($exception['class'] ?? null) ? $exception['class'] : null, + 'exception_message' => is_array($exception) && is_string($exception['message'] ?? null) ? $exception['message'] : null, + 'attempts' => $outcome->attempts, + 'duration_ms' => $outcome->durationMs, + 'stack' => json_encode($outcome->stack), + 'context' => json_encode(Redaction::context($outcome->context)), + 'limits_breached' => json_encode($outcome->limitsBreached), + 'policies' => json_encode($outcome->policies), + 'timeline' => json_encode($outcome->timeline), + 'started_at' => $outcome->startedAt, + 'ended_at' => $outcome->endedAt, + ]; + } + + /** + * @return array + */ + private function hydrate(object $row): array + { + $data = []; + + foreach (get_object_vars($row) as $key => $value) { + $data[(string) $key] = $value; + } + + foreach (['stack', 'context', 'limits_breached', 'policies', 'timeline'] as $json) { + $data[$json] = is_string($data[$json] ?? null) ? json_decode($data[$json], true) : ($data[$json] ?? null); + } + + return $data; + } +} diff --git a/src/Store/StoreOutcomes.php b/src/Store/StoreOutcomes.php new file mode 100644 index 0000000..35b1beb --- /dev/null +++ b/src/Store/StoreOutcomes.php @@ -0,0 +1,101 @@ + */ + protected array $buffer = []; + + protected bool $failureReported = false; + + public function __construct(protected OutcomeStore $store) {} + + /** + * @return array + */ + public function subscribe(Dispatcher $events): array + { + return [ + PointEnded::class => 'buffer', + JobProcessed::class => 'flush', + JobExceptionOccurred::class => 'flush', + Looping::class => 'flush', + WorkerStopping::class => 'flush', + ]; + } + + /** + * Hold an outcome until the next flush. + */ + public function buffer(PointEnded $event): void + { + if (! $this->store->enabled()) { + return; + } + + $this->buffer[] = $event->outcome; + + if (count($this->buffer) >= self::BUFFER_LIMIT) { + $this->flush(); + } + } + + /** + * Write everything held so far and return how many rows were written. + */ + public function flush(): int + { + if ($this->buffer === []) { + return 0; + } + + $outcomes = $this->buffer; + $this->buffer = []; + + try { + return $this->store->write($outcomes); + } catch (Throwable $e) { + if (! $this->failureReported) { + $this->failureReported = true; + Log::warning('[Monitor] the outcome store could not be written; outcomes are still in the log', [ + 'exception' => $e::class, + 'message' => $e->getMessage(), + ]); + } + + return 0; + } + } + + /** + * How many outcomes are waiting to be written. + */ + public function pending(): int + { + return count($this->buffer); + } +} diff --git a/src/StructuredLogger.php b/src/StructuredLogger.php deleted file mode 100644 index a12d537..0000000 --- a/src/StructuredLogger.php +++ /dev/null @@ -1,158 +0,0 @@ -rawOrigin = $instance->resolveOrigin($origin); - - $wrapper = OriginWrapper::fromConfig(Config::string('monitor.origin_path_wrapper', 'square')); - $instance->origin = $wrapper->wrap($instance->rawOrigin); - - return $instance; - } - - private function __construct(string|object|null $origin = null) - { - $this->rawOrigin = $this->resolveOrigin($origin); - $this->origin = $this->rawOrigin; - } - - public function getOrigin(): string - { - return $this->origin; - } - - protected function resolveOrigin(string|object|null $origin): string - { - if (is_object($origin)) { - $origin = get_class($origin); - } - - if (! is_string($origin) || $origin === '') { - return ''; - } - - /** @var array $pathReplacers */ - $pathReplacers = Config::array('monitor.origin_path_replacers', []); - - // Use strtr() for efficient multi-point replacements applied in order - if (! empty($pathReplacers)) { - $origin = strtr($origin, $pathReplacers); - } - - $separator = Config::string('monitor.origin_separator', ':'); - $origin = str_replace('\\', $separator, $origin); - - if ($prefix = Config::get('monitor.prefix')) { - if (! is_string($prefix)) { - throw new InvalidArgumentException('Monitor prefix must be a string'); - } - - $origin = $prefix.$separator.$origin; - } - - return $origin; - } - - /** - * @param array $context - * @return array - */ - protected function enrich(string $level, string $message, array $context = []): array - { - // Apply redaction to the context and message if enabled - if (Config::boolean('monitor.redactor.enabled')) { - $profile = Config::string('monitor.redactor.redactor_profile', 'default'); - $context = Redactor::redact($context, $profile); - /** @var string $message */ - $message = Redactor::redact($message, $profile); - } - - return [ - 'level' => $level, - 'event' => "{$this->rawOrigin}:{$level}", - 'message' => "{$this->origin} {$message}", - 'trace_id' => Monitor::trace()->hasStarted() ? Monitor::trace()->id() : null, - 'context' => $context, - 'timestamp' => now()->toISOString(), - 'duration_ms' => Monitor::time()->elapsed(), - 'memory_mb' => round(memory_get_usage(true) / 1048576, 2), - ]; - } - - protected function prefix(string $message): string - { - return "{$this->origin} {$message}"; - } - - /** @param array $context */ - public function info(string $message, array $context = []): void - { - Log::info($this->prefix($message), $this->enrich('info', $message, $context)); - } - - /** @param array $context */ - public function error(string $message, array $context = []): void - { - Log::error($this->prefix($message), $this->enrich('error', $message, $context)); - } - - /** @param array $context */ - public function warning(string $message, array $context = []): void - { - Log::warning($this->prefix($message), $this->enrich('warning', $message, $context)); - } - - /** @param array $context */ - public function debug(string $message, array $context = []): void - { - Log::debug($this->prefix($message), $this->enrich('debug', $message, $context)); - } - - /** @param array $context */ - public function notice(string $message, array $context = []): void - { - Log::notice($this->prefix($message), $this->enrich('notice', $message, $context)); - } - - /** @param array $context */ - public function critical(string $message, array $context = []): void - { - Log::critical($this->prefix($message), $this->enrich('critical', $message, $context)); - } - - /** @param array $context */ - public function alert(string $message, array $context = []): void - { - Log::alert($this->prefix($message), $this->enrich('alert', $message, $context)); - } - - /** @param array $context */ - public function emergency(string $message, array $context = []): void - { - Log::emergency($this->prefix($message), $this->enrich('emergency', $message, $context)); - } - - /** @param array $context */ - public function log(string $level, string $message, array $context = []): void - { - Log::{$level}($this->prefix($message), $this->enrich($level, $message, $context)); - } -} diff --git a/src/Support/ChildEscalations.php b/src/Support/ChildEscalations.php new file mode 100644 index 0000000..c89cf77 --- /dev/null +++ b/src/Support/ChildEscalations.php @@ -0,0 +1,46 @@ +|null */ + private static ?WeakMap $escalations = null; + + public static function record(Throwable $exception, string $point): void + { + self::map()[$exception] = $point; + } + + public static function contains(Throwable $exception): bool + { + return isset(self::map()[$exception]); + } + + public static function pointFor(Throwable $exception): ?string + { + return self::map()[$exception] ?? null; + } + + /** + * @return WeakMap + */ + private static function map(): WeakMap + { + return self::$escalations ??= new WeakMap; + } +} diff --git a/src/Support/ControlledContext.php b/src/Support/ControlledContext.php deleted file mode 100644 index 2c18e68..0000000 --- a/src/Support/ControlledContext.php +++ /dev/null @@ -1,40 +0,0 @@ -current !== null) { - throw new NestedControlledBlockException( - "Nested Controlled block detected: attempted to start '{$name}' while already in '{$this->current['name']}' (uuid: {$this->current['uuid']})." - ); - } - - $this->current = compact('name', 'uuid'); - } - - public function exit(): void - { - $this->current = null; - } - - public function isInside(): bool - { - return $this->current !== null; - } - - /** @return array{name: string, uuid: string}|null */ - public function current(): ?array - { - return $this->current; - } -} diff --git a/src/Support/Domain.php b/src/Support/Domain.php new file mode 100644 index 0000000..0344762 --- /dev/null +++ b/src/Support/Domain.php @@ -0,0 +1,45 @@ + $map */ + $map = Config::array('monitor.domains.map', []); + + foreach ($map as $prefix => $domain) { + if (! str_starts_with($class, $prefix)) { + continue; + } + + if (is_string($domain) && $domain !== '') { + return $domain; + } + + $rest = substr($class, strlen($prefix)); + $segments = explode('\\', $rest); + + // A class directly under the prefix has no domain segment of its own. + if (count($segments) > 1 && $segments[0] !== '') { + return $segments[0]; + } + } + + return Config::string('monitor.domains.fallback', 'App'); + } +} diff --git a/src/Support/Lists.php b/src/Support/Lists.php new file mode 100644 index 0000000..3c2a606 --- /dev/null +++ b/src/Support/Lists.php @@ -0,0 +1,37 @@ +> + */ + public static function ofArrays(mixed $value): array + { + $out = []; + + foreach (is_array($value) ? $value : [] as $item) { + if (! is_array($item)) { + continue; + } + + $row = []; + + foreach ($item as $key => $itemValue) { + $row[(string) $key] = $itemValue; + } + + $out[] = $row; + } + + return $out; + } +} diff --git a/src/Support/LogRedactor.php b/src/Support/LogRedactor.php deleted file mode 100644 index a7ce56b..0000000 --- a/src/Support/LogRedactor.php +++ /dev/null @@ -1,351 +0,0 @@ - */ - private array $redactedKeys = []; - - /** @var array */ - private array $entropyCache = []; - - /** - * Redact sensitive data from log context. - * - * @param array $context - * @return array - */ - public function redact(array $context): array - { - if (! Config::get('monitor.log_redactor.enabled', true)) { - return $context; - } - - $config = RedactorConfig::fromConfig(); - $this->redactedKeys = []; - $this->entropyCache = []; - - $wasRedacted = false; - $redactedContext = $this->redactRecursively($context, $config, $wasRedacted); - - if (is_array($redactedContext) && $wasRedacted && $config->markRedacted) { - $redactedContext['_redacted'] = true; - - if ($config->trackRedactedKeys && ! empty($this->redactedKeys)) { - $redactedContext['_redacted_keys'] = array_unique($this->redactedKeys); - } - } - - /** @var array $result */ - $result = is_array($redactedContext) ? $redactedContext : $context; - - return $result; - } - - /** - * Recursively redact data from arrays and objects. - */ - protected function redactRecursively( - mixed $data, - RedactorConfig $config, - bool &$wasRedacted - ): mixed { - if (is_array($data)) { - /** @var array $arrayData */ - $arrayData = $data; - - return $this->redactArray($arrayData, $config, $wasRedacted); - } - - if (is_object($data)) { - return $this->redactObject($data, $config, $wasRedacted); - } - - if (is_string($data)) { - return $this->redactString($data, $config, $wasRedacted); - } - - return $data; - } - - /** - * Redact sensitive data from an array. - * - * @param array $array - * @return array - */ - protected function redactArray( - array $array, - RedactorConfig $config, - bool &$wasRedacted - ): array { - // Check if array is too large - if ($config->redactLargeObjects && count($array) > $config->maxObjectSize) { - $wasRedacted = true; - - return ['_large_object_redacted' => sprintf('%s (Array with %d items)', $config->replacement, count($array))]; - } - - $result = []; - - foreach ($array as $key => $value) { - $keyLower = strtolower((string) $key); - - // Priority 1: Safe keys - always show unredacted - if (in_array($keyLower, $config->safeKeys, true)) { - $result[$key] = $value; - - continue; - } - - // Priority 2: Blocked keys - always redact - if (in_array($keyLower, $config->blockedKeys, true)) { - $result[$key] = $config->replacement; - $wasRedacted = true; - $this->redactedKeys[] = (string) $key; - - continue; - } - - // Priority 3 & 4: Recursively process the value (applies regex and shannon entropy) - $processedValue = $this->redactRecursively($value, $config, $wasRedacted); - - // Handle object removal case - if ($processedValue === '__MONITOR_REMOVE_OBJECT__') { - // Skip adding this key to the result (effectively removing it) - continue; - } - - $result[$key] = $processedValue; - } - - return $result; - } - - /** - * Redact sensitive data from an object. - */ - protected function redactObject( - object $object, - RedactorConfig $config, - bool &$wasRedacted - ): mixed { - // Try to convert object to array using toArray() method if available - if (method_exists($object, 'toArray')) { - try { - /** @var array $array */ - $array = $object->toArray(); - - return $this->redactArray($array, $config, $wasRedacted); - } catch (\Throwable) { - // Fall through to other methods - } - } - - // Try JSON encoding first to detect circular references and other issues - try { - $jsonString = json_encode($object, JSON_THROW_ON_ERROR); - - $array = json_decode($jsonString, true, 512, JSON_THROW_ON_ERROR); - if (! is_array($array)) { - // JSON decode didn't return an array - Monitor::log('LogRedactor')->warning('Unable to redact object - JSON decode did not return array', [ - 'object_class' => get_class($object), - 'reason' => 'json_decode_not_array', - 'decoded_type' => gettype($array), - 'behavior' => $config->nonRedactableObjectBehavior, - ]); - - return $this->handleNonRedactableObject($object, $config, $wasRedacted); - } - - // Check if object is too large - if ($config->redactLargeObjects && count($array) > $config->maxObjectSize) { - $wasRedacted = true; - - return ['_large_object_redacted' => sprintf('%s (Object %s with %d properties)', $config->replacement, get_class($object), count($array))]; - } - - /** @var array $arrayData */ - $arrayData = $array; - - return $this->redactArray($arrayData, $config, $wasRedacted); - } catch (\Throwable $e) { - // If JSON encoding/decoding fails, it's likely due to circular references, - // resources, or other non-serializable content. Return the original object - // to avoid infinite recursion or other issues. - Monitor::log('LogRedactor')->warning('Exception while trying to redact object', [ - 'object_class' => get_class($object), - 'reason' => 'exception_during_processing', - 'exception_type' => get_class($e), - 'exception_message' => $e->getMessage(), - 'behavior' => $config->nonRedactableObjectBehavior, - ]); - - return $this->handleNonRedactableObject($object, $config, $wasRedacted); - } - } - - /** - * Handle objects that cannot be redacted based on configuration. - */ - protected function handleNonRedactableObject( - object $object, - RedactorConfig $config, - bool &$wasRedacted - ): mixed { - return match ($config->nonRedactableObjectBehavior) { - 'remove' => $this->removeObject($wasRedacted), - 'empty_array' => $this->replaceWithEmptyArray($wasRedacted), - 'redact' => $this->replaceWithRedactionText($object, $config, $wasRedacted), - default => $object, // 'preserve' or any unknown value - }; - } - - /** - * Remove the object entirely (return a special marker that can be filtered out). - */ - protected function removeObject(bool &$wasRedacted): string - { - $wasRedacted = true; - - return '__MONITOR_REMOVE_OBJECT__'; - } - - /** @return array */ - protected function replaceWithEmptyArray(bool &$wasRedacted): array - { - $wasRedacted = true; - - return []; - } - - /** - * Replace with redaction text. - */ - protected function replaceWithRedactionText( - object $object, - RedactorConfig $config, - bool &$wasRedacted - ): string { - $wasRedacted = true; - - return sprintf('%s (Non-redactable object %s)', $config->replacement, get_class($object)); - } - - /** - * Redact sensitive data from a string. - */ - protected function redactString( - string $string, - RedactorConfig $config, - bool &$wasRedacted - ): string { - // Check if string is too long - if ($config->maxValueLength !== null && strlen($string) > $config->maxValueLength) { - $wasRedacted = true; - - return sprintf('%s (String with %d characters)', $config->replacement, strlen($string)); - } - - // Priority 3: Apply regex patterns - foreach ($config->patterns as $pattern) { - if (preg_match($pattern, $string)) { - $wasRedacted = true; - - return $config->replacement; - } - } - - // Priority 4: Shannon entropy analysis - if ($config->enableShannonEntropy && $this->shouldRedactByEntropy($string, $config)) { - $wasRedacted = true; - - return $config->replacement; - } - - return $string; - } - - /** - * Determine if a string should be redacted based on Shannon entropy. - */ - protected function shouldRedactByEntropy(string $string, RedactorConfig $config): bool - { - // Only analyze strings that meet minimum length requirement - if (strlen($string) < $config->minLength) { - return false; - } - - // Skip common words and patterns that might have high entropy but are not sensitive - if ($this->isCommonPattern($string, $config)) { - return false; - } - - $entropy = $this->calculateShannonEntropy($string); - - return $entropy >= $config->entropyThreshold; - } - - /** - * Calculate Shannon entropy of a string with caching. - */ - protected function calculateShannonEntropy(string $string): float - { - // Check cache first - if (isset($this->entropyCache[$string])) { - return $this->entropyCache[$string]; - } - - $length = strlen($string); - if ($length <= 1) { - return $this->entropyCache[$string] = 0.0; - } - - // Count character frequencies and calculate entropy in a single loop - $frequencies = []; - for ($i = 0; $i < $length; $i++) { - $char = $string[$i]; - $frequencies[$char] = ($frequencies[$char] ?? 0) + 1; - } - - // Calculate entropy - $entropy = 0.0; - foreach ($frequencies as $frequency) { - $probability = $frequency / $length; - if ($probability > 0) { - $entropy -= $probability * log($probability, 2); - } - } - - // Cache the result - $this->entropyCache[$string] = $entropy; - - return $entropy; - } - - /** - * Check if a string matches common patterns that shouldn't be redacted despite high entropy. - */ - protected function isCommonPattern(string $string, RedactorConfig $config): bool - { - foreach ($config->entropyExclusionPatterns as $pattern) { - if (preg_match($pattern, $string)) { - // Special case: hex strings need additional length check - if ($pattern === '/^[0-9a-f]+$/i' && strlen($string) >= 32) { - continue; // Long hex strings might be sensitive (like SHA256) - } - - return true; - } - } - - return false; - } -} diff --git a/src/Support/PointName.php b/src/Support/PointName.php new file mode 100644 index 0000000..8046556 --- /dev/null +++ b/src/Support/PointName.php @@ -0,0 +1,42 @@ +value : $name; + } + + public static function validate(string $name): string + { + if (! self::isValid($name)) { + throw new InvalidPointName(sprintf( + 'Control point name [%s] does not match the pattern [%s].', + $name, + Config::string('monitor.point_name_pattern'), + )); + } + + return $name; + } + + public static function isValid(string $name): bool + { + $pattern = Config::string('monitor.point_name_pattern', '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/'); + + return preg_match($pattern, $name) === 1; + } +} diff --git a/src/Support/Profiles.php b/src/Support/Profiles.php new file mode 100644 index 0000000..8e0993f --- /dev/null +++ b/src/Support/Profiles.php @@ -0,0 +1,166 @@ + + */ + public static function names(): array + { + /** @var array $profiles */ + $profiles = Config::array('monitor.profiles', []); + + return array_map(strval(...), array_keys($profiles)); + } + + public static function exists(string $name): bool + { + return in_array($name, self::names(), true); + } + + /** + * @return array + */ + public static function definition(string $name): array + { + $definition = Config::get("monitor.profiles.{$name}"); + + if (! is_array($definition)) { + throw new InvalidProfile(sprintf('Profile [%s] is not defined in config/monitor.php.', $name)); + } + + /** @var array $definition */ + return $definition; + } + + /** + * Policies keyed by type: retry, transaction, breaker. + * + * @return array + */ + public static function policies(string $name, string $point): array + { + $definition = self::definition($name); + $policies = []; + + if (is_array($definition['retry'] ?? null)) { + $retry = $definition['retry']; + $policy = Retry::times(self::int($retry, 'times', 1)) + ->backoff(self::int($retry, 'backoff_ms', 0), self::float($retry, 'multiplier', 2.0), self::bool($retry, 'jitter', true)); + $policies['retry'] = $policy->on(self::classes($retry, 'on'))->except(self::classes($retry, 'except')); + } + + if (is_array($definition['transaction'] ?? null)) { + $tx = $definition['transaction']; + $policy = Transaction::retries(self::int($tx, 'retries', 0))->backoff(self::int($tx, 'backoff_ms', 0)); + $connection = $tx['connection'] ?? null; + $policy->connection(is_string($connection) ? $connection : null); + + if (isset($tx['on'])) { + $policy->on(self::classes($tx, 'on')); + } + + $policies['transaction'] = $policy->except(self::classes($tx, 'except')); + } + + if (is_array($definition['breaker'] ?? null)) { + $breaker = $definition['breaker']; + $breakerName = is_string($breaker['name'] ?? null) ? $breaker['name'] : $point; + $policy = Breaker::named($breakerName); + + if (isset($breaker['after'])) { + $policy->after(self::int($breaker, 'after', 5), isset($breaker['within']) ? self::int($breaker, 'within', 60) : null); + } + + if (isset($breaker['for'])) { + $policy->for(self::int($breaker, 'for', 120)); + } + + $policies['breaker'] = $policy->on(self::classes($breaker, 'on'))->except(self::classes($breaker, 'except')); + } + + return $policies; + } + + public static function within(string $name): ?Within + { + $definition = self::definition($name); + $seconds = $definition['within'] ?? null; + + return is_int($seconds) || is_float($seconds) ? new Within((float) $seconds) : null; + } + + public static function attempts(string $name): ?Attempts + { + $definition = self::definition($name); + $max = $definition['attempts'] ?? null; + + return is_int($max) ? new Attempts($max) : null; + } + + /** + * @param array $source + */ + private static function int(array $source, string $key, int $default): int + { + $value = $source[$key] ?? $default; + + return is_int($value) ? $value : (is_numeric($value) ? (int) $value : $default); + } + + /** + * @param array $source + */ + private static function float(array $source, string $key, float $default): float + { + $value = $source[$key] ?? $default; + + return is_numeric($value) ? (float) $value : $default; + } + + /** + * @param array $source + */ + private static function bool(array $source, string $key, bool $default): bool + { + $value = $source[$key] ?? $default; + + return is_bool($value) ? $value : $default; + } + + /** + * @param array $source + * @return list> + */ + private static function classes(array $source, string $key): array + { + $value = $source[$key] ?? []; + + if (! is_array($value)) { + return []; + } + + /** @var list> $classes */ + $classes = array_values(array_filter($value, fn (mixed $c): bool => is_string($c) && is_a($c, \Throwable::class, true))); + + return $classes; + } +} diff --git a/src/Support/Redaction.php b/src/Support/Redaction.php new file mode 100644 index 0000000..6788b60 --- /dev/null +++ b/src/Support/Redaction.php @@ -0,0 +1,94 @@ + $record + * @return array + */ + public static function record(array $record): array + { + if (isset($record['context']) && is_array($record['context'])) { + $record['context'] = self::context($record['context']); + } + + foreach (['exception', 'escalation_exception'] as $key) { + if (isset($record[$key]) && is_array($record[$key])) { + $record[$key] = self::exception($record[$key]); + } + } + + return $record; + } + + /** + * @param array $context + * @return array + */ + public static function context(array $context): array + { + $profile = self::profile(); + + if ($profile === null) { + return $context; + } + + try { + $redacted = Redactor::profile($profile)->withoutMarkers()->redactSafely($context); + } catch (Throwable) { + // A record must never fail a control point; without a redactor the context is dropped rather than leaked. + return ['_redaction' => 'unavailable']; + } + + return is_array($redacted) ? $redacted : []; + } + + /** + * @param array $exception + * @return array + */ + public static function exception(array $exception): array + { + $profile = self::profile(); + + if ($profile === null) { + return $exception; + } + + foreach (['message', 'previous'] as $key) { + if (! isset($exception[$key])) { + continue; + } + + try { + $exception[$key] = Redactor::profile($profile)->withoutMarkers()->redactSafely($exception[$key]); + } catch (Throwable) { + $exception[$key] = '[redaction unavailable]'; + } + } + + return $exception; + } + + public static function profile(): ?string + { + $profile = Config::get('monitor.records.redaction'); + + return is_string($profile) && $profile !== '' ? $profile : null; + } +} diff --git a/src/Support/RedactorConfig.php b/src/Support/RedactorConfig.php deleted file mode 100644 index f8a20e8..0000000 --- a/src/Support/RedactorConfig.php +++ /dev/null @@ -1,78 +0,0 @@ - */ - public array $safeKeys = [], - /** @var array */ - public array $blockedKeys = [], - /** @var array */ - public array $patterns = [], - public string $replacement = '[REDACTED]', - public ?int $maxValueLength = null, - public bool $redactLargeObjects = true, - public int $maxObjectSize = 50, - public bool $enableShannonEntropy = true, - public float $entropyThreshold = 4.5, - public int $minLength = 20, - /** @var array */ - public array $entropyExclusionPatterns = [], - public bool $markRedacted = true, - public bool $trackRedactedKeys = false, - public string $nonRedactableObjectBehavior = 'preserve' - ) {} - - public static function fromConfig(): self - { - /** @var array $safeKeys */ - $safeKeys = Config::array('monitor.log_redactor.safe_keys', []); - /** @var array $blockedKeys */ - $blockedKeys = Config::array('monitor.log_redactor.blocked_keys', []); - /** @var array $patterns */ - $patterns = Config::array('monitor.log_redactor.patterns', []); - - // Ensure we only have string values and apply transformations - $safeKeysLower = array_map(function (string $key): string { - return strtolower($key); - }, $safeKeys); - - $blockedKeysLower = array_map(function (string $key): string { - return strtolower($key); - }, $blockedKeys); - - $validPatterns = array_filter($patterns, fn (string $pattern): bool => @preg_match($pattern, '') !== false); - - // Handle nullable max value length - $maxValueLength = Config::get('monitor.log_redactor.max_value_length'); - $maxValueLengthTyped = is_int($maxValueLength) ? $maxValueLength : null; - - /** @var array $entropyExclusionPatterns */ - $entropyExclusionPatterns = Config::array('monitor.log_redactor.shannon_entropy.exclusion_patterns'); - - $validExclusionPatterns = array_filter($entropyExclusionPatterns, fn (string $pattern): bool => @preg_match($pattern, '') !== false); - - return new self( - safeKeys: $safeKeysLower, - blockedKeys: $blockedKeysLower, - patterns: array_values($validPatterns), // Re-index array - replacement: Config::string('monitor.log_redactor.replacement', '[REDACTED]'), - maxValueLength: $maxValueLengthTyped, - redactLargeObjects: Config::boolean('monitor.log_redactor.redact_large_objects', true), - maxObjectSize: Config::integer('monitor.log_redactor.max_object_size', 50), - enableShannonEntropy: Config::boolean('monitor.log_redactor.shannon_entropy.enabled', true), - entropyThreshold: Config::float('monitor.log_redactor.shannon_entropy.threshold', 4.5), - minLength: Config::integer('monitor.log_redactor.shannon_entropy.min_length', 20), - entropyExclusionPatterns: array_values($validExclusionPatterns), // Re-index array - markRedacted: Config::boolean('monitor.log_redactor.mark_redacted', true), - trackRedactedKeys: Config::boolean('monitor.log_redactor.track_redacted_keys', false), - nonRedactableObjectBehavior: Config::string('monitor.log_redactor.non_redactable_object_behavior', 'preserve') - ); - } -} diff --git a/src/Taps/StructuredLoggingTap.php b/src/Taps/StructuredLoggingTap.php deleted file mode 100644 index e065522..0000000 --- a/src/Taps/StructuredLoggingTap.php +++ /dev/null @@ -1,21 +0,0 @@ -getHandlers() as $handler) { - if ($handler instanceof AbstractProcessingHandler) { - $handler->setFormatter(new StructuredJsonFormatter); - } - } - } -} diff --git a/src/Testing/Expectations.php b/src/Testing/Expectations.php new file mode 100644 index 0000000..302f028 --- /dev/null +++ b/src/Testing/Expectations.php @@ -0,0 +1,136 @@ +toBeControlled(); + * expect('App\ControlPoints')->toHaveCompleteControlPoints(); + * + * The assertions are also plain static methods, for PHPUnit. + */ +class Expectations +{ + /** + * The subject an expectation was built on; Pest rebinds the closures below + * to its own Expectation, which has the same property. + */ + public mixed $value = null; + + public static function register(): void + { + $mixin = new self; + + expect()->extend('toBeControlled', $mixin->toBeControlled()); + expect()->extend('toHaveCompleteControlPoints', $mixin->toHaveCompleteControlPoints()); + } + + /** + * Every class in the namespace is a control point class or calls one inline. + */ + public static function assertControlled(string $namespace): void + { + $inventory = self::discovery()->build(rules: []); + $findings = self::withNamespaces([$namespace], fn (): array => (new CriticalNamespaceUncontrolled)->check($inventory)); + + Assert::assertEmpty($findings, self::describe($findings, "Uncontrolled operations in {$namespace}:")); + } + + /** + * Every class-form point in the namespace passes the configured rules. + */ + public static function assertComplete(string $namespace): void + { + $inventory = self::discovery()->build(); + $prefix = rtrim($namespace, '\\').'\\'; + + $findings = array_values(array_filter($inventory->findings(), function (Finding $finding) use ($inventory, $prefix): bool { + if (! $finding->isError()) { + return false; + } + + $point = $finding->point !== null ? $inventory->find($finding->point) : null; + + return $point instanceof PointDescription && str_starts_with($point->origin, $prefix); + })); + + Assert::assertEmpty($findings, self::describe($findings, "Incomplete control points in {$namespace}:")); + } + + /** + * @internal + */ + public static function namespaceOf(mixed $value): string + { + if (! is_string($value) || $value === '') { + throw new InvalidArgumentException('Expected a namespace string.'); + } + + return $value; + } + + private function toBeControlled(): Closure + { + return function (): object { + Expectations::assertControlled(Expectations::namespaceOf($this->value)); + + return $this; + }; + } + + private function toHaveCompleteControlPoints(): Closure + { + return function (): object { + Expectations::assertComplete(Expectations::namespaceOf($this->value)); + + return $this; + }; + } + + /** + * @param list $findings + */ + private static function describe(array $findings, string $heading): string + { + return $heading."\n".implode("\n", array_map(fn (Finding $f): string => ' - '.$f->message, $findings)); + } + + /** + * @param list $namespaces + * @param callable(): list $callback + * @return list + */ + private static function withNamespaces(array $namespaces, callable $callback): array + { + $config = Container::getInstance()->make('config'); + $previous = $config->get('monitor.critical_namespaces'); + $config->set('monitor.critical_namespaces', $namespaces); + + try { + return $callback(); + } finally { + $config->set('monitor.critical_namespaces', $previous); + } + } + + private static function discovery(): Discovery + { + return Container::getInstance()->make(Discovery::class); + } +} diff --git a/src/Testing/MonitorFake.php b/src/Testing/MonitorFake.php new file mode 100644 index 0000000..ad8fcb2 --- /dev/null +++ b/src/Testing/MonitorFake.php @@ -0,0 +1,374 @@ + */ + protected array $outcomes = []; + + /** @var array */ + protected array $returning = []; + + /** @var array */ + protected array $failing = []; + + public function __construct(Container $container, protected readonly Runner $live) + { + parent::__construct($container); + } + + /** + * Execute the point, substituting a canned value or failure when one is registered. + */ + public function execute(Control $control, Closure $callback): Outcome + { + $point = $control->name(); + + if (array_key_exists($point, $this->failing)) { + $exception = $this->failing[$point]; + $callback = function () use ($exception): never { + throw $exception; + }; + } elseif (array_key_exists($point, $this->returning)) { + $value = $this->returning[$point]; + $callback = fn (): mixed => $value; + } + + $outcome = $this->live->execute($control, $callback); + + $this->outcomes[] = $outcome; + + return $outcome; + } + + /** + * Make the point succeed with this value without running its callback. + */ + public function returning(string|BackedEnum $point, mixed $value): static + { + $name = PointName::of($point); + $this->returning[$name] = $value; + unset($this->failing[$name]); + + return $this; + } + + /** + * Make the point's callback throw this, exercising its own corrections. + */ + public function failing(string|BackedEnum $point, Throwable $exception): static + { + $name = PointName::of($point); + $this->failing[$name] = $exception; + unset($this->returning[$name]); + + return $this; + } + + /** + * Get the recorded outcomes, optionally for one point. + * + * @return Collection + */ + public function outcomes(string|BackedEnum|null $point = null): Collection + { + $outcomes = new Collection($this->outcomes); + + if ($point === null) { + return $outcomes; + } + + $name = PointName::of($point); + + return $outcomes->filter(fn (Outcome $o): bool => $o->point === $name)->values(); + } + + /** + * Discard every recorded outcome. + */ + public function forget(): void + { + $this->outcomes = []; + } + + /** + * Assert the point ran at least once, optionally with an outcome matching the callback. + * + * @param (Closure(Outcome): bool)|null $callback + */ + public function assertRan(string|BackedEnum $point, ?Closure $callback = null): static + { + $name = PointName::of($point); + + Assert::assertNotEmpty($this->matching($name, $callback), $callback instanceof Closure + ? "The control point [{$name}] ran, but no outcome matched the callback." + : "The control point [{$name}] did not run."); + + return $this; + } + + /** + * Assert the point did not run, or ran with no outcome matching the callback. + * + * @param (Closure(Outcome): bool)|null $callback + */ + public function assertNotRan(string|BackedEnum $point, ?Closure $callback = null): static + { + $name = PointName::of($point); + + Assert::assertEmpty($this->matching($name, $callback), $callback instanceof Closure + ? "The control point [{$name}] ran with an outcome matching the callback, but was expected not to." + : "The control point [{$name}] ran, but was expected not to."); + + return $this; + } + + /** + * Assert the point ran exactly once. + */ + public function assertRanOnce(string|BackedEnum $point): static + { + return $this->assertRanTimes($point, 1); + } + + /** + * Assert the point ran exactly this many times. + */ + public function assertRanTimes(string|BackedEnum $point, int $times): static + { + $name = PointName::of($point); + $count = $this->outcomes($name)->count(); + + Assert::assertSame($times, $count, "The control point [{$name}] ran {$count} times instead of {$times} times."); + + return $this; + } + + /** + * Assert the point succeeded, optionally with an outcome matching the callback. + * + * @param (Closure(Outcome): bool)|null $callback + */ + public function assertSucceeded(string|BackedEnum $point, ?Closure $callback = null): static + { + return $this->assertStatus($point, Status::Succeeded, $callback); + } + + /** + * Assert the point recovered, optionally from the given risk class or matching the callback. + * + * @param (Closure(Outcome): bool)|class-string|null $from + */ + public function assertRecovered(string|BackedEnum $point, Closure|string|null $from = null): static + { + return $this->assertStatus($point, Status::Recovered, $this->risk($from)); + } + + /** + * Assert the point did not recover, or not from the given risk class. + * + * @param (Closure(Outcome): bool)|class-string|null $from + */ + public function assertNotRecovered(string|BackedEnum $point, Closure|string|null $from = null): static + { + return $this->assertNotStatus($point, Status::Recovered, $this->risk($from)); + } + + /** + * Assert the point escalated, optionally with the given exception class or matching the callback. + * + * @param (Closure(Outcome): bool)|class-string|null $with + */ + public function assertEscalated(string|BackedEnum $point, Closure|string|null $with = null): static + { + return $this->assertStatus($point, Status::Escalated, $this->exception($with)); + } + + /** + * Assert the point did not escalate, or not with the given exception class. + * + * @param (Closure(Outcome): bool)|class-string|null $with + */ + public function assertNotEscalated(string|BackedEnum $point, Closure|string|null $with = null): static + { + return $this->assertNotStatus($point, Status::Escalated, $this->exception($with)); + } + + /** + * Assert the point was refused by an open breaker. + */ + public function assertRefused(string|BackedEnum $point): static + { + return $this->assertStatus($point, Status::Refused, null); + } + + /** + * Assert the point was never refused. + */ + public function assertNotRefused(string|BackedEnum $point): static + { + return $this->assertNotStatus($point, Status::Refused, null); + } + + /** + * Assert the point was retried, optionally exactly this many times. + */ + public function assertRetried(string|BackedEnum $point, ?int $times = null): static + { + $name = PointName::of($point); + $this->assertRan($name); + + Assert::assertNotEmpty($this->matching($name, $this->retried($times)), $times === null + ? "The control point [{$name}] was not retried." + : "The control point [{$name}] was not retried exactly {$times} times."); + + return $this; + } + + /** + * Assert the point was never retried, or never exactly this many times. + */ + public function assertNotRetried(string|BackedEnum $point, ?int $times = null): static + { + $name = PointName::of($point); + + Assert::assertEmpty($this->matching($name, $this->retried($times)), $times === null + ? "The control point [{$name}] was retried, but was expected not to be." + : "The control point [{$name}] was retried exactly {$times} times, but was expected not to be."); + + return $this; + } + + /** + * Assert an outcome of the point breached the named limit. + */ + public function assertLimitBreached(string|BackedEnum $point, string $limit): static + { + $name = PointName::of($point); + $this->assertRan($name); + + Assert::assertNotEmpty( + $this->matching($name, fn (Outcome $o): bool => $o->breachedLimit($limit)), + "The control point [{$name}] did not breach the [{$limit}] limit.", + ); + + return $this; + } + + /** + * Assert no point escalated. + */ + public function assertNothingEscalated(): static + { + $escalated = $this->outcomes()->filter(fn (Outcome $o): bool => $o->escalated())->map(fn (Outcome $o): string => $o->point); + + Assert::assertEmpty($escalated->all(), 'Control points escalated: '.$escalated->implode(', ').'.'); + + return $this; + } + + /** + * Assert no point ran. + */ + public function assertNothingRan(): static + { + $ran = $this->outcomes()->map(fn (Outcome $o): string => $o->point); + + Assert::assertEmpty($ran->all(), 'Control points ran: '.$ran->implode(', ').'.'); + + return $this; + } + + /** + * @param (Closure(Outcome): bool)|null $callback + */ + protected function assertStatus(string|BackedEnum $point, Status $status, ?Closure $callback): static + { + $name = PointName::of($point); + $this->assertRan($name); + + $matching = $this->matching($name, fn (Outcome $o): bool => $o->status === $status && (! $callback instanceof Closure || $callback($o))); + $seen = $this->outcomes($name)->map(fn (Outcome $o): string => $o->status->value)->implode(', '); + + Assert::assertNotEmpty($matching, "The control point [{$name}] did not end {$status->value}; saw: {$seen}."); + + return $this; + } + + /** + * @param (Closure(Outcome): bool)|null $callback + */ + protected function assertNotStatus(string|BackedEnum $point, Status $status, ?Closure $callback): static + { + $name = PointName::of($point); + + $matching = $this->matching($name, fn (Outcome $o): bool => $o->status === $status && (! $callback instanceof Closure || $callback($o))); + + Assert::assertEmpty($matching, "The control point [{$name}] ended {$status->value}, but was expected not to."); + + return $this; + } + + /** + * @param (Closure(Outcome): bool)|null $callback + * @return list + */ + protected function matching(string $point, ?Closure $callback): array + { + return array_values($this->outcomes($point)->filter(fn (Outcome $o): bool => ! $callback instanceof Closure || $callback($o))->all()); + } + + /** + * @param (Closure(Outcome): bool)|class-string|null $from + * @return (Closure(Outcome): bool)|null + */ + private function risk(Closure|string|null $from): ?Closure + { + return is_string($from) ? fn (Outcome $o): bool => $o->recoveredFrom === $from : $from; + } + + /** + * @param (Closure(Outcome): bool)|class-string|null $with + * @return (Closure(Outcome): bool)|null + */ + private function exception(Closure|string|null $with): ?Closure + { + return is_string($with) ? fn (Outcome $o): bool => $o->exception instanceof $with : $with; + } + + /** + * @return Closure(Outcome): bool + */ + private function retried(?int $times): Closure + { + return $times === null + ? fn (Outcome $o): bool => $o->attempts > 1 + : fn (Outcome $o): bool => $o->attempts === $times + 1; + } +} diff --git a/src/Trace.php b/src/Trace.php deleted file mode 100644 index 7a4e26f..0000000 --- a/src/Trace.php +++ /dev/null @@ -1,60 +0,0 @@ -hasStarted()) { - return $this; - } - - if ($traceId !== null) { - $this->override($traceId); - } else { - $this->start(); - } - - return $this; - } - - public function start(): void - { - if ($this->hasStarted()) { - throw new \LogicException('Trace has already been started.'); - } - - $this->traceId = Str::uuid()->toString(); - } - - public function override(string $uuid): void - { - $this->traceId = $uuid; - } - - public function id(): string - { - if (! $this->traceId) { - throw new \LogicException('Trace ID has not been started.'); - } - - return $this->traceId; - } - - public function hasStarted(): bool - { - return $this->traceId !== null; - } - - public function hasNotStarted(): bool - { - return $this->traceId === null; - } -} diff --git a/src/Trace/PicksUpCommandTrace.php b/src/Trace/PicksUpCommandTrace.php new file mode 100644 index 0000000..4f6db22 --- /dev/null +++ b/src/Trace/PicksUpCommandTrace.php @@ -0,0 +1,26 @@ +trace->pickup(); + } +} diff --git a/src/Trace/PicksUpJobTrace.php b/src/Trace/PicksUpJobTrace.php new file mode 100644 index 0000000..b12ce33 --- /dev/null +++ b/src/Trace/PicksUpJobTrace.php @@ -0,0 +1,24 @@ +trace->pickup(); + $this->stack->handOff(); + } +} diff --git a/src/Trace/PropagatesTrace.php b/src/Trace/PropagatesTrace.php new file mode 100644 index 0000000..953a4d8 --- /dev/null +++ b/src/Trace/PropagatesTrace.php @@ -0,0 +1,38 @@ +traced(), which add the current + * trace to an outgoing request as traceparent and the legacy header. + */ +class PropagatesTrace +{ + /** + * @param Closure(): Trace $trace resolves the trace lazily, so nothing is built at boot + */ + public static function register(Closure $trace): void + { + $headers = static fn (): array => [ + Config::string('monitor.trace.header', 'traceparent') => TraceParent::format($trace()->id()), + Config::string('monitor.trace.legacy_header', 'X-Trace-Id') => $trace()->id(), + ]; + + PendingRequest::macro('traced', function () use ($headers): PendingRequest { + /** @var PendingRequest $this */ + return $this->withHeaders($headers()); + }); + + Factory::macro('traced', function () use ($headers): PendingRequest { + /** @var Factory $this */ + return $this->withHeaders($headers()); + }); + } +} diff --git a/src/Trace/Trace.php b/src/Trace/Trace.php new file mode 100644 index 0000000..cb9dbf0 --- /dev/null +++ b/src/Trace/Trace.php @@ -0,0 +1,108 @@ +current() !== null; + } + + public function current(): ?string + { + $id = Context::get(self::KEY); + + return is_string($id) && $id !== '' ? $id : null; + } + + /** + * The current trace ID, starting one if none exists. + */ + public function id(): string + { + return $this->pickup(); + } + + /** + * Start a new trace, replacing any that exists. + */ + public function start(): string + { + return $this->override(self::generate()); + } + + /** + * Continue the current trace, or adopt the given ID, or start a new one. + */ + public function pickup(?string $id = null): string + { + $current = $this->current(); + + if ($current !== null) { + return $current; + } + + if ($id !== null) { + return $this->override($id); + } + + return $this->start(); + } + + /** + * Adopt the given ID, replacing any that exists. + * + * @throws InvalidTraceId when the ID is neither 32 hex characters nor a UUID + */ + public function override(string $id): string + { + $normalised = self::normalise($id); + + if ($normalised === null) { + throw new InvalidTraceId(sprintf('[%s] is not a valid trace ID.', $id)); + } + + Context::add(self::KEY, $normalised); + + return $normalised; + } + + public function clear(): void + { + Context::forget(self::KEY); + } + + public static function generate(): string + { + return bin2hex(random_bytes(16)); + } + + /** + * 32 hex characters, or a UUID with its dashes removed; anything else is null. + */ + public static function normalise(string $id): ?string + { + $candidate = strtolower(str_replace('-', '', trim($id))); + + if (preg_match('/^[0-9a-f]{32}$/', $candidate) !== 1 || $candidate === str_repeat('0', 32)) { + return null; + } + + return $candidate; + } +} diff --git a/src/Trace/TraceParent.php b/src/Trace/TraceParent.php new file mode 100644 index 0000000..e033740 --- /dev/null +++ b/src/Trace/TraceParent.php @@ -0,0 +1,42 @@ +failing('{{ point }}', new RuntimeException('unexpected')); + + {{ class }}::attempt(); + + Monitor::assertEscalated('{{ point }}', with: RuntimeException::class); +}); diff --git a/stubs/control-point.stub b/stubs/control-point.stub new file mode 100644 index 0000000..108997d --- /dev/null +++ b/stubs/control-point.stub @@ -0,0 +1,48 @@ +recover(SomeExpectedException::class, fn (SomeExpectedException $e) => /* the value to return instead */) + // ->ensure(fn ($result): bool => true, 'describe what must hold') + ->escalate(fn ($outcome) => report($outcome->exception)); + } + + /** + * Context recorded with every transition of this point. + * + * @return array + */ + public function context(): array + { + return []; + } + + /** + * The operation. Parameters are resolved from the container. + */ + public function handle(): mixed + { + // + } +} diff --git a/testbench.yaml b/testbench.yaml index f67328f..cea7441 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -1,6 +1,8 @@ laravel: '@testbench' providers: + - Kirschbaum\Redactor\RedactorServiceProvider + - Laravel\Mcp\Server\McpServiceProvider - Kirschbaum\Monitor\MonitorServiceProvider workbench: diff --git a/tests/Feature/BreakerTest.php b/tests/Feature/BreakerTest.php new file mode 100644 index 0000000..ffadb28 --- /dev/null +++ b/tests/Feature/BreakerTest.php @@ -0,0 +1,210 @@ +breaker('stripe', after: 2, within: 60, for: 100); +} + +describe('breaker policy', function (): void { + it('opens after the configured failures and refuses the next run', function (): void { + Event::fake([BreakerOpened::class, PointRefused::class]); + + stripe()->attempt(fn () => throw new Fatal('1')); + stripe()->attempt(fn () => throw new Fatal('2')); + + $calls = 0; + $outcome = stripe()->attempt(function () use (&$calls): void { + $calls++; + }); + + expect($calls)->toBe(0) + ->and($outcome->status)->toBe(Status::Refused) + ->and($outcome->refused())->toBeTrue() + ->and($outcome->exception)->toBeInstanceOf(BreakerOpen::class) + ->and($outcome->exception->breaker)->toBe('stripe') + ->and($outcome->exception->retryAfterSeconds)->toBe(100) + ->and($outcome->timeline[1]['event'])->toBe('breaker.refused') + ->and(Monitor::breaker()->isOpen('stripe'))->toBeTrue(); + + Event::assertDispatchedTimes(BreakerOpened::class, 1); + Event::assertDispatched(PointRefused::class); + }); + + it('run() throws BreakerOpen when refused', function (): void { + stripe()->attempt(fn () => throw new Fatal); + stripe()->attempt(fn () => throw new Fatal); + + expect(fn (): mixed => stripe()->run(fn (): int => 1))->toThrow(BreakerOpen::class); + }); + + it('lets the point recover from its own refusal', function (): void { + stripe()->attempt(fn () => throw new Fatal); + stripe()->attempt(fn () => throw new Fatal); + + $outcome = stripe()->recover(BreakerOpen::class, fn (BreakerOpen $e): string => "later ({$e->breaker})")->attempt(fn (): int => 1); + + expect($outcome->status)->toBe(Status::Recovered) + ->and($outcome->value)->toBe('later (stripe)') + ->and($outcome->recoveredFrom)->toBe(BreakerOpen::class); + }); + + it('counts one failure per run, after retries', function (): void { + Sleep::fake(); + + Monitor::control('payment.charge')->breaker('stripe', after: 2)->retry(times: 3, backoffMs: 0)->attempt(failingTimes(10)); + + expect(Monitor::breaker()->state('stripe')->failureCount())->toBe(1); + }); + + it('does not count exceptions the breaker is told to ignore', function (): void { + $control = fn () => Monitor::control('payment.charge')->policy(Breaker::named('stripe')->after(1)->except([CardDeclined::class])); + + $control()->attempt(fn () => throw new CardDeclined); + + expect(Monitor::breaker()->isOpen('stripe'))->toBeFalse(); + + $control()->attempt(fn () => throw new Fatal); + + expect(Monitor::breaker()->isOpen('stripe'))->toBeTrue(); + }); + + it('half-opens after the open period, lets one probe through, and closes on success', function (): void { + Event::fake([BreakerHalfOpen::class, BreakerClosed::class]); + + stripe()->attempt(fn () => throw new Fatal); + stripe()->attempt(fn () => throw new Fatal); + + $this->travel(101)->seconds(); + + expect(Monitor::breaker()->isOpen('stripe'))->toBeFalse(); + + $probe = stripe()->attempt(fn (): string => 'probe ok'); + + expect($probe->status)->toBe(Status::Succeeded) + ->and($probe->timeline[1]['event'])->toBe('breaker.probe') + ->and(Monitor::breaker()->state('stripe')->state)->toBe(State::Closed); + + Event::assertDispatched(BreakerHalfOpen::class); + Event::assertDispatched(BreakerClosed::class); + }); + + it('refuses a second caller while a probe is in flight', function (): void { + stripe()->attempt(fn () => throw new Fatal); + stripe()->attempt(fn () => throw new Fatal); + $this->travel(101)->seconds(); + + $second = null; + $first = stripe()->attempt(function () use (&$second): string { + $second = stripe()->attempt(fn (): string => 'no'); + + return 'probe'; + }); + + expect($first->status)->toBe(Status::Succeeded) + ->and($second->status)->toBe(Status::Refused); + }); + + it('reopens for a full period when the probe fails', function (): void { + stripe()->attempt(fn () => throw new Fatal); + stripe()->attempt(fn () => throw new Fatal); + $this->travel(101)->seconds(); + + stripe()->attempt(fn () => throw new Fatal('probe failed')); + + expect(Monitor::breaker()->isOpen('stripe'))->toBeTrue() + ->and(Monitor::breaker()->retryAfter('stripe'))->toBe(100); + }); + + it('forgets failures outside the window', function (): void { + stripe()->attempt(fn () => throw new Fatal); + $this->travel(61)->seconds(); + stripe()->attempt(fn () => throw new Fatal); + + expect(Monitor::breaker()->isOpen('stripe'))->toBeFalse() + ->and(Monitor::breaker()->state('stripe')->failureCount())->toBe(1); + }); + + it('describes itself with the effective configuration', function (): void { + config()->set('monitor.breakers', ['after' => 9, 'within' => 90, 'for' => 900, 'prefix' => 'monitor:breaker:']); + + expect(Breaker::named('x')->describe())->toMatchArray(['type' => 'breaker', 'name' => 'x', 'after' => 9, 'within' => 90, 'for' => 900]) + ->and(Breaker::named('x')->after(1, 2)->for(3)->describe())->toMatchArray(['after' => 1, 'within' => 2, 'for' => 3]); + }); +}); + +describe('circuit breaker service', function (): void { + it('starts closed', function (): void { + expect(Monitor::breaker()->state('fresh')->isClosed())->toBeTrue() + ->and(Monitor::breaker()->isClosed('fresh'))->toBeTrue() + ->and(Monitor::breaker()->permit('fresh')->allowed)->toBeTrue() + ->and(Monitor::breaker()->retryAfter('fresh'))->toBe(0); + }); + + it('can be opened and closed by hand', function (): void { + Event::fake([BreakerOpened::class, BreakerClosed::class]); + + Monitor::breaker()->open('efiling', 30); + + expect(Monitor::breaker()->isOpen('efiling'))->toBeTrue() + ->and(Monitor::breaker()->retryAfter('efiling'))->toBe(30); + + Monitor::breaker()->close('efiling'); + + expect(Monitor::breaker()->isOpen('efiling'))->toBeFalse(); + + Event::assertDispatched(BreakerOpened::class); + Event::assertDispatched(BreakerClosed::class); + }); + + it('does not announce a close that changes nothing', function (): void { + Event::fake([BreakerClosed::class]); + + Monitor::breaker()->recordSuccess('quiet'); + + Event::assertNotDispatched(BreakerClosed::class); + }); + + it('uses configured defaults for the standalone API', function (): void { + config()->set('monitor.breakers.after', 1); + + Monitor::breaker()->recordFailure('api'); + + expect(Monitor::breaker()->isOpen('api'))->toBeTrue() + ->and(BreakerConfig::defaults()->after)->toBe(1); + }); + + it('keeps at most "after" failures in the window', function (): void { + $config = new BreakerConfig(after: 2, within: 60, for: 10); + + Monitor::breaker()->recordFailure('cap', $config); + Monitor::breaker()->recordFailure('cap', $config); + + expect(Monitor::breaker()->state('cap')->failureCount())->toBe(2) + ->and(Monitor::breaker()->state('cap')->isOpen())->toBeTrue(); + }); + + it('survives a malformed cache entry', function (): void { + Cache::put('monitor:breaker:junk', ['state' => 'weird', 'failures' => 'no', 'opened_at' => 'x'], 60); + + expect(Monitor::breaker()->state('junk')->isClosed())->toBeTrue(); + }); +}); diff --git a/tests/Feature/CheckCircuitBreakersMiddlewareTest.php b/tests/Feature/CheckCircuitBreakersMiddlewareTest.php deleted file mode 100644 index 73290d9..0000000 --- a/tests/Feature/CheckCircuitBreakersMiddlewareTest.php +++ /dev/null @@ -1,256 +0,0 @@ -instance(CircuitBreaker::class, new CircuitBreaker); -}); - -describe('CheckCircuitBreakers Middleware', function () { - describe('Handle Method', function () { - it('allows request through when no circuit breakers are specified', function () { - $middleware = new CheckCircuitBreakers; - $request = Request::create('/test', 'GET'); - - $next = function ($req) { - return response('success', 200); - }; - - $response = $middleware->handle($request, $next); - - expect($response->getStatusCode())->toBe(200) - ->and($response->getContent())->toBe('success'); - }); - - it('allows request through when circuit breakers are closed', function () { - $middleware = new CheckCircuitBreakers; - $request = Request::create('/test', 'GET'); - - $next = function ($req) { - return response('success', 200); - }; - - $response = $middleware->handle($request, $next, 'breaker1', 'breaker2'); - - expect($response->getStatusCode())->toBe(200) - ->and($response->getContent())->toBe('success'); - }); - - it('denies request when first circuit breaker is open', function () { - $circuitBreaker = app(CircuitBreaker::class); - $circuitBreaker->forceOpen('breaker1'); - - $middleware = new CheckCircuitBreakers; - $request = Request::create('/test', 'GET'); - - $nextCalled = false; - $next = function ($req) use (&$nextCalled) { - $nextCalled = true; - - return response('success', 200); - }; - - $response = $middleware->handle($request, $next, 'breaker1', 'breaker2'); - - expect($response->getStatusCode())->toBe(503) - ->and($nextCalled)->toBeFalse() - ->and($response->headers->get('X-Circuit-Breaker'))->toBe('breaker1') - ->and($response->headers->get('X-Circuit-Breaker-Status'))->toBe('open') - ->and($response->headers->has('Retry-After'))->toBeTrue(); - }); - - it('denies request when second circuit breaker is open', function () { - $circuitBreaker = app(CircuitBreaker::class); - $circuitBreaker->forceOpen('breaker2'); - - $middleware = new CheckCircuitBreakers; - $request = Request::create('/test', 'GET'); - - $nextCalled = false; - $next = function ($req) use (&$nextCalled) { - $nextCalled = true; - - return response('success', 200); - }; - - $response = $middleware->handle($request, $next, 'breaker1', 'breaker2'); - - expect($response->getStatusCode())->toBe(503) - ->and($nextCalled)->toBeFalse() - ->and($response->headers->get('X-Circuit-Breaker'))->toBe('breaker2') - ->and($response->headers->get('X-Circuit-Breaker-Status'))->toBe('open'); - }); - - it('stops at first open circuit breaker when multiple are open', function () { - $circuitBreaker = app(CircuitBreaker::class); - $circuitBreaker->forceOpen('breaker1'); - $circuitBreaker->forceOpen('breaker2'); - - $middleware = new CheckCircuitBreakers; - $request = Request::create('/test', 'GET'); - - $next = function ($req) { - return response('success', 200); - }; - - $response = $middleware->handle($request, $next, 'breaker1', 'breaker2'); - - expect($response->getStatusCode())->toBe(503) - ->and($response->headers->get('X-Circuit-Breaker'))->toBe('breaker1'); - }); - }); - - describe('Deny Method', function () { - it('returns 503 response with correct headers', function () { - $middleware = new CheckCircuitBreakers; - - // Use reflection to test the protected method - $reflection = new \ReflectionClass($middleware); - $denyMethod = $reflection->getMethod('deny'); - $denyMethod->setAccessible(true); - - $response = $denyMethod->invoke($middleware, 'test-breaker'); - - expect($response)->toBeInstanceOf(Response::class) - ->and($response->getStatusCode())->toBe(503) - ->and($response->getContent())->toBe('') - ->and($response->headers->get('X-Circuit-Breaker'))->toBe('test-breaker') - ->and($response->headers->get('X-Circuit-Breaker-Status'))->toBe('open') - ->and($response->headers->has('Retry-After'))->toBeTrue(); - }); - }); - - describe('RetryAfter Method', function () { - it('returns default 300 seconds when state has no last failure time', function () { - $middleware = new CheckCircuitBreakers; - - // Use reflection to test the protected method - $reflection = new \ReflectionClass($middleware); - $retryAfterMethod = $reflection->getMethod('retryAfter'); - $retryAfterMethod->setAccessible(true); - - $retryAfter = $retryAfterMethod->invoke($middleware, 'non-existent-breaker'); - - expect($retryAfter)->toBe(300); - }); - - it('calculates retry after based on elapsed time from failure', function () { - $circuitBreaker = app(CircuitBreaker::class); - - // Record a failure to create a state with lastFailureAt - $circuitBreaker->recordFailure('test-breaker', 300); - - $middleware = new CheckCircuitBreakers; - - // Use reflection to test the protected method - $reflection = new \ReflectionClass($middleware); - $retryAfterMethod = $reflection->getMethod('retryAfter'); - $retryAfterMethod->setAccessible(true); - - $retryAfter = $retryAfterMethod->invoke($middleware, 'test-breaker'); - - expect($retryAfter)->toBeInt() - ->and($retryAfter)->toBeGreaterThanOrEqual(0) - ->and($retryAfter)->toBeLessThanOrEqual(300); - }); - - it('uses decay seconds from state when available', function () { - $circuitBreaker = app(CircuitBreaker::class); - - // Record a failure with custom decay seconds - $circuitBreaker->recordFailure('test-breaker', 600); - - $middleware = new CheckCircuitBreakers; - - // Use reflection to test the protected method - $reflection = new \ReflectionClass($middleware); - $retryAfterMethod = $reflection->getMethod('retryAfter'); - $retryAfterMethod->setAccessible(true); - - $retryAfter = $retryAfterMethod->invoke($middleware, 'test-breaker'); - - expect($retryAfter)->toBeInt() - ->and($retryAfter)->toBeGreaterThanOrEqual(0) - ->and($retryAfter)->toBeLessThanOrEqual(600); - }); - - it('falls back to config when state has no decay seconds', function () { - Config::set('monitor.circuit_breaker.default_decay_seconds', 450); - - $circuitBreaker = app(CircuitBreaker::class); - - // Create a state but without decay seconds (by using the state directly) - // First record a failure, then manually modify to remove decay seconds - $circuitBreaker->recordFailure('config-fallback-breaker', null); - - $middleware = new CheckCircuitBreakers; - - // Use reflection to test the protected method - $reflection = new \ReflectionClass($middleware); - $retryAfterMethod = $reflection->getMethod('retryAfter'); - $retryAfterMethod->setAccessible(true); - - $retryAfter = $retryAfterMethod->invoke($middleware, 'config-fallback-breaker'); - - expect($retryAfter)->toBeInt() - ->and($retryAfter)->toBeGreaterThanOrEqual(0) - ->and($retryAfter)->toBeLessThanOrEqual(450); - }); - - it('returns 0 when decay time has fully elapsed', function () { - $circuitBreaker = app(CircuitBreaker::class); - - // Create a state with an old failure time - $circuitBreaker->recordFailure('old-breaker', 1); - - // Wait for decay to pass - sleep(2); - - $middleware = new CheckCircuitBreakers; - - // Use reflection to test the protected method - $reflection = new \ReflectionClass($middleware); - $retryAfterMethod = $reflection->getMethod('retryAfter'); - $retryAfterMethod->setAccessible(true); - - $retryAfter = $retryAfterMethod->invoke($middleware, 'old-breaker'); - - expect($retryAfter)->toBeGreaterThanOrEqual(0) - ->and($retryAfter)->toBeLessThanOrEqual(1); - }); - }); - - describe('Integration Tests', function () { - it('handles complete flow from open breaker to denied request', function () { - $circuitBreaker = app(CircuitBreaker::class); - - // Record enough failures to open the breaker - for ($i = 0; $i < 5; $i++) { - $circuitBreaker->recordFailure('integration-breaker'); - } - - $middleware = new CheckCircuitBreakers; - $request = Request::create('/api/test', 'GET'); - - $next = function ($req) { - return response()->json(['status' => 'success']); - }; - - $response = $middleware->handle($request, $next, 'integration-breaker'); - - expect($response->getStatusCode())->toBe(503) - ->and($response->headers->get('X-Circuit-Breaker'))->toBe('integration-breaker') - ->and($response->headers->get('X-Circuit-Breaker-Status'))->toBe('open') - ->and($response->headers->has('Retry-After'))->toBeTrue(); - }); - }); -}); diff --git a/tests/Feature/ControlPointClassTest.php b/tests/Feature/ControlPointClassTest.php new file mode 100644 index 0000000..086d2da --- /dev/null +++ b/tests/Feature/ControlPointClassTest.php @@ -0,0 +1,111 @@ +set('monitor.domains.map', ['Workbench\\Monitor\\ControlPoints\\' => null]); +}); + +describe('class form', function (): void { + it('runs with constructor arguments and container-injected handle()', function (): void { + $result = ChargeCard::run(48211, 12900); + + expect($result)->toMatchArray(['amount' => 12900, 'settled' => true]); + }); + + it('returns an Outcome from attempt() with the attribute\'s name, profile and the instance context', function (): void { + $outcome = ChargeCard::attempt(48211, 12900); + + expect($outcome)->toBeInstanceOf(Outcome::class) + ->and($outcome->point)->toBe('payment.charge') + ->and($outcome->profile)->toBe('external') + ->and($outcome->origin)->toBe(ChargeCard::class) + ->and($outcome->domain)->toBe('Payments') + ->and($outcome->context)->toBe(['invoice' => 48211, 'amount' => 12900]) + ->and($outcome->status)->toBe(Status::Succeeded); + }); + + it('applies its risks, limits and escalation', function (): void { + StripeClient::$charge = fn () => throw new CardDeclined('insufficient_funds'); + expect(ChargeCard::run(1, 100))->toBe(['declined' => 'insufficient_funds']); + + StripeClient::$charge = fn (int $amount): array => ['amount' => $amount, 'settled' => false]; + $outcome = ChargeCard::attempt(1, 100); + expect($outcome->status)->toBe(Status::Escalated) + ->and($outcome->exception)->toBeInstanceOf(EnsureFailed::class) + ->and(PagePayments::$paged)->toHaveCount(1) + ->and(PagePayments::$paged[0]->point)->toBe('payment.charge'); + }); + + it('can be executed as an instance', function (): void { + expect((new ChargeCard(2, 300))->execute()->value['amount'])->toBe(300); + }); + + it('honours an explicit domain on the attribute', function (): void { + expect(SubmitFiling::attempt()->domain)->toBe('Courts'); + }); + + it('is recorded by the fake like any other point', function (): void { + Monitor::fake()->failing('payment.refund', new CardDeclined); + + RefundCard::run(); + + Monitor::assertRecovered('payment.refund', from: CardDeclined::class); + }); + + it('describes itself statically without a constructor', function (): void { + $described = ChargeCard::describe(); + + expect($described)->toMatchArray(['point' => 'payment.charge', 'origin' => ChargeCard::class, 'profile' => 'external', 'form' => 'class', 'notes' => [], 'escalation' => PagePayments::class, 'risks' => [CardDeclined::class]]) + ->and(array_column($described['policies'], 'type'))->toBe(['breaker', 'retry']) + ->and($described['policies'][0]['name'])->toBe('stripe') + ->and($described['limits'][1])->toBe(['type' => 'attempts', 'max' => 3]); + }); + + it('notes when control() cannot be read without the constructor', function (): void { + $described = NeedsConstructor::describe(); + + expect($described['notes'][0])->toStartWith('control() could not be read statically') + ->and(NeedsConstructor::run('stripe'))->toBeTrue(); + }); + + it('requires the attribute and a handle method', function (): void { + expect(fn (): mixed => NoAttribute::run())->toThrow(InvalidControlPoint::class, 'must carry a #[Point'); + expect(fn (): mixed => NoHandle::run())->toThrow(InvalidControlPoint::class, 'must define a public handle()'); + }); + + it('exposes the Control it builds', function (): void { + $control = (new ChargeCard(1, 1))->toControl(); + + expect($control)->toBeInstanceOf(Control::class)->and($control->name())->toBe('payment.charge')->and($control->context())->toBe(['invoice' => 1, 'amount' => 1]); + }); +}); diff --git a/tests/Feature/ControlTest.php b/tests/Feature/ControlTest.php new file mode 100644 index 0000000..ec6c5bb --- /dev/null +++ b/tests/Feature/ControlTest.php @@ -0,0 +1,348 @@ +run(fn (): string => 'charged'))->toBe('charged'); + }); + + it('attempt() returns a succeeded outcome with every field set', function (): void { + $outcome = Monitor::control('payment.charge', $this)->with(['invoice' => 7])->attempt(fn (): string => 'charged'); + + expect($outcome)->toBeInstanceOf(Outcome::class) + ->and($outcome->status)->toBe(Status::Succeeded) + ->and($outcome->succeeded())->toBeTrue() + ->and($outcome->hasValue())->toBeTrue() + ->and($outcome->value)->toBe('charged') + ->and($outcome->point)->toBe('payment.charge') + ->and($outcome->id)->toHaveLength(26) + ->and($outcome->parentId)->toBeNull() + ->and($outcome->traceId)->toMatch('/^[0-9a-f]{32}$/') + ->and($outcome->origin)->toBe(static::class) + ->and($outcome->attempts)->toBe(1) + ->and($outcome->durationMs)->toBeGreaterThanOrEqual(0.0) + ->and($outcome->exception)->toBeNull() + ->and($outcome->recoveredFrom)->toBeNull() + ->and($outcome->limitsBreached)->toBe([]) + ->and($outcome->context)->toBe(['invoice' => 7]) + ->and($outcome->stack)->toBe(['payment.charge']) + ->and($outcome->timeline[0]['event'])->toBe('started'); + }); + + it('run() throws what escalated', function (): void { + expect(fn () => Monitor::control('payment.charge')->run(fn () => throw new CardDeclined)) + ->toThrow(CardDeclined::class); + }); + + it('attempt() never throws for what the operation did', function (): void { + $outcome = Monitor::control('payment.charge')->attempt(fn () => throw new CardDeclined('lost_card')); + + expect($outcome->status)->toBe(Status::Escalated) + ->and($outcome->escalated())->toBeTrue() + ->and($outcome->hasValue())->toBeFalse() + ->and($outcome->value)->toBeNull() + ->and($outcome->exception)->toBeInstanceOf(CardDeclined::class) + ->and($outcome->toArray()['exception'])->toBe(['class' => CardDeclined::class, 'message' => 'Card declined: lost_card']); + }); + + it('rejects a name that does not match the pattern', function (string $name): void { + expect(fn () => Monitor::control($name))->toThrow(InvalidPointName::class); + })->with(['Payment', 'payment', 'payment.', 'payment.Charge', 'payment charge', '']); + + it('accepts dotted lowercase names with digits and underscores', function (): void { + expect(Monitor::control('court.e_filing.submit_v2')->name())->toBe('court.e_filing.submit_v2'); + }); +}); + +describe('risks and corrections', function (): void { + it('uses the correction return value as the result', function (): void { + $value = Monitor::control('payment.charge') + ->recover(CardDeclined::class, fn (CardDeclined $e): array => ['declined' => $e->declineCode]) + ->run(fn () => throw new CardDeclined('insufficient_funds')); + + expect($value)->toBe(['declined' => 'insufficient_funds']); + }); + + it('treats a null correction as a null result, not as unhandled', function (): void { + $outcome = Monitor::control('payment.charge') + ->recover(CardDeclined::class, fn (): null => null) + ->attempt(fn () => throw new CardDeclined); + + expect($outcome->status)->toBe(Status::Recovered) + ->and($outcome->recovered())->toBeTrue() + ->and($outcome->hasValue())->toBeTrue() + ->and($outcome->value)->toBeNull() + ->and($outcome->recoveredFrom)->toBe(CardDeclined::class) + ->and($outcome->exception)->toBeInstanceOf(CardDeclined::class); + }); + + it('treats false and zero corrections as values too', function (mixed $value): void { + expect(Monitor::control('payment.charge')->recover(CardDeclined::class, fn (): mixed => $value)->run(fn () => throw new CardDeclined)) + ->toBe($value); + })->with([false, 0, '']); + + it('matches the first declared risk that fits, in declaration order', function (): void { + $value = Monitor::control('payment.charge') + ->recover(RuntimeException::class, fn (): string => 'runtime') + ->recover(CardDeclined::class, fn (): string => 'declined') + ->run(fn () => throw new CardDeclined); + + expect($value)->toBe('runtime'); + }); + + it('hands the correction the exception and a provisional outcome', function (): void { + $seen = null; + + Monitor::control('payment.charge', $this)->with(['invoice' => 9]) + ->recover(CardDeclined::class, function (CardDeclined $e, Outcome $partial) use (&$seen): string { + $seen = $partial; + + return 'ok'; + }) + ->run(fn () => throw new CardDeclined); + + expect($seen)->toBeInstanceOf(Outcome::class) + ->and($seen->point)->toBe('payment.charge') + ->and($seen->context)->toBe(['invoice' => 9]) + ->and($seen->exception)->toBeInstanceOf(CardDeclined::class) + ->and($seen->attempts)->toBe(1); + }); + + it('escalates with the correction\'s own exception when the correction throws', function (): void { + $outcome = Monitor::control('payment.charge') + ->recover(CardDeclined::class, fn () => throw new DomainException('cannot recover')) + ->attempt(fn () => throw new CardDeclined); + + expect($outcome->status)->toBe(Status::Escalated) + ->and($outcome->exception)->toBeInstanceOf(DomainException::class) + ->and($outcome->recoveredFrom)->toBeNull(); + }); + + it('lets a correction rethrow to escalate the original', function (): void { + expect(fn () => Monitor::control('payment.charge') + ->recover(CardDeclined::class, fn (CardDeclined $e) => throw $e) + ->run(fn () => throw new CardDeclined)) + ->toThrow(CardDeclined::class); + }); + + it('supports Throwable as an explicit catch-all', function (): void { + $control = Monitor::control('payment.charge')->recover(Throwable::class, fn (): string => 'anything'); + + expect($control->hasCatchAll())->toBeTrue() + ->and($control->run(fn () => throw new Error('fatal')))->toBe('anything'); + }); + + it('rejects a risk that is not a throwable class', function (): void { + expect(fn () => Monitor::control('payment.charge')->recover('NotAClass', fn (): null => null)) + ->toThrow(InvalidControlPoint::class); + }); +}); + +describe('escalation', function (): void { + it('calls a closure escalation with the outcome and still propagates', function (): void { + $seen = null; + + $outcome = Monitor::control('payment.charge') + ->escalate(function (Outcome $o) use (&$seen): void { + $seen = $o; + }) + ->attempt(fn () => throw new CardDeclined); + + expect($seen)->toBe($outcome) + ->and($outcome->status)->toBe(Status::Escalated) + ->and($outcome->exception)->toBeInstanceOf(CardDeclined::class); + }); + + it('resolves an escalation class from the container', function (): void { + Monitor::control('payment.charge')->escalate(PagePayments::class)->attempt(fn () => throw new CardDeclined); + + expect(PagePayments::$received)->toBeInstanceOf(Outcome::class) + ->and(PagePayments::$received->point)->toBe('payment.charge'); + }); + + it('does not escalate a recovered failure', function (): void { + Monitor::control('payment.charge') + ->recover(CardDeclined::class, fn (): string => 'ok') + ->escalate(PagePayments::class) + ->run(fn () => throw new CardDeclined); + + expect(PagePayments::$received)->toBeNull(); + }); + + it('records a failing escalation and keeps the original exception', function (): void { + Event::fake([EscalationFailed::class]); + + expect(fn () => Monitor::control('payment.charge')->escalate(BrokenEscalation::class)->run(fn () => throw new CardDeclined)) + ->toThrow(CardDeclined::class); + + Event::assertDispatched(EscalationFailed::class, fn (EscalationFailed $e): bool => $e->exception instanceof LogicException && $e->outcome->point === 'payment.charge'); + }); + + it('rejects an escalation class that is not an Escalation', function (): void { + expect(fn () => Monitor::control('payment.charge')->escalate(stdClass::class)) + ->toThrow(InvalidControlPoint::class); + }); +}); + +describe('limits', function (): void { + it('fails the run with EnsureFailed when a post-condition does not hold', function (): void { + $outcome = Monitor::control('payment.charge') + ->ensure(fn (array $r): bool => $r['settled'], 'charge not settled') + ->attempt(fn (): array => ['settled' => false]); + + expect($outcome->status)->toBe(Status::Escalated) + ->and($outcome->exception)->toBeInstanceOf(EnsureFailed::class) + ->and($outcome->exception->reason)->toBe('charge not settled') + ->and($outcome->exception->value)->toBe(['settled' => false]); + }); + + it('lets a correction recover from EnsureFailed', function (): void { + $value = Monitor::control('payment.charge') + ->ensure(fn (array $r): bool => $r['settled']) + ->recover(EnsureFailed::class, fn (EnsureFailed $e): string => 'unsettled') + ->run(fn (): array => ['settled' => false]); + + expect($value)->toBe('unsettled'); + }); + + it('records a duration breach without failing the run', function (): void { + Event::fake([PointLimitBreached::class]); + + $outcome = Monitor::control('payment.charge')->within(0.001)->attempt(function (): string { + Sleep::usleep(3000); + + return 'slow but fine'; + }); + + expect($outcome->status)->toBe(Status::Succeeded) + ->and($outcome->value)->toBe('slow but fine') + ->and($outcome->breachedLimit('duration'))->toBeTrue() + ->and($outcome->limitsBreached['duration']['threshold'])->toBe(1.0) + ->and($outcome->limitsBreached['duration']['actual'])->toBeGreaterThan(1.0); + + Event::assertDispatched(PointLimitBreached::class, fn (PointLimitBreached $e): bool => $e->limit === 'duration' && $e->run->point === 'payment.charge'); + }); + + it('does not record a breach inside the limit', function (): void { + expect(Monitor::control('payment.charge')->within(10)->attempt(fn (): int => 1)->limitsBreached)->toBe([]); + }); +}); + +describe('events and context', function (): void { + it('dispatches started, recovered and ended in order with the same run id', function (): void { + Event::fake([PointStarted::class, PointRecovered::class, PointEscalated::class, PointEnded::class]); + + $outcome = Monitor::control('payment.charge') + ->recover(CardDeclined::class, fn (): string => 'ok') + ->attempt(fn () => throw new CardDeclined); + + Event::assertDispatched(PointStarted::class, fn (PointStarted $e): bool => $e->run->id === $outcome->id); + Event::assertDispatched(PointRecovered::class, fn (PointRecovered $e): bool => $e->outcome === $outcome); + Event::assertNotDispatched(PointEscalated::class); + Event::assertDispatched(PointEnded::class, fn (PointEnded $e): bool => $e->outcome === $outcome); + }); + + it('exposes the trace id and current point through Context while running, and clears them after', function (): void { + $during = null; + + Monitor::control('payment.charge')->run(function () use (&$during): void { + $during = [Context::get('trace_id'), Context::get('control_point')]; + }); + + expect($during[0])->toMatch('/^[0-9a-f]{32}$/') + ->and($during[1])->toBe('payment.charge') + ->and(Context::get('control_point'))->toBeNull() + ->and(Monitor::stack()->isInside())->toBeFalse(); + }); + + it('clears the stack even when the operation escalates', function (): void { + Monitor::control('payment.charge')->attempt(fn () => throw new CardDeclined); + + expect(Monitor::stack()->depth())->toBe(0); + }); +}); + +describe('profiles and description', function (): void { + it('applies a configured profile and lets the point override it', function (): void { + config()->set('monitor.profiles.external', [ + 'retry' => ['times' => 4, 'backoff_ms' => 10], + 'breaker' => ['after' => 3, 'within' => 30, 'for' => 60], + 'within' => 7, + ]); + + $control = Monitor::control('payment.charge')->profile('external')->retry(times: 1)->within(2); + $described = $control->describe(); + + expect($described['profile'])->toBe('external') + ->and($described['policies'])->toHaveCount(2) + ->and($described['policies'][0]['type'])->toBe('breaker') + ->and($described['policies'][0]['name'])->toBe('payment.charge') + ->and($described['policies'][1]['type'])->toBe('retry') + ->and($described['policies'][1]['times'])->toBe(1) + ->and($described['limits'][0])->toBe(['type' => 'within', 'seconds' => 2.0]); + }); + + it('rejects an unknown profile at declaration time', function (): void { + expect(fn () => Monitor::control('payment.charge')->profile('nope'))->toThrow(InvalidProfile::class); + }); + + it('describes the point for the inventory', function (): void { + $described = Monitor::control('payment.charge', $this) + ->transaction(retries: 1) + ->recover(CardDeclined::class, fn (): null => null) + ->escalate(PagePayments::class) + ->ensure(fn (): bool => true, 'settled') + ->attempts(3) + ->describe(); + + expect($described)->toMatchArray([ + 'point' => 'payment.charge', + 'origin' => static::class, + 'risks' => [CardDeclined::class], + 'catch_all' => false, + 'escalation' => PagePayments::class, + ]) + ->and($described['policies'][0]['type'])->toBe('transaction') + ->and($described['limits'])->toBe([['type' => 'attempts', 'max' => 3], ['type' => 'ensure', 'reason' => 'settled']]); + }); + + it('describes a closure escalation as such and defaults the origin', function (): void { + $described = Monitor::control('payment.charge')->escalate(fn (): null => null)->describe(); + + expect($described['escalation'])->toBe('closure') + ->and($described['origin'])->toBe(Control::class); + }); + + it('derives the domain from the origin namespace and allows an override', function (): void { + config()->set('monitor.domains.map', ['App\\Services\\' => null]); + + expect(Monitor::control('payment.charge', 'App\\Services\\Payments\\StripeCharger')->resolvedDomain())->toBe('Payments') + ->and(Monitor::control('payment.charge', 'App\\Services\\Payments\\StripeCharger')->domain('Billing')->resolvedDomain())->toBe('Billing'); + }); +}); diff --git a/tests/Feature/ControlledConfigurationTest.php b/tests/Feature/ControlledConfigurationTest.php deleted file mode 100644 index f6b7f1b..0000000 --- a/tests/Feature/ControlledConfigurationTest.php +++ /dev/null @@ -1,130 +0,0 @@ -byDefault(); - Log::shouldReceive('critical')->byDefault(); - Log::shouldReceive('warning')->byDefault(); - Log::shouldReceive('error')->byDefault(); -}); - -describe('Controlled Configuration Tests', function () { - describe('Exception Trace Configuration', function () { - it('returns null when exception trace is disabled', function () { - Config::set('monitor.exception_trace.enabled', false); - - $controlled = Controlled::for('trace-disabled-test') - ->catching([ - RuntimeException::class => function ($exception, $meta) { - // Check that exception details are null when tracing is disabled - expect($meta['exception'])->toBeNull(); - throw $exception; // Re-throw to maintain test expectation - }, - ]); - - expect(fn () => $controlled->run(function () { - throw new RuntimeException('Test exception'); - }))->toThrow(RuntimeException::class); - }); - - it('includes exception data when trace is enabled', function () { - Config::set('monitor.exception_trace.enabled', true); - Config::set('app.debug', true); - Config::set('monitor.exception_trace.full_on_debug', true); - - $exceptionData = null; - $controlled = Controlled::for('trace-enabled-test') - ->catching([ - RuntimeException::class => function ($exception, $meta) use (&$exceptionData) { - $exceptionData = $meta['exception']; - throw $exception; // Re-throw to maintain test expectation - }, - ]); - - expect(fn () => $controlled->run(function () { - throw new RuntimeException('Test exception with trace'); - }))->toThrow(RuntimeException::class); - - expect($exceptionData)->not->toBeNull() - ->and($exceptionData['class'])->toBe(RuntimeException::class) - ->and($exceptionData['message'])->toBe('Test exception with trace'); - }); - - it('handles trace configuration when debug mode is disabled', function () { - Config::set('monitor.exception_trace.enabled', true); - Config::set('app.debug', false); - Config::set('monitor.exception_trace.max_lines', 3); - - $exceptionData = null; - $controlled = Controlled::for('limited-trace-test') - ->catching([ - RuntimeException::class => function ($exception, $meta) use (&$exceptionData) { - $exceptionData = $meta['exception']; - throw $exception; // Re-throw to maintain test expectation - }, - ]); - - expect(fn () => $controlled->run(function () { - throw new RuntimeException('Test exception with limited trace'); - }))->toThrow(RuntimeException::class); - - expect($exceptionData)->not->toBeNull() - ->and($exceptionData['class'])->toBe(RuntimeException::class); - - // If trace exists, it should be limited - if (isset($exceptionData['trace'])) { - expect(count($exceptionData['trace']))->toBeLessThanOrEqual(3); - } - }); - }); - - describe('Circuit Breaker Without Name', function () { - it('executes normally when no circuit breaker is configured', function () { - $controlled = Controlled::for('no-breaker-execution-test'); - - $result = $controlled->run(function () { - return 'success without breaker'; - }); - - expect($result)->toBe('success without breaker'); - }); - - it('handles failure without circuit breaker name set', function () { - $controlled = Controlled::for('no-breaker-failure-test'); - - expect(fn () => $controlled->run(function () { - throw new RuntimeException('Failure without breaker'); - }))->toThrow(RuntimeException::class, 'Failure without breaker'); - }); - }); - - describe('Uncaught Exception Callback Exception Handling', function () { - it('logs uncaught exception callback errors without affecting main exception', function () { - // This tests the uncaught exception callback error handling - $controlled = Controlled::for('uncaught-exception-error-logging-test') - ->onUncaughtException(function ($exception, $meta) { - throw new RuntimeException('Uncaught exception handler failed'); - }) - ->catching([ - RuntimeException::class => function ($exception, $meta) { - // This should still be called despite uncaught exception handler failure - expect($exception)->toBeInstanceOf(RuntimeException::class); - throw $exception; // Re-throw to maintain test expectation - }, - ]); - - expect(fn () => $controlled->run(function () { - throw new RuntimeException('Original exception'); - }))->toThrow(RuntimeException::class, 'Original exception'); - }); - }); -}); diff --git a/tests/Feature/ControlledFeatureTest.php b/tests/Feature/ControlledFeatureTest.php deleted file mode 100644 index 08f8a0a..0000000 --- a/tests/Feature/ControlledFeatureTest.php +++ /dev/null @@ -1,354 +0,0 @@ -setupLogMocking(); - Cache::flush(); - - // Allow logging calls that the Controlled class makes - Log::shouldReceive('info')->byDefault(); - Log::shouldReceive('critical')->byDefault(); - Log::shouldReceive('warning')->byDefault(); - Log::shouldReceive('error')->byDefault(); -}); - -describe('Controlled Framework Integration', function () { - describe('Basic Execution with Framework', function () { - it('executes callback and returns result', function () { - $result = Controlled::for('test-operation') - ->run(fn () => 'success'); - - expect($result)->toBe('success'); - }); - - it('executes callback with different return types', function () { - expect(Controlled::for('string-test')->run(fn () => 'string'))->toBe('string') - ->and(Controlled::for('int-test')->run(fn () => 42))->toBe(42) - ->and(Controlled::for('array-test')->run(fn () => ['key' => 'value']))->toBe(['key' => 'value']) - ->and(Controlled::for('null-test')->run(fn () => null))->toBeNull() - ->and(Controlled::for('bool-test')->run(fn () => true))->toBeTrue(); - }); - - it('preserves exceptions from callback', function () { - expect(fn () => Controlled::for('failing-operation') - ->run(function () { - throw new RuntimeException('Operation failed'); - })) - ->toThrow(RuntimeException::class, 'Operation failed'); - }); - }); - - describe('Context Integration', function () { - it('executes with context successfully', function () { - $context = ['user_id' => 123, 'action' => 'test']; - - $result = Controlled::for('context-test') - ->overrideContext($context) - ->run(fn () => 'context-success'); - - expect($result)->toBe('context-success'); - }); - - it('merges context and executes', function () { - $result = Controlled::for('merge-context-test') - ->overrideContext(['initial' => 'value']) - ->addContext(['additional' => 'data']) - ->run(fn () => 'merge-success'); - - expect($result)->toBe('merge-success'); - }); - }); - - describe('Failure Handling Integration', function () { - it('calls exception handler when operation fails', function () { - $handlerCalled = false; - $capturedMeta = null; - - // With the new design, caught exceptions don't re-throw unless handler explicitly does so - $result = Controlled::for('fail-callback-test') - ->catching([ - RuntimeException::class => function ($exception, $meta) use (&$handlerCalled, &$capturedMeta) { - $handlerCalled = true; - $capturedMeta = $meta; - - return 'handled'; // Return recovery value - }, - ]) - ->run(function () { - throw new RuntimeException('Test failure'); - }); - - expect($handlerCalled)->toBeTrue() - ->and($capturedMeta)->toBeArray() - ->and($capturedMeta['controlled_block'])->toBe('fail-callback-test') - ->and($result)->toBe('handled'); // Should return the recovery value - }); - - it('calls uncaught exception callback when operation fails with uncaught exception', function () { - $uncaughtCalled = false; - $capturedMeta = null; - - expect(function () use (&$uncaughtCalled, &$capturedMeta) { - Controlled::for('uncaught-exception-test') - ->onUncaughtException(function ($exception, $meta) use (&$uncaughtCalled, &$capturedMeta) { - $uncaughtCalled = true; - $capturedMeta = $meta; - }) - ->run(function () { - throw new RuntimeException('Test uncaught exception'); - }); - })->toThrow(RuntimeException::class, 'Test uncaught exception'); - - expect($uncaughtCalled)->toBeTrue() - ->and($capturedMeta)->toBeArray() - ->and($capturedMeta['controlled_block'])->toBe('uncaught-exception-test'); - }); - - it('continues execution if exception handler throws', function () { - expect(function () { - Controlled::for('callback-error-test') - ->catching([ - RuntimeException::class => function () { - throw new RuntimeException('Handler error'); - }, - ]) - ->run(function () { - throw new RuntimeException('Original error'); - }); - })->toThrow(RuntimeException::class, 'Original error'); - }); - - it('handles exception when handler returns null', function () { - $handlerCalled = false; - $capturedMeta = null; - - // Test the case where exception handler returns null (or doesn't explicitly return) - // This should trigger line 297: return true; // Indicate exception was handled - $result = Controlled::for('handler-returns-null-test') - ->catching([ - RuntimeException::class => function ($exception, $meta) use (&$handlerCalled, &$capturedMeta) { - $handlerCalled = true; - $capturedMeta = $meta; - - // Explicitly return null to test line 297 - return null; - }, - ]) - ->run(function () { - throw new RuntimeException('Test exception for null handler'); - }); - - expect($handlerCalled)->toBeTrue() - ->and($capturedMeta)->toBeArray() - ->and($capturedMeta['controlled_block'])->toBe('handler-returns-null-test') - ->and($result)->toBeTrue(); // Should return true when handler returns null - }); - - it('handles exception when handler has no explicit return', function () { - $handlerCalled = false; - $capturedMeta = null; - - // Test the case where exception handler doesn't explicitly return anything (implicit null) - // This should also trigger line 297: return true; // Indicate exception was handled - $result = Controlled::for('handler-no-return-test') - ->catching([ - RuntimeException::class => function ($exception, $meta) use (&$handlerCalled, &$capturedMeta) { - $handlerCalled = true; - $capturedMeta = $meta; - // No explicit return statement (implicit null) - }, - ]) - ->run(function () { - throw new RuntimeException('Test exception for no-return handler'); - }); - - expect($handlerCalled)->toBeTrue() - ->and($capturedMeta)->toBeArray() - ->and($capturedMeta['controlled_block'])->toBe('handler-no-return-test') - ->and($result)->toBeTrue(); // Should return true when handler has no explicit return - }); - }); - - describe('Circuit Breaker Integration', function () { - beforeEach(function () { - app()->instance(CircuitBreaker::class, new CircuitBreaker); - }); - - it('configures circuit breaker with defaults', function () { - $result = Controlled::for('breaker-test') - ->withCircuitBreaker('test-breaker') - ->run(fn () => 'success'); - - expect($result)->toBe('success'); - }); - - it('configures circuit breaker with custom threshold and decay', function () { - $result = Controlled::for('custom-breaker-test') - ->withCircuitBreaker('custom-breaker', 10, 600) - ->run(fn () => 'success'); - - expect($result)->toBe('success'); - }); - - it('opens circuit breaker after threshold failures', function () { - $breakerName = 'threshold-test-breaker'; - - // Simulate failures to reach threshold - for ($i = 0; $i < 5; $i++) { - try { - Controlled::for("failure-{$i}") - ->withCircuitBreaker($breakerName) - ->run(function () { - throw new RuntimeException('Simulated failure'); - }); - } catch (RuntimeException $e) { - // Expected failures - } - } - - // Next call should be blocked by circuit breaker - expect(function () use ($breakerName) { - Controlled::for('blocked-test') - ->withCircuitBreaker($breakerName) - ->run(fn () => 'should-not-execute'); - })->toThrow(RuntimeException::class, "Circuit breaker '{$breakerName}' is open"); - }); - - it('resets circuit breaker on successful execution', function () { - $breakerName = 'reset-test-breaker'; - - // Force breaker open - app(CircuitBreaker::class)->forceOpen($breakerName); - - // Should be open initially - expect(app(CircuitBreaker::class)->isOpen($breakerName))->toBeTrue(); - - // Reset the breaker manually to test reset functionality - app(CircuitBreaker::class)->reset($breakerName); - - // Now execution should succeed and keep breaker closed - $result = Controlled::for('reset-success-test') - ->withCircuitBreaker($breakerName) - ->run(fn () => 'success'); - - expect($result)->toBe('success') - ->and(app(CircuitBreaker::class)->isOpen($breakerName))->toBeFalse(); - }); - }); - - describe('Transaction Support Integration', function () { - it('executes with transaction wrapper', function () { - DB::shouldReceive('transaction') - ->once() - ->with(Mockery::type(Closure::class)) - ->andReturnUsing(function (Closure $callback) { - return $callback(); - }); - - $result = Controlled::for('transaction-test') - ->withDatabaseTransaction() - ->run(fn () => 'transaction-success'); - - expect($result)->toBe('transaction-success'); - }); - - it('retries transaction on failure', function () { - $attempts = 0; - - DB::shouldReceive('transaction') - ->times(3) // Initial attempt + 2 retries - ->with(Mockery::type(Closure::class)) - ->andReturnUsing(function (Closure $callback) use (&$attempts) { - $attempts++; - if ($attempts < 3) { - throw new RuntimeException("Attempt {$attempts} failed"); - } - - return $callback(); - }); - - $result = Controlled::for('retry-test') - ->withDatabaseTransaction(2) // 2 retries - ->run(fn () => 'retry-success'); - - expect($result)->toBe('retry-success') - ->and($attempts)->toBe(3); - }); - }); - - describe('Trace Management Integration', function () { - it('overrides trace ID when specified', function () { - $customTraceId = 'custom-trace-123'; - - $result = Controlled::for('trace-override-test') - ->overrideTraceId($customTraceId) - ->run(fn () => 'trace-success'); - - expect($result)->toBe('trace-success'); - }); - - it('uses default trace when no override specified', function () { - $result = Controlled::for('default-trace-test') - ->run(fn () => 'default-success'); - - expect($result)->toBe('default-success'); - }); - }); - - describe('Custom Logger Integration', function () { - it('uses custom logger when specified', function () { - $result = Controlled::for('custom-logger-test', 'CustomOrigin') - ->run(fn () => 'custom-logger-success'); - - expect($result)->toBe('custom-logger-success'); - }); - }); - - describe('Nested Block Prevention Integration', function () { - it('prevents nested controlled blocks', function () { - expect(function () { - Controlled::for('outer-block') - ->run(function () { - Controlled::for('inner-block') - ->run(fn () => 'should-not-reach'); - }); - })->toThrow(NestedControlledBlockException::class); - }); - - it('allows sequential controlled blocks', function () { - $result1 = Controlled::for('first-block') - ->run(fn () => 'first-success'); - - $result2 = Controlled::for('second-block') - ->run(fn () => 'second-success'); - - expect($result1)->toBe('first-success') - ->and($result2)->toBe('second-success'); - }); - }); - - describe('Full Integration Chaining', function () { - it('supports fluent interface chaining with execution', function () { - $result = Controlled::for('chain-test', 'ChainTest') - ->overrideContext(['initial' => 'data']) - ->addContext(['additional' => 'context']) - ->overrideTraceId('custom-trace') - ->run(fn () => 'chain-success'); - - expect($result)->toBe('chain-success'); - }); - }); -}); diff --git a/tests/Feature/ControlledIntegrationTest.php b/tests/Feature/ControlledIntegrationTest.php deleted file mode 100644 index a10be00..0000000 --- a/tests/Feature/ControlledIntegrationTest.php +++ /dev/null @@ -1,247 +0,0 @@ -run(fn () => 'facade-success'); - - expect($result)->toBe('facade-success'); - }); - - it('creates controlled block with name parameter through facade', function () { - $result = Monitor::controlled('named-test') - ->run(fn () => 'named-success'); - - expect($result)->toBe('named-success'); - }); - - it('creates controlled block with immediate name through facade', function () { - $controlled = Monitor::controlled('immediate-name'); - - expect($controlled)->toBeInstanceOf(\Kirschbaum\Monitor\Controlled::class); - - $result = $controlled->run(fn () => 'immediate-success'); - expect($result)->toBe('immediate-success'); - }); - - it('integrates with circuit breaker through facade', function () { - app()->instance(CircuitBreaker::class, new CircuitBreaker); - - $result = Monitor::controlled('breaker-integration') - ->withCircuitBreaker('test-integration-breaker') - ->run(fn () => 'breaker-success'); - - expect($result)->toBe('breaker-success'); - }); - - it('integrates with transactions through facade', function () { - DB::shouldReceive('transaction') - ->once() - ->with(Mockery::type(\Closure::class)) - ->andReturnUsing(function (\Closure $callback) { - return $callback(); - }); - - $result = Monitor::controlled('transaction-integration') - ->withDatabaseTransaction() - ->run(fn () => 'transaction-success'); - - expect($result)->toBe('transaction-success'); - }); - - it('chains multiple configuration methods through facade', function () { - $result = Monitor::controlled('full-chain-test', 'IntegrationTest') - ->overrideContext(['feature' => 'integration']) - ->addContext(['test' => 'comprehensive']) - ->run(fn () => 'full-chain-success'); - - expect($result)->toBe('full-chain-success'); - }); - - it('handles failures with callbacks through facade', function () { - $failureCalled = false; - $uncaughtCalled = false; - - expect(function () use (&$failureCalled, &$uncaughtCalled) { - Monitor::controlled('integration-failure-test') - ->catching([ - \InvalidArgumentException::class => function () use (&$failureCalled) { - $failureCalled = true; // This should not be called - }, - ]) - ->onUncaughtException(function ($exception, $meta) use (&$uncaughtCalled) { - $uncaughtCalled = true; // This should be called for uncaught exceptions - }) - ->run(function () { - throw new RuntimeException('Integration test failure'); // RuntimeException not caught - }); - })->toThrow(RuntimeException::class, 'Integration test failure'); - - expect($failureCalled)->toBeFalse() // Handler not called since exception type doesn't match - ->and($uncaughtCalled)->toBeTrue(); // onUncaughtException called for uncaught exception - }); - - it('preserves trace context through facade', function () { - // Start a trace - $trace = Monitor::trace()->start(); - - $result = Monitor::controlled('trace-context-test') - ->run(fn () => 'trace-preserved'); - - expect($result)->toBe('trace-preserved'); - - // Just verify trace is still accessible - expect(Monitor::trace())->toBeInstanceOf(\Kirschbaum\Monitor\Trace::class); - }); - - it('overrides trace ID through facade', function () { - $customTraceId = 'custom-integration-trace-123'; - - $result = Monitor::controlled('trace-override-integration') - ->overrideTraceId($customTraceId) - ->run(fn () => 'trace-override-success'); - - expect($result)->toBe('trace-override-success'); - }); - - it('works with custom logger through facade', function () { - $result = Monitor::controlled('custom-logger-integration', 'CustomIntegrationOrigin') - ->run(fn () => 'custom-logger-success'); - - expect($result)->toBe('custom-logger-success'); - }); - -}); - -describe('Monitor with Origin Integration', function () { - it('creates controlled block from origin instance', function () { - $originInstance = new class - { - public function __toString(): string - { - return 'TestOrigin'; - } - }; - - $result = Monitor::controlled('origin-test', $originInstance) - ->run(fn () => 'origin-success'); - - expect($result)->toBe('origin-success'); - }); - - it('preserves origin through controlled block chain', function () { - $result = Monitor::controlled('string-origin-test', 'StringOrigin') - ->overrideContext(['test' => 'origin-preservation']) - ->run(fn () => 'origin-chain-success'); - - expect($result)->toBe('origin-chain-success'); - }); -}); - -describe('Real-world Integration Scenarios', function () { - it('simulates payment processing with comprehensive monitoring', function () { - $paymentSuccessful = false; - - $result = Monitor::controlled('payment_processing') - ->overrideContext([ - 'user_id' => 123, - 'amount' => 99.99, - 'currency' => 'USD', - ]) - - ->catching([ - \Throwable::class => function ($exception, $meta) { - // Log to financial audit system - // In real implementation would call external service - }, - ]) - ->onUncaughtException(function ($exception, $meta) { - // Alert financial operations team - // In real implementation would trigger alerts - }) - ->run(function () use (&$paymentSuccessful) { - // Simulate payment processing - $paymentSuccessful = true; - - return ['transaction_id' => 'txn_123', 'status' => 'completed']; - }); - - expect($result)->toBe(['transaction_id' => 'txn_123', 'status' => 'completed']) - ->and($paymentSuccessful)->toBeTrue(); - }); - - it('simulates database operation with retry and circuit breaker', function () { - app()->instance(CircuitBreaker::class, new CircuitBreaker); - - DB::shouldReceive('transaction') - ->twice() // First attempt fails, second succeeds - ->with(Mockery::type(\Closure::class)) - ->andReturnUsing(function (\Closure $callback) { - static $attempts = 0; - $attempts++; - - if ($attempts === 1) { - throw new RuntimeException('Database temporarily unavailable'); - } - - return $callback(); - }); - - $result = Monitor::controlled('database_critical_write') - ->overrideContext(['operation' => 'user_signup', 'table' => 'users']) - - ->withCircuitBreaker('database_write', 3, 60) - ->withDatabaseTransaction(1) // 1 retry - ->catching([ - \RuntimeException::class => function ($exception, $meta) { - // Log database issues for monitoring - }, - ]) - ->run(function () { - return ['id' => 456, 'status' => 'created']; - }); - - expect($result)->toBe(['id' => 456, 'status' => 'created']); - }); - - it('simulates external API call with full monitoring stack', function () { - $apiCallCount = 0; - - $result = Monitor::controlled('external_api_integration') - ->overrideContext([ - 'service' => 'stripe', - 'endpoint' => '/charges', - 'method' => 'POST', - ]) - - ->overrideTraceId('api-call-trace-789') - ->catching([ - \Exception::class => function ($exception, $meta) use (&$apiCallCount) { - // Increment failure metrics - $apiCallCount++; - }, - ]) - ->run(function () { - // Simulate successful API call - return ['charge_id' => 'ch_123', 'amount' => 2000, 'status' => 'succeeded']; - }); - - expect($result)->toBe(['charge_id' => 'ch_123', 'amount' => 2000, 'status' => 'succeeded']) - ->and($apiCallCount)->toBe(0); // No failures - }); -}); diff --git a/tests/Feature/ControlledTransactionRetryTest.php b/tests/Feature/ControlledTransactionRetryTest.php deleted file mode 100644 index ec98ed6..0000000 --- a/tests/Feature/ControlledTransactionRetryTest.php +++ /dev/null @@ -1,387 +0,0 @@ -byDefault(); - Log::shouldReceive('critical')->byDefault(); - Log::shouldReceive('warning')->byDefault(); - Log::shouldReceive('error')->byDefault(); - Log::shouldReceive('debug')->byDefault(); - - // Reset circuit breaker state - app()->instance(CircuitBreaker::class, new CircuitBreaker); -}); - -describe('Controlled Transaction Retry and Circuit Breaker Edge Cases', function () { - describe('Transaction Retry Edge Cases', function () { - it('throws RuntimeException when transaction retries are exhausted without success', function () { - // Mock DB::transaction to always fail - DB::shouldReceive('transaction') - ->times(4) // Initial attempt + 3 retries - ->with(Mockery::type(Closure::class)) - ->andThrow(new RuntimeException('Database connection failed')); - - expect(function () { - Controlled::for('exhausted-retries-test') - ->withDatabaseTransaction(3) // 3 retries - ->run(function () { - return 'should-not-reach'; - }); - })->toThrow(RuntimeException::class, 'Database connection failed'); - }); - - it('executes retry logic and succeeds on second attempt', function () { - // This test covers the retry logic including the warning log path (line 252) - $attempts = 0; - DB::shouldReceive('transaction') - ->times(2) - ->with(Mockery::type(Closure::class)) - ->andReturnUsing(function (Closure $callback) use (&$attempts) { - $attempts++; - if ($attempts === 1) { - throw new RuntimeException('First attempt failed'); - } - - return $callback(); - }); - - $result = Controlled::for('retry-logic-test') - ->withDatabaseTransaction(2) // 2 retries - ->run(function () { - return 'retry-success'; - }); - - expect($result)->toBe('retry-success') - ->and($attempts)->toBe(2); - }); - - it('respects shouldRetryException logic with retryOnlyExceptions', function () { - DB::shouldReceive('transaction') - ->once() - ->with(Mockery::type(Closure::class)) - ->andThrow(new InvalidArgumentException('Not retryable')); - - // Should NOT retry InvalidArgumentException when only RuntimeException is allowed - expect(function () { - Controlled::for('only-exceptions-test') - ->withDatabaseTransaction(2, [RuntimeException::class], []) - ->run(function () { - return 'should-not-reach'; - }); - })->toThrow(InvalidArgumentException::class, 'Not retryable'); - }); - - it('respects shouldRetryException logic with retryExcludeExceptions', function () { - DB::shouldReceive('transaction') - ->once() - ->with(Mockery::type(Closure::class)) - ->andThrow(new InvalidArgumentException('Excluded exception')); - - // Should NOT retry InvalidArgumentException when it's excluded - expect(function () { - Controlled::for('exclude-exceptions-test') - ->withDatabaseTransaction(2, [], [InvalidArgumentException::class]) - ->run(function () { - return 'should-not-reach'; - }); - })->toThrow(InvalidArgumentException::class, 'Excluded exception'); - }); - - it('retries when exception is in retryOnlyExceptions list', function () { - $attempts = 0; - DB::shouldReceive('transaction') - ->times(2) - ->with(Mockery::type(Closure::class)) - ->andReturnUsing(function (Closure $callback) use (&$attempts) { - $attempts++; - if ($attempts === 1) { - throw new RuntimeException('Allowed retryable exception'); - } - - return $callback(); - }); - - $result = Controlled::for('allowed-exception-retry-test') - ->withDatabaseTransaction(1, [RuntimeException::class], []) - ->run(function () { - return 'retry-success'; - }); - - expect($result)->toBe('retry-success') - ->and($attempts)->toBe(2); - }); - - it('handles complex retry logic with multiple exception types', function () { - DB::shouldReceive('transaction') - ->once() - ->with(Mockery::type(Closure::class)) - ->andThrow(new \LogicException('Logic error')); // Not in only list - - expect(function () { - Controlled::for('complex-retry-test') - ->withDatabaseTransaction(2, [RuntimeException::class, InvalidArgumentException::class], []) - ->run(function () { - return 'should-not-reach'; - }); - })->toThrow(\LogicException::class, 'Logic error'); - }); - }); - - describe('Circuit Breaker Null Checks', function () { - it('executes normally when no circuit breaker name is set', function () { - // Test line 324: if (! $this->breakerName) return false; - $result = Controlled::for('no-breaker-name-test') - ->run(function () { - return 'success-without-breaker'; - }); - - expect($result)->toBe('success-without-breaker'); - }); - - it('handles failure without circuit breaker name for recordFailure', function () { - // Test line 337: if (! $this->breakerName) return; - expect(function () { - Controlled::for('no-breaker-failure-test') - ->run(function () { - throw new RuntimeException('Failure without breaker name'); - }); - })->toThrow(RuntimeException::class, 'Failure without breaker name'); - }); - - it('handles success without circuit breaker name for resetCircuitBreaker', function () { - // Test line 349: if (! $this->breakerName) return; - $result = Controlled::for('no-breaker-success-test') - ->run(function () { - return 'success-no-breaker-reset'; - }); - - expect($result)->toBe('success-no-breaker-reset'); - }); - }); - - describe('shouldRetryException Edge Cases', function () { - it('returns false when exception not in retryOnlyExceptions list', function () { - // Test the specific logic in lines 359-366 where we check retryOnlyExceptions - // and lines 372-373 where we return false if no match found - - $attempts = 0; - DB::shouldReceive('transaction') - ->once() - ->with(Mockery::type(Closure::class)) - ->andReturnUsing(function () use (&$attempts) { - $attempts++; - throw new \DomainException('Domain error not in allowed list'); - }); - - expect(function () { - Controlled::for('not-in-only-list-test') - ->withDatabaseTransaction(2, [RuntimeException::class, InvalidArgumentException::class], []) - ->run(function () { - return 'should-not-reach'; - }); - })->toThrow(\DomainException::class, 'Domain error not in allowed list'); - - // Should only attempt once since exception is not in the allowed list - expect($attempts)->toBe(1); - }); - - it('tests the break logic in retryOnlyExceptions loop', function () { - // This tests the break statement in the foreach loop (around line 362) - $attempts = 0; - DB::shouldReceive('transaction') - ->times(2) - ->with(Mockery::type(Closure::class)) - ->andReturnUsing(function (Closure $callback) use (&$attempts) { - $attempts++; - if ($attempts === 1) { - // First exception is RuntimeException (which is in the allowed list) - throw new RuntimeException('Runtime error - should retry'); - } - - return $callback(); - }); - - $result = Controlled::for('break-logic-test') - ->withDatabaseTransaction(1, [RuntimeException::class, InvalidArgumentException::class], []) - ->run(function () { - return 'break-logic-success'; - }); - - expect($result)->toBe('break-logic-success') - ->and($attempts)->toBe(2); - }); - - it('returns false when exception is in retryExcludeExceptions', function () { - $attempts = 0; - DB::shouldReceive('transaction') - ->once() - ->with(Mockery::type(Closure::class)) - ->andReturnUsing(function () use (&$attempts) { - $attempts++; - throw new InvalidArgumentException('Excluded exception type'); - }); - - expect(function () { - Controlled::for('excluded-exception-test') - ->withDatabaseTransaction(2, [], [InvalidArgumentException::class]) - ->run(function () { - return 'should-not-reach'; - }); - })->toThrow(InvalidArgumentException::class, 'Excluded exception type'); - - expect($attempts)->toBe(1); - }); - - it('handles inheritance in exception checking', function () { - // Test that exception inheritance works in the instanceof checks - class CustomRuntimeException extends RuntimeException {} - - $attempts = 0; - DB::shouldReceive('transaction') - ->times(2) - ->with(Mockery::type(Closure::class)) - ->andReturnUsing(function (Closure $callback) use (&$attempts) { - $attempts++; - if ($attempts === 1) { - throw new CustomRuntimeException('Custom runtime exception'); - } - - return $callback(); - }); - - $result = Controlled::for('inheritance-test') - ->withDatabaseTransaction(1, [RuntimeException::class], []) - ->run(function () { - return 'inheritance-success'; - }); - - expect($result)->toBe('inheritance-success') - ->and($attempts)->toBe(2); - }); - }); - - describe('Transaction Fallback Error Handling', function () { - it('verifies transaction retry exhaustion behavior', function () { - // Line 252 (the fallback RuntimeException) is theoretically unreachable in normal execution - // because the while loop logic ensures we either return or throw before exiting naturally. - // This test verifies the normal behavior when retries are exhausted. - - DB::shouldReceive('transaction') - ->times(3) // Initial + 2 retries = 3 attempts total - ->with(Mockery::type(Closure::class)) - ->andThrow(new RuntimeException('Persistent failure')); - - expect(function () { - Controlled::for('retry-exhaustion-test') - ->withDatabaseTransaction(2) // 2 retries - ->run(function () { - return 'should-not-reach'; - }); - })->toThrow(RuntimeException::class, 'Persistent failure'); - }); - }); - - describe('Complex Retry Scenarios', function () { - it('handles mixed exception types in complex retry scenarios', function () { - $exceptionSequence = [ - new RuntimeException('Runtime error - retryable'), - new InvalidArgumentException('Invalid arg - not retryable for this config'), - ]; - - $attempts = 0; - DB::shouldReceive('transaction') - ->times(2) - ->with(Mockery::type(Closure::class)) - ->andReturnUsing(function () use (&$attempts, $exceptionSequence) { - $exception = $exceptionSequence[$attempts] ?? $exceptionSequence[count($exceptionSequence) - 1]; - $attempts++; - throw $exception; - }); - - // Should retry RuntimeException but not InvalidArgumentException - expect(function () { - Controlled::for('mixed-exception-test') - ->withDatabaseTransaction(2, [RuntimeException::class], []) - ->run(function () { - return 'should-not-reach'; - }); - })->toThrow(InvalidArgumentException::class, 'Invalid arg - not retryable for this config'); - - expect($attempts)->toBe(2); - }); - - it('forces retry warning log by testing specific retry path', function () { - // This test specifically targets line 252 by ensuring the warning log gets called - $attempts = 0; - DB::shouldReceive('transaction') - ->times(3) // Initial + 2 retries - ->with(Mockery::type(Closure::class)) - ->andReturnUsing(function (Closure $callback) use (&$attempts) { - $attempts++; - if ($attempts <= 2) { - throw new RuntimeException("Attempt {$attempts} failed"); - } - - return $callback(); - }); - - // Allow any warning calls to pass through - Log::shouldReceive('warning')->byDefault(); - - $result = Controlled::for('force-retry-warning-test') - ->withDatabaseTransaction(2) // 2 retries = 3 total attempts - ->run(function () { - return 'final-success'; - }); - - expect($result)->toBe('final-success') - ->and($attempts)->toBe(3); - }); - - it('throws the last exception instead of generic RuntimeException', function () { - // This test verifies our improved retry logic throws the actual last exception - // rather than a generic RuntimeException - $attempts = 0; - DB::shouldReceive('transaction') - ->times(3) // Initial + 2 retries = 3 attempts total - ->with(Mockery::type(Closure::class)) - ->andReturnUsing(function () use (&$attempts) { - $attempts++; - if ($attempts === 1) { - throw new InvalidArgumentException('First attempt failed'); - } elseif ($attempts === 2) { - throw new RuntimeException('Second attempt failed'); - } else { - throw new DomainException('Third and final attempt failed'); - } - }); - - // Allow any warning calls to pass through - Log::shouldReceive('warning')->byDefault(); - - expect(function () { - Controlled::for('last-exception-test') - ->withDatabaseTransaction(2) // 2 retries = 3 total attempts - ->run(function () { - return 'should-not-reach'; - }); - })->toThrow(DomainException::class, 'Third and final attempt failed'); - - expect($attempts)->toBe(3); - }); - }); -}); diff --git a/tests/Feature/CorrectionsAndEscalationTest.php b/tests/Feature/CorrectionsAndEscalationTest.php new file mode 100644 index 0000000..1819c99 --- /dev/null +++ b/tests/Feature/CorrectionsAndEscalationTest.php @@ -0,0 +1,95 @@ + $exception instanceof CardDeclined ? $exception->declineCode : 'unknown', 'app' => $this->config->get('app.name'), 'point' => $outcome->point]; + } +} + +beforeEach(fn (): array => PagePayments::$paged = []); + +describe('corrections as classes', function (): void { + it('resolves a Correction class from the container and names it in the description', function (): void { + $control = Monitor::control('payment.charge')->recover(CardDeclined::class, DeclineHandler::class); + + expect($control->run(fn () => throw new CardDeclined('stolen')))->toMatchArray(['declined' => 'stolen', 'app' => 'Laravel', 'point' => 'payment.charge']) + ->and($control->describe()['corrections'])->toBe([DeclineHandler::class]) + ->and(Monitor::control('a.b')->recover(CardDeclined::class, fn (): null => null)->describe()['corrections'])->toBe(['closure']); + }); + + it('rejects a class that is not a Correction', function (): void { + expect(fn () => Monitor::control('payment.charge')->recover(CardDeclined::class, stdClass::class))->toThrow(InvalidControlPoint::class, 'Correction class'); + }); +}); + +describe('escalation on limits', function (): void { + it('escalates a run that succeeded but breached a limit when asked', function (): void { + $control = fn () => Monitor::control('payment.charge')->within(0.0001)->escalate(PagePayments::class); + + $control()->run(fn (): null => usleep(500)); + expect(PagePayments::$paged)->toBe([]); + + $outcome = $control()->escalateLimits()->attempt(fn (): null => usleep(500)); + expect($outcome->succeeded())->toBeTrue() + ->and(PagePayments::$paged)->toHaveCount(1) + ->and(PagePayments::$paged[0]->breachedLimit('duration'))->toBeTrue() + ->and($control()->escalateLimits()->describe()['escalate_limits'])->toBeTrue(); + }); +}); + +describe('throttled escalation', function (): void { + it('escalates at most once per window per point', function (): void { + LogFake::bind(); + $control = fn () => Monitor::control('payment.charge')->escalate(PagePayments::class)->throttleEscalation(600); + + $control()->attempt(fn () => throw new CardDeclined); + $control()->attempt(fn () => throw new CardDeclined); + Monitor::control('payment.refund')->escalate(PagePayments::class)->throttleEscalation(600)->attempt(fn () => throw new CardDeclined); + + expect(PagePayments::$paged)->toHaveCount(2) + ->and(array_map(fn (Outcome $o): string => $o->point, PagePayments::$paged))->toBe(['payment.charge', 'payment.refund']); + Log::assertLoggedTimes(fn ($log): bool => ($log->context['event'] ?? null) === 'escalation.throttled' && $log->level === 'notice' && $log->context['throttle_seconds'] === 600 && str_contains($log->message, 'escalation skipped'), 1); + + $this->travel(601)->seconds(); + $control()->attempt(fn () => throw new CardDeclined); + + expect(PagePayments::$paged)->toHaveCount(3) + ->and($control()->describe()['escalation_throttle'])->toBe(600); + }); +}); + +describe('origin-less logging and timestamps', function (): void { + it('binds Monitor::log() to the running point and falls back outside one', function (): void { + $inside = null; + + Monitor::control('payment.charge', 'App\\Services\\Payments\\Charger')->run(function () use (&$inside): void { + $inside = Monitor::log()->prefix(); + }); + + expect($inside)->toBe('[Payments:Charger]') + ->and(Monitor::log()->origin())->toBe(Kirschbaum\Monitor\Monitor::class); + }); + + it('records when a run started and ended', function (): void { + $outcome = Monitor::control('payment.charge')->attempt(fn (): null => usleep(1000)); + + expect($outcome->startedAt->lessThanOrEqualTo($outcome->endedAt))->toBeTrue() + ->and($outcome->toArray())->toHaveKeys(['started_at', 'ended_at']); + }); +}); diff --git a/tests/Feature/EdgesTest.php b/tests/Feature/EdgesTest.php new file mode 100644 index 0000000..7dbe91f --- /dev/null +++ b/tests/Feature/EdgesTest.php @@ -0,0 +1,175 @@ +from('App\\Services\\Payments\\X'); + + expect($control->origin())->toBe('App\\Services\\Payments\\X') + ->and($control->hasEscalation())->toBeFalse() + ->and($control->escalate(fn (): null => null)->hasEscalation())->toBeTrue() + ->and(Breaker::named('stripe')->name())->toBe('stripe'); + }); + + it('caps the failure window even while the circuit is open', function (): void { + $config = new BreakerConfig(after: 2, within: 60, for: 10); + + Monitor::breaker()->recordFailure('cap', $config); + Monitor::breaker()->recordFailure('cap', $config); + Monitor::breaker()->recordFailure('cap', $config); + + expect(Monitor::breaker()->state('cap')->failureCount())->toBe(2); + }); + + it('treats non-array profile exception lists as empty and empty writes as nothing', function (): void { + config()->set('monitor.profiles.odd', ['retry' => ['times' => 1, 'on' => 'not-a-list']]); + + expect(Profiles::policies('odd', 'x.y')['retry']->describe()['on'])->toBe([]) + ->and(resolve(OutcomeStore::class)->write([]))->toBe(0) + ->and(Lists::ofArrays('nope'))->toBe([]) + ->and(Lists::ofArrays([1, ['a' => 1], 'x']))->toBe([['a' => 1]]); + }); + + it('registers the fluent completeness expectation on a passing namespace', function (): void { + config()->set('monitor.discovery.paths', [realpath(__DIR__.'/../../workbench/app')]); + config()->set('monitor.inventory.rules', ['duplicate_names' => false, 'name_pattern' => false, 'missing_escalation' => false, 'unreadable_control' => false]); + Expectations::register(); + + expect('Workbench\\Monitor\\ControlPoints\\Payments')->toHaveCompleteControlPoints(); + }); +}); + +describe('inventory edges', function (): void { + beforeEach(function (): void { + config()->set('monitor.discovery.paths', [realpath(__DIR__.'/../../workbench/app')]); + config()->set('monitor.critical_namespaces', ['Workbench\\Monitor\\ControlPoints']); + }); + + it('records a class that cannot be described and one that cannot be loaded', function (): void { + $inventory = resolve(Discovery::class)->build(); + + $unnamed = $inventory->find(Unnamed::class); + + expect($unnamed)->not->toBeNull() + ->and($unnamed->dynamicName)->toBeTrue() + ->and($unnamed->notes[0])->toContain('could not be described') + ->and($inventory->find('payment.direct')?->form)->toBe('inline'); + + $uncontrolled = array_filter($inventory->findings(), fn (Finding $f): bool => $f->rule === 'critical_namespace_uncontrolled'); + + expect(implode(' ', array_map(fn (Finding $f): string => $f->message, $uncontrolled)))->not->toContain('GhostFiling'); + }); + + it('explains an attempts limit and survives a store that cannot be read', function (): void { + config()->set('monitor.records.store.enabled', true); + $point = resolve(Discovery::class)->build()->find('payment.charge'); + + $text = resolve(Explainer::class)->explain($point, resolve(OutcomeStore::class)); + + expect($text)->toContain('at most 3 attempt(s)')->toContain('History: the store could not be read.'); + }); + + it('lists points when the store is enabled but its table is missing', function (): void { + config()->set('monitor.records.store.enabled', true); + + $this->artisan('monitor:points')->expectsOutputToContain('payment.charge')->assertSuccessful(); + }); + + it('warns instead of overwriting an existing test', function (): void { + $class = app_path('ControlPoints/Payments/Existing.php'); + $test = base_path('tests/Feature/ControlPoints/Payments/ExistingTest.php'); + File::ensureDirectoryExists(dirname($test)); + File::put($test, 'artisan('make:control-point', ['name' => 'Payments/Existing'])->expectsOutputToContain('Test already exists')->assertSuccessful(); + + expect(File::get($test))->toBe(' [['type' => 'text', 'text' => '{"password":"x"}']]]); + + expect($redactor->redact($response))->toBe($response) + ->and($redactor->data(['password' => 'x']))->toBe(['password' => 'x']); + }); + + it('redacts streamed notifications, structured content and iterables', function (): void { + $redactor = new ResponseRedactor('observability'); + + $notification = JsonRpcResponse::notification('notifications/message', ['content' => [['type' => 'text', 'text' => '{"password":"hunter2"}']]]); + $structured = JsonRpcResponse::result('2', ['content' => [], 'structuredContent' => ['password' => 'hunter2', 'n' => 1]]); + + $out = iterator_to_array($redactor->redact((function () use ($notification, $structured) { + yield $notification; + yield $structured; + })())); + + expect($out[0]->content['params']['content'][0]['text'])->toContain('[REDACTED]') + ->and($out[1]->content['result']['structuredContent'])->toBe(['password' => '[REDACTED]', 'n' => 1]); + }); + + it('replaces data it cannot redact', function (): void { + Redactor::shouldReceive('inspect')->andThrow(new Fatal('broken profile')); + + expect((new ResponseRedactor('observability'))->data(['a' => 1]))->toBe(['redaction' => 'failed']); + }); + + it('serialises the other value objects to json', function (): void { + config()->set('monitor.discovery.paths', [realpath(__DIR__.'/../../workbench/app')]); + $inventory = resolve(Discovery::class)->build(); + + expect(json_decode(json_encode($inventory->find('payment.charge')), true)['point'])->toBe('payment.charge') + ->and(json_decode(json_encode($inventory->findings()[0]), true))->toHaveKey('rule') + ->and(json_decode(json_encode(Monitor::breaker()->state('x')), true)['state'])->toBe('closed'); + }); + + it('drops context rather than leaking it when the redactor is unavailable', function (): void { + Redactor::shouldReceive('profile')->andThrow(new Fatal('no redactor')); + + expect(Redaction::context(['password' => 'x']))->toBe(['_redaction' => 'unavailable']) + ->and(Redaction::exception(['class' => 'E', 'message' => 'secret'])['message'])->toBe('[redaction unavailable]'); + }); + + it('refuses to fake without a facade application', function (): void { + $app = Monitor::getFacadeApplication(); + Monitor::setFacadeApplication(null); + + try { + expect(fn (): MonitorFake => Monitor::fake())->toThrow(RuntimeException::class, 'facade application'); + } finally { + Monitor::setFacadeApplication($app); + } + }); +}); diff --git a/tests/Feature/EnforcementTest.php b/tests/Feature/EnforcementTest.php new file mode 100644 index 0000000..e389546 --- /dev/null +++ b/tests/Feature/EnforcementTest.php @@ -0,0 +1,72 @@ +set('monitor.discovery.paths', [realpath(__DIR__.'/../../workbench/app')]); +}); + +describe('pest expectations', function (): void { + it('registers toBeControlled and toHaveCompleteControlPoints', function (): void { + Expectations::register(); + + expect('Workbench\\Monitor\\ControlPoints\\Payments')->toBeControlled(); + expect(fn () => expect('Workbench\\Monitor\\ControlPoints\\Filings')->toBeControlled()) + ->toThrow(AssertionFailedError::class, 'Uncontrolled operations in Workbench\\Monitor\\ControlPoints\\Filings'); + + expect(fn () => expect('Workbench\\Monitor\\ControlPoints\\Payments')->toHaveCompleteControlPoints()) + ->toThrow(AssertionFailedError::class, 'declares no escalation'); + }); + + it('passes completeness for a namespace whose points are complete', function (): void { + config()->set('monitor.inventory.rules', ['duplicate_names' => false, 'name_pattern' => false, 'missing_escalation' => false, 'unreadable_control' => false]); + + Expectations::assertComplete('Workbench\\Monitor\\ControlPoints\\Payments'); + + expect(true)->toBeTrue(); + }); + + it('rejects a non-string subject', function (): void { + Expectations::register(); + + expect(fn () => expect(42)->toBeControlled())->toThrow(InvalidArgumentException::class); + }); + + it('restores the critical namespaces it borrowed', function (): void { + config()->set('monitor.critical_namespaces', ['Keep\\Me']); + + Expectations::assertControlled('Workbench\\Monitor\\ControlPoints\\Payments'); + + expect(config('monitor.critical_namespaces'))->toBe(['Keep\\Me']); + }); +}); + +describe('phpstan rule', function (): void { + it('flags the uncontrolled fixture and nothing else', function (): void { + $process = new Process([ + PHP_BINARY, realpath(__DIR__.'/../../vendor/bin/phpstan'), 'analyse', + '--configuration='.realpath(__DIR__.'/../Fixtures/PhpStan/critical.neon'), + '--error-format=json', '--no-progress', '--memory-limit=1G', + ], realpath(__DIR__.'/../..')); + $process->run(); + + $report = json_decode($process->getOutput(), true); + + $flagged = []; + + foreach ($report['files'] ?? [] as $file => $entry) { + foreach ($entry['messages'] as $message) { + if ($message['identifier'] === 'monitor.uncontrolled') { + $flagged[basename($file)] = $message['message']; + } + } + } + + expect($flagged)->toHaveCount(2)->toHaveKey('Uncontrolled.php')->toHaveKey('Misnamed.php') + ->and($flagged['Uncontrolled.php'])->toContain('Uncontrolled sits in a critical namespace'); + })->skip(! is_file(__DIR__.'/../../vendor/bin/phpstan'), 'phpstan is not installed'); +}); diff --git a/tests/Feature/FakeTest.php b/tests/Feature/FakeTest.php new file mode 100644 index 0000000..8a6a87c --- /dev/null +++ b/tests/Feature/FakeTest.php @@ -0,0 +1,160 @@ +run(function () use (&$ran): string { + $ran = true; + + return 'charged'; + }); + + expect($fake)->toBeInstanceOf(MonitorFake::class) + ->and($ran)->toBeTrue() + ->and(Monitor::outcomes())->toHaveCount(1) + ->and(Monitor::outcomes('payment.charge')[0]->value)->toBe('charged'); + + Monitor::assertRan('payment.charge') + ->assertRanTimes('payment.charge', 1) + ->assertSucceeded('payment.charge') + ->assertSucceeded('payment.charge', fn (Outcome $o): bool => $o->value === 'charged') + ->assertNotRan('payment.refund') + ->assertNothingEscalated(); + }); + + it('returns a canned value without running the callback', function (): void { + Monitor::fake()->returning('payment.charge', 'canned'); + $ran = false; + + $value = Monitor::control('payment.charge')->run(function () use (&$ran): string { + $ran = true; + + return 'real'; + }); + + expect($value)->toBe('canned')->and($ran)->toBeFalse(); + Monitor::assertSucceeded('payment.charge'); + }); + + it('fails a point through its own corrections', function (): void { + Monitor::fake()->failing('payment.charge', new CardDeclined('stolen_card')); + + $value = Monitor::control('payment.charge') + ->recover(CardDeclined::class, fn (CardDeclined $e): string => "declined: {$e->declineCode}") + ->run(fn (): string => 'never'); + + expect($value)->toBe('declined: stolen_card'); + Monitor::assertRecovered('payment.charge')->assertRecovered('payment.charge', from: CardDeclined::class); + }); + + it('asserts escalation, refusal, retries and limits', function (): void { + Sleep::fake(); + Monitor::fake(); + + Monitor::control('payment.charge')->retry(times: 2, backoffMs: 0)->attempt(fn () => throw new Fatal('x')); + Monitor::breaker()->open('stripe', 60); + Monitor::control('payment.refund')->breaker('stripe')->attempt(fn (): int => 1); + Monitor::control('search.index')->within(0.0001)->attempt(fn (): null => usleep(500)); + + Monitor::assertEscalated('payment.charge') + ->assertEscalated('payment.charge', with: Fatal::class) + ->assertRetried('payment.charge') + ->assertRetried('payment.charge', times: 2) + ->assertRefused('payment.refund') + ->assertLimitBreached('search.index', 'duration'); + }); + + it('fails assertions with a readable message', function (): void { + Monitor::fake(); + + Monitor::control('payment.charge')->attempt(fn () => throw new Fatal); + + expect(fn () => Monitor::assertNotRan('payment.charge'))->toThrow(AssertionFailedError::class, 'ran, but was expected not to') + ->and(fn () => Monitor::assertSucceeded('payment.charge'))->toThrow(AssertionFailedError::class, 'did not end succeeded; saw: escalated') + ->and(fn () => Monitor::assertRan('payment.refund'))->toThrow(AssertionFailedError::class, 'did not run') + ->and(fn () => Monitor::assertRan('payment.charge', fn (): bool => false))->toThrow(AssertionFailedError::class, 'no outcome matched') + ->and(fn () => Monitor::assertRetried('payment.charge'))->toThrow(AssertionFailedError::class, 'was not retried') + ->and(fn () => Monitor::assertRetried('payment.charge', 3))->toThrow(AssertionFailedError::class, 'exactly 3 times') + ->and(fn () => Monitor::assertLimitBreached('payment.charge', 'duration'))->toThrow(AssertionFailedError::class, 'did not breach') + ->and(fn () => Monitor::assertNothingEscalated())->toThrow(AssertionFailedError::class, 'escalated: payment.charge') + ->and(fn () => Monitor::assertNothingRan())->toThrow(AssertionFailedError::class, 'ran: payment.charge') + ->and(fn () => Monitor::assertRanTimes('payment.charge', 2))->toThrow(AssertionFailedError::class, 'ran 1 times instead of 2 times'); + }); + + it('can forget what it recorded and swap canned behaviours', function (): void { + $fake = Monitor::fake()->returning('a.b', 1)->failing('a.b', new Fatal); + + expect(Monitor::control('a.b')->attempt(fn (): int => 9)->escalated())->toBeTrue(); + + $fake->returning('a.b', 2); + + expect(Monitor::control('a.b')->run(fn (): int => 9))->toBe(2); + + $fake->forget(); + Monitor::assertNothingRan(); + }); + + it('asserts the negative half of every outcome', function (): void { + Sleep::fake(); + Monitor::fake(); + + Monitor::control('payment.charge')->run(fn (): int => 1); + Monitor::control('payment.refund')->recover(CardDeclined::class, fn (): null => null)->run(fn () => throw new CardDeclined); + Monitor::control('search.index')->retry(times: 1, backoffMs: 0)->attempt(fn () => throw new Fatal); + + Monitor::assertNotRan('payment.capture') + ->assertNotRan('payment.charge', fn (Outcome $o): bool => $o->escalated()) + ->assertRanOnce('payment.charge') + ->assertNotRecovered('payment.charge') + ->assertNotRecovered('payment.refund', from: Fatal::class) + ->assertRecovered('payment.refund', fn (Outcome $o): bool => $o->value === null) + ->assertNotEscalated('payment.charge') + ->assertNotEscalated('search.index', with: CardDeclined::class) + ->assertEscalated('search.index', fn (Outcome $o): bool => $o->attempts === 2) + ->assertNotRefused('payment.charge') + ->assertNotRetried('payment.charge') + ->assertNotRetried('search.index', times: 5); + + expect(fn () => Monitor::assertNotRan('payment.charge'))->toThrow(AssertionFailedError::class, 'ran, but was expected not to') + ->and(fn () => Monitor::assertNotRan('payment.charge', fn (): bool => true))->toThrow(AssertionFailedError::class, 'matching the callback') + ->and(fn () => Monitor::assertNotRecovered('payment.refund'))->toThrow(AssertionFailedError::class, 'ended recovered, but was expected not to') + ->and(fn () => Monitor::assertNotEscalated('search.index'))->toThrow(AssertionFailedError::class, 'ended escalated') + ->and(fn () => Monitor::assertNotRetried('search.index'))->toThrow(AssertionFailedError::class, 'was retried, but was expected not to be') + ->and(fn () => Monitor::assertNotRetried('search.index', times: 1))->toThrow(AssertionFailedError::class, 'retried exactly 1 times'); + + Monitor::breaker()->open('stripe', 60); + Monitor::control('payment.capture')->breaker('stripe')->attempt(fn (): int => 1); + + expect(fn () => Monitor::assertNotRefused('payment.capture'))->toThrow(AssertionFailedError::class, 'ended refused'); + }); + + it('accepts enum point names everywhere', function (): void { + Monitor::fake()->returning(PointNames::Charge, 'canned'); + + expect(Monitor::control(PointNames::Charge)->run(fn (): string => 'real'))->toBe('canned'); + + Monitor::assertRan(PointNames::Charge)->assertSucceeded(PointNames::Charge)->assertNotRan(PointNames::Refund); + expect(Monitor::outcomes(PointNames::Charge))->toHaveCount(1); + }); + + it('serialises outcomes to json', function (): void { + Monitor::fake(); + $outcome = Monitor::control('payment.charge')->attempt(fn (): int => 1); + + expect(json_decode(json_encode($outcome), true)['status'])->toBe('succeeded') + ->and($outcome->toArray()['point'])->toBe('payment.charge'); + }); +}); diff --git a/tests/Feature/HttpBreakerTest.php b/tests/Feature/HttpBreakerTest.php new file mode 100644 index 0000000..1e79f40 --- /dev/null +++ b/tests/Feature/HttpBreakerTest.php @@ -0,0 +1,68 @@ + Http::response('down', 503)]); + + Http::breaker('stripe', after: 2, within: 60, for: 100)->get('https://stripe.test/charges'); + Http::breaker('stripe', after: 2, within: 60, for: 100)->get('https://stripe.test/charges'); + + expect(Monitor::breaker()->isOpen('stripe'))->toBeTrue(); + expect(fn () => Http::breaker('stripe')->get('https://stripe.test/charges'))->toThrow(BreakerOpen::class); + Http::assertSentCount(2); + }); + + it('counts connection failures and closes on success', function (): void { + Http::fake(['stripe.test/down' => Http::failedConnection(), 'stripe.test/up' => Http::response('ok', 200)]); + + expect(fn () => Http::baseUrl('https://stripe.test')->breaker('stripe', after: 1)->get('/down'))->toThrow(ConnectionException::class) + ->and(Monitor::breaker()->isOpen('stripe'))->toBeTrue(); + + $this->travel(200)->seconds(); + + Http::breaker('stripe', after: 1)->get('https://stripe.test/up'); + + expect(Monitor::breaker()->state('stripe')->isClosed())->toBeTrue(); + Http::assertSent(fn (ClientRequest $r): bool => $r->url() === 'https://stripe.test/up'); + }); + + it('shares the circuit with control points', function (): void { + Monitor::breaker()->open('stripe', 60); + + expect(fn () => Http::breaker('stripe')->get('https://stripe.test/x'))->toThrow(BreakerOpen::class) + ->and(Monitor::control('payment.charge')->breaker('stripe')->attempt(fn (): int => 1)->refused())->toBeTrue(); + }); + + it('records a rejection that arrives through the promise', function (): void { + $middleware = HttpBreaker::middleware(fn () => Monitor::breaker(), 'stripe', new BreakerConfig(1, 60, 100)); + $handler = $middleware(fn (): PromiseInterface => Create::rejectionFor(new RuntimeException('socket closed'))); + + expect(fn () => $handler(new Request('GET', 'https://stripe.test/x'), [])->wait())->toThrow(RuntimeException::class, 'socket closed') + ->and(Monitor::breaker()->isOpen('stripe'))->toBeTrue(); + + Monitor::breaker()->close('stripe'); + $handler = $middleware(fn (): PromiseInterface => Create::rejectionFor('not an exception')); + + expect(fn () => $handler(new Request('GET', 'https://stripe.test/x'), [])->wait())->toThrow(RejectionException::class) + ->and(Monitor::breaker()->isOpen('stripe'))->toBeTrue(); + }); + + it('accepts a Once policy object through policy()', function (): void { + expect(Monitor::control('a.b')->policy(Once::key('k'))->describe()['policies'][0]['type'])->toBe('once'); + }); +}); diff --git a/tests/Feature/InventoryTest.php b/tests/Feature/InventoryTest.php new file mode 100644 index 0000000..be82be0 --- /dev/null +++ b/tests/Feature/InventoryTest.php @@ -0,0 +1,218 @@ +set('monitor.discovery.paths', [workbenchPath()]); + config()->set('monitor.critical_namespaces', ['Workbench\\Monitor\\ControlPoints']); +}); + +describe('discovery', function (): void { + it('finds class-form points fully and inline points by name', function (): void { + $inventory = resolve(Discovery::class)->build(); + + $names = array_map(fn (PointDescription $p): string => $p->name, $inventory->points); + sort($names); + + expect($names)->toBe(['(dynamic)', 'BadName', Unnamed::class, 'filing.submit', 'payment.capture', 'payment.charge', 'payment.charge', 'payment.direct', 'payment.inline', 'payment.refund']); + + $charge = $inventory->find('payment.charge'); + $inline = $inventory->find('payment.inline'); + + expect($charge->isClassForm())->toBeTrue() + ->and($charge->origin)->toBe(ChargeCard::class) + ->and($charge->file)->toEndWith('ChargeCard.php') + ->and($charge->line)->toBeInt() + ->and($charge->risks)->toBe([CardDeclined::class]) + ->and($charge->escalation)->toBe(PagePayments::class) + ->and(array_column($charge->policies, 'type'))->toBe(['breaker', 'retry']) + ->and($charge->limits[1])->toBe(['type' => 'attempts', 'max' => 3]) + ->and($inline->isClassForm())->toBeFalse() + ->and($inline->origin)->toBe(InlineCharger::class) + ->and($inline->line)->toBeInt() + ->and($inline->policies)->toBe([]) + ->and($inline->notes[0])->toContain('inventoried by name'); + }); + + it('raises one finding per rule on the deliberately wrong fixtures', function (): void { + $inventory = resolve(Discovery::class)->build(); + + $byRule = []; + foreach ($inventory->findings() as $finding) { + $byRule[$finding->rule][] = $finding; + } + + expect(array_keys($byRule))->toEqualCanonicalizing(array_keys(Rules::all())) + ->and($byRule['duplicate_names'][0]->message)->toContain('"payment.charge" is declared 2 times') + ->and($byRule['duplicate_names'][0]->isError())->toBeTrue() + ->and($byRule['name_pattern'][0]->point)->toBe('BadName') + ->and($byRule['missing_escalation'][0]->point)->toBe('payment.refund') + ->and($byRule['catch_all_without_escalation'][0]->point)->toBe('filing.submit') + ->and($byRule['catch_all_without_escalation'][0]->level)->toBe(Finding::WARNING) + ->and($byRule['critical_namespace_uncontrolled'])->toHaveCount(1) + ->and($byRule['critical_namespace_uncontrolled'][0]->message)->toContain('Uncontrolled sits in a critical namespace') + ->and($byRule['dynamic_name'][0]->file)->toEndWith('InlineCharger.php') + ->and($byRule['unreadable_control'][0]->point)->toBe('payment.capture') + ->and($inventory->hasErrors())->toBeTrue(); + }); + + it('lets rules be switched off in config', function (): void { + config()->set('monitor.inventory.rules', ['duplicate_names' => false, 'missing_escalation' => false]); + + $rules = array_map(fn (Finding $r): string => $r->rule, resolve(Discovery::class)->build()->findings()); + + expect($rules)->not->toContain('duplicate_names')->not->toContain('missing_escalation')->toContain('name_pattern'); + }); + + it('skips the critical namespace rule when none are configured, and unknown paths', function (): void { + config()->set('monitor.critical_namespaces', []); + + expect(resolve(Discovery::class)->build()->findings())->each->not->toHaveProperty('rule', 'critical_namespace_uncontrolled') + ->and(resolve(Discovery::class)->build(['/definitely/not/here'])->points)->toBe([]); + }); + + it('reads a file without executing it and survives a syntax error', function (): void { + $scanned = resolve(AstScanner::class)->scan(workbenchPath().'/ControlPoints/Payments/InlineCharger.php'); + + expect($scanned->classes)->toBe([InlineCharger::class]) + ->and($scanned->inlinePoints)->toHaveCount(3) + ->and($scanned->inlinePoints[0]['name'])->toBe('payment.inline') + ->and($scanned->inlinePoints[1]['name'])->toBe('payment.direct') + ->and($scanned->inlinePoints[2]['name'])->toBeNull(); + + $broken = tempnam(sys_get_temp_dir(), 'monitor').'.php'; + file_put_contents($broken, 'scan($broken)->classes)->toBe([]); + unlink($broken); + + expect(resolve(AstScanner::class)->scan('/nope/nothing.php')->classes)->toBe([]); + }); +}); + +describe('reports', function (): void { + it('renders json and sarif', function (): void { + $inventory = resolve(Discovery::class)->build(); + + $json = json_decode((new JsonReport)->render($inventory), true); + $sarif = json_decode((new SarifReport)->render($inventory, workbenchPath()), true); + + expect($json['points'])->toHaveCount(10)->and($json['findings'])->not->toBeEmpty() + ->and($sarif['version'])->toBe('2.1.0') + ->and($sarif['runs'][0]['tool']['driver']['rules'])->toHaveCount(count(Rules::all())) + ->and($sarif['runs'][0]['results'][0]['ruleId'])->toBeString() + ->and($sarif['runs'][0]['results'][0]['locations'][0]['physicalLocation']['artifactLocation']['uri'])->toStartWith('ControlPoints/'); + }); + + it('explains a point in prose', function (): void { + $inventory = resolve(Discovery::class)->build(); + $explainer = resolve(Explainer::class); + + $charge = $explainer->explain($inventory->find('payment.charge')); + $inline = $explainer->explain($inventory->find('payment.inline')); + $filing = $explainer->explain($inventory->find('filing.submit')); + + expect($charge)->toContain('payment.charge is a control point class') + ->toContain('with the "external" profile') + ->toContain('the "stripe" breaker opens after') + ->toContain('retry 2 time(s)') + ->toContain('the result must satisfy "charge must be settled"') + ->toContain('It recovers from Workbench\\Monitor\\Support\\CardDeclined') + ->toContain('PagePayments is called when it escalates') + ->and($inline)->toContain('declared inline')->toContain('Only its name is known statically') + ->and($filing)->toContain('a database transaction retried 1 time(s)')->toContain('should finish within 5s')->toContain('(a catch-all)')->toContain('Nothing is told when it escalates'); + }); + + it('adds history from the store when it is enabled', function (): void { + config()->set('monitor.records.store.enabled', true); + (require glob(__DIR__.'/../../database/migrations/*_create_monitor_outcomes_table.php')[0])->up(); + ChargeCard::run(1, 1); + resolve(StoreOutcomes::class)->flush(); + + $point = resolve(Discovery::class)->build()->find('payment.charge'); + + expect(resolve(Explainer::class)->explain($point, resolve(OutcomeStore::class)))->toContain('Last 24 hours: 1 succeeded') + ->and(resolve(Explainer::class)->explain(resolve(Discovery::class)->build()->find('payment.refund'), resolve(OutcomeStore::class)))->toContain('no runs in the last 24 hours'); + }); +}); + +describe('commands', function (): void { + it('lists points as a table and fails --check on errors', function (): void { + $this->artisan('monitor:points')->expectsOutputToContain('payment.charge')->expectsOutputToContain('control point(s)')->assertSuccessful(); + $this->artisan('monitor:points', ['--check' => true])->assertFailed(); + $this->artisan('monitor:points', ['--format' => 'json'])->expectsOutputToContain('"findings"')->assertSuccessful(); + $this->artisan('monitor:points', ['--format' => 'sarif'])->expectsOutputToContain('sarif-2.1.0')->assertSuccessful(); + $this->artisan('monitor:points', ['--path' => ['/nowhere']])->expectsOutputToContain('No control points found')->assertSuccessful(); + }); + + it('passes --check on a clean tree', function (): void { + config()->set('monitor.critical_namespaces', []); + config()->set('monitor.inventory.rules', ['duplicate_names' => false, 'name_pattern' => false, 'missing_escalation' => false, 'unreadable_control' => false]); + + $this->artisan('monitor:points', ['--check' => true])->assertSuccessful(); + }); + + it('shows last seen from the store', function (): void { + config()->set('monitor.records.store.enabled', true); + (require glob(__DIR__.'/../../database/migrations/*_create_monitor_outcomes_table.php')[0])->up(); + ChargeCard::run(1, 1); + resolve(StoreOutcomes::class)->flush(); + + $this->artisan('monitor:points')->expectsOutputToContain('succeeded @')->assertSuccessful(); + }); + + it('explains a point or says it does not exist', function (): void { + $this->artisan('monitor:explain', ['point' => 'payment.charge'])->expectsOutputToContain('is a control point class')->assertSuccessful(); + $this->artisan('monitor:explain', ['point' => 'payment.charge', '--json' => true])->expectsOutputToContain('"form": "class"')->assertSuccessful(); + $this->artisan('monitor:explain', ['point' => 'nope.nope'])->assertFailed(); + }); + + it('generates a control point class and its test', function (): void { + $class = app_path('ControlPoints/Payments/RefundCard.php'); + $test = base_path('tests/Feature/ControlPoints/Payments/RefundCardTest.php'); + File::delete([$class, $test]); + + try { + $this->artisan('make:control-point', ['name' => 'Payments/RefundCard', '--profile' => 'external'])->assertSuccessful(); + + expect(File::exists($class))->toBeTrue()->and(File::exists($test))->toBeTrue(); + + $source = File::get($class); + expect($source)->toContain('namespace App\\ControlPoints\\Payments;') + ->toContain("#[Point('payments.refund_card', profile: 'external')]") + ->toContain('final class RefundCard extends ControlPoint') + ->toContain('protected function control(Control $control): void'); + + expect(File::get($test))->toContain('use App\\ControlPoints\\Payments\\RefundCard;')->toContain("Monitor::assertSucceeded('payments.refund_card')"); + + $this->artisan('make:control-point', ['name' => 'Payments/RefundCard'])->expectsOutputToContain('already exists'); + $this->artisan('make:control-point', ['name' => 'Payments/RefundCard', '--force' => true, '--point' => 'payment.refund', '--no-test' => true])->assertSuccessful(); + expect(File::get($class))->toContain("#[Point('payment.refund')]"); + } finally { + File::delete([$class, $test]); + File::deleteDirectory(app_path('ControlPoints')); + File::deleteDirectory(base_path('tests/Feature/ControlPoints')); + } + }); +}); diff --git a/tests/Feature/LogRedactorTest.php b/tests/Feature/LogRedactorTest.php deleted file mode 100644 index af128f4..0000000 --- a/tests/Feature/LogRedactorTest.php +++ /dev/null @@ -1,122 +0,0 @@ -set('monitor.enabled', true); - config()->set('monitor.origin_path_wrapper', 'none'); - - $this->setupLogMocking(); - }); - - it('calls Redactor facade when redactor is enabled', function () { - config()->set('monitor.redactor.enabled', true); - config()->set('monitor.redactor.redactor_profile', 'test_profile'); - - $testContext = ['user_id' => 123, 'email' => 'test@example.com']; - $testMessage = 'Test message with sensitive data'; - - Redactor::shouldReceive('redact') - ->once() - ->with($testContext, 'test_profile') - ->andReturn(['user_id' => 123, 'email' => '[REDACTED]']); - - Redactor::shouldReceive('redact') - ->once() - ->with($testMessage, 'test_profile') - ->andReturn('Test message with [REDACTED]'); - - Log::shouldReceive('info')->once()->withAnyArgs(); - - StructuredLogger::from('TestClass')->info($testMessage, $testContext); - }); - - it('does not call Redactor facade when redactor is disabled', function () { - config()->set('monitor.redactor.enabled', false); - - $testContext = ['user_id' => 123, 'email' => 'test@example.com']; - $testMessage = 'Test message with sensitive data'; - - Redactor::shouldReceive('redact')->never(); - Log::shouldReceive('info')->once()->withAnyArgs(); - - StructuredLogger::from('TestClass')->info($testMessage, $testContext); - }); - - it('uses the configured redactor profile', function () { - config()->set('monitor.redactor.enabled', true); - config()->set('monitor.redactor.redactor_profile', 'custom_profile'); - - $testContext = ['data' => 'sensitive']; - $testMessage = 'Test message'; - - Redactor::shouldReceive('redact') - ->once() - ->with($testContext, 'custom_profile') - ->andReturn($testContext); - - Redactor::shouldReceive('redact') - ->once() - ->with($testMessage, 'custom_profile') - ->andReturn($testMessage); - - Log::shouldReceive('info')->once()->withAnyArgs(); - - StructuredLogger::from('TestClass')->info($testMessage, $testContext); - }); - - it('defaults to default profile when not configured', function () { - config()->set('monitor.redactor.enabled', true); - // Don't set redactor_profile key at all to test default behavior - - $testContext = ['data' => 'sensitive']; - $testMessage = 'Test message'; - - Redactor::shouldReceive('redact') - ->once() - ->with($testContext, 'default') - ->andReturn($testContext); - - Redactor::shouldReceive('redact') - ->once() - ->with($testMessage, 'default') - ->andReturn($testMessage); - - Log::shouldReceive('info')->once()->withAnyArgs(); - - StructuredLogger::from('TestClass')->info($testMessage, $testContext); - }); - - it('calls redactor for all log levels', function () { - config()->set('monitor.redactor.enabled', true); - config()->set('monitor.redactor.redactor_profile', 'default'); - - $testContext = ['sensitive' => 'data']; - $testMessage = 'Test message'; - - $logLevels = ['info', 'error', 'warning', 'debug']; - - foreach ($logLevels as $level) { - Redactor::shouldReceive('redact') - ->once() - ->with($testContext, 'default') - ->andReturn($testContext); - - Redactor::shouldReceive('redact') - ->once() - ->with($testMessage, 'default') - ->andReturn($testMessage); - - Log::shouldReceive($level)->once()->withAnyArgs(); - - StructuredLogger::from('TestClass')->{$level}($testMessage, $testContext); - } - }); -}); diff --git a/tests/Feature/McpRegistrationTest.php b/tests/Feature/McpRegistrationTest.php new file mode 100644 index 0000000..0a0aae0 --- /dev/null +++ b/tests/Feature/McpRegistrationTest.php @@ -0,0 +1,27 @@ +set('monitor.mcp.enabled', true); + $app['config']->set('monitor.mcp.handle', 'monitor'); + } + + public function test_the_server_is_registered_as_a_local_server_when_enabled(): void + { + $this->assertNotNull(Mcp::getLocalServer('monitor')); + } +} diff --git a/tests/Feature/McpTest.php b/tests/Feature/McpTest.php new file mode 100644 index 0000000..16aed01 --- /dev/null +++ b/tests/Feature/McpTest.php @@ -0,0 +1,132 @@ +set('monitor.discovery.paths', [realpath(__DIR__.'/../../workbench/app')]); + config()->set('monitor.critical_namespaces', []); + StripeClient::$charge = null; +}); + +function enableStoreForMcp(): void +{ + config()->set('monitor.records.store.enabled', true); + (require glob(__DIR__.'/../../database/migrations/*_create_monitor_outcomes_table.php')[0])->up(); +} + +describe('server', function (): void { + it('registers its tools, resources and prompts', function (): void { + MonitorServer::tools()->assertRegistered([ListPoints::class, ExplainPoint::class, RecentOutcomes::class, Escalations::class]); + MonitorServer::resources()->assertRegistered([RecordSchema::class, Guidelines::class]); + MonitorServer::prompts()->assertRegistered([WrapOperation::class]); + }); + + it('is not registered as a local server by default', function (): void { + expect(Mcp::getLocalServer('monitor'))->toBeNull(); + }); + +}); + +describe('tools', function (): void { + it('lists points with findings and filters by domain', function (): void { + MonitorServer::tool(ListPoints::class)->assertOk()->assertSee('payment.charge')->assertSee('"findings"')->assertSee('payment.inline'); + MonitorServer::tool(ListPoints::class, ['domain' => 'Courts'])->assertOk()->assertSee('filing.submit')->assertDontSee('payment.refund'); + }); + + it('explains a point, with history when the store is on', function (): void { + MonitorServer::tool(ExplainPoint::class, ['point' => 'payment.charge'])->assertOk()->assertSee('is a control point class')->assertSee('PagePayments is called'); + MonitorServer::tool(ExplainPoint::class, ['point' => 'nope.nope'])->assertHasErrors(['No control point named "nope.nope"']); + MonitorServer::tool(ExplainPoint::class, ['point' => ''])->assertHasErrors(['Pass the control point name']); + + enableStoreForMcp(); + ChargeCard::run(1, 1); + resolve(StoreOutcomes::class)->flush(); + + MonitorServer::tool(ExplainPoint::class, ['point' => 'payment.charge'])->assertOk()->assertSee('Last 24 hours: 1 succeeded'); + }); + + it('refuses outcomes and escalations while the store is off', function (): void { + MonitorServer::tool(RecentOutcomes::class)->assertHasErrors(['The outcome store is disabled']); + MonitorServer::tool(Escalations::class)->assertHasErrors(['The outcome store is disabled']); + }); + + it('returns recent outcomes with filters and validates the window', function (): void { + enableStoreForMcp(); + ChargeCard::run(1, 1); + StripeClient::$charge = fn () => throw new RuntimeException('gateway down'); + ChargeCard::attempt(2, 2); + resolve(StoreOutcomes::class)->flush(); + + MonitorServer::tool(RecentOutcomes::class)->assertOk()->assertSee('"count":2')->assertSee('gateway down'); + MonitorServer::tool(RecentOutcomes::class, ['status' => 'escalated', 'limit' => 1, 'since' => '1h'])->assertOk()->assertSee('"count":1')->assertSee('"status":"escalated"'); + MonitorServer::tool(RecentOutcomes::class, ['point' => 'nope.nope'])->assertOk()->assertSee('"count":0'); + MonitorServer::tool(RecentOutcomes::class, ['since' => 'yesterday'])->assertHasErrors(['"since" must look like']); + }); + + it('groups escalations and refusals by point', function (): void { + enableStoreForMcp(); + StripeClient::$charge = fn () => throw new RuntimeException('gateway down'); + ChargeCard::attempt(1, 1); + ChargeCard::attempt(2, 2); + Monitor::breaker()->open('stripe', 60); + ChargeCard::attempt(3, 3); + resolve(StoreOutcomes::class)->flush(); + + MonitorServer::tool(Escalations::class, ['since' => '1h'])->assertOk() + ->assertSee('"escalated":2')->assertSee('"refused":1')->assertSee('RuntimeException')->assertSee('"last_trace_id"'); + MonitorServer::tool(Escalations::class, ['domain' => 'Nowhere'])->assertOk()->assertSee('"points":[]'); + MonitorServer::tool(Escalations::class, ['since' => 'bad'])->assertHasErrors(['"since" must look like']); + }); +}); + +describe('resources and prompts', function (): void { + it('serves the record schema and the guidelines', function (): void { + MonitorServer::resource(RecordSchema::class)->assertOk()->assertSee('"monitor/1"')->assertSee('point.escalated'); + MonitorServer::resource(Guidelines::class)->assertOk()->assertSee('control point')->assertSee('make:control-point'); + }); + + it('builds the wrap_operation prompt from a file', function (): void { + $file = realpath(__DIR__.'/../../workbench/app/ControlPoints/Filings/Uncontrolled.php'); + + MonitorServer::prompt(WrapOperation::class, ['path' => $file, 'point' => 'filing.submit'])->assertOk() + ->assertSee('Name it "filing.submit"')->assertSee('filed without anyone knowing')->assertSee('## Guidelines'); + MonitorServer::prompt(WrapOperation::class, ['path' => 'missing.php'])->assertOk()->assertSee('Propose a dotted lowercase name'); + }); + + it('redacts json structurally and prose line by line, and can be turned off', function (): void { + $redactor = new ResponseRedactor('observability'); + + expect(json_decode($redactor->text('{"trace_id":"0af7651916cd43dd8448eb211c80319c","password":"hunter2","n":1}'), true)) + ->toMatchArray(['trace_id' => '0af7651916cd43dd8448eb211c80319c', 'password' => '[REDACTED]', 'n' => 1]) + ->and($redactor->text("first line\ntoken Xk9vB2mQ7pL4sN8wR1tY6uZ3aC5dF0gHjE2bV here\nlast line"))->toStartWith('first line')->toEndWith('last line')->not->toContain('Xk9vB2mQ7pL4sN8wR1tY6uZ3aC5dF0gHjE2bV') + ->and($redactor->text('[]'))->toBe('[]') + ->and((new ResponseRedactor(null))->text('{"password":"hunter2"}'))->toBe('{"password":"hunter2"}'); + + $response = JsonRpcResponse::error('1', -1, 'token Xk9vB2mQ7pL4sN8wR1tY6uZ3aC5dF0gHjE2bV leaked'); + expect($redactor->redact($response)->content['error']['message'])->not->toContain('Xk9vB2mQ7pL4sN8wR1tY6uZ3aC5dF0gHjE2bV'); + }); + + it('redacts what it returns', function (): void { + enableStoreForMcp(); + Monitor::control('payment.charge')->with(['password' => 'hunter2', 'invoice' => 5])->run(fn (): int => 1); + resolve(StoreOutcomes::class)->flush(); + + MonitorServer::tool(RecentOutcomes::class)->assertOk()->assertDontSee('hunter2')->assertSee('"invoice":5'); + }); +}); diff --git a/tests/Feature/MonitorServiceProviderTest.php b/tests/Feature/MonitorServiceProviderTest.php deleted file mode 100644 index bd95e91..0000000 --- a/tests/Feature/MonitorServiceProviderTest.php +++ /dev/null @@ -1,223 +0,0 @@ -app->bound(Monitor::class))->toBeTrue() - ->and($this->app->bound(Trace::class))->toBeTrue() - ->and($this->app->bound(LogTimer::class))->toBeTrue(); - - // Test singleton behavior - same instance returned - $monitor1 = $this->app->make(Monitor::class); - $monitor2 = $this->app->make(Monitor::class); - expect($monitor1)->toBe($monitor2); - - $trace1 = $this->app->make(Trace::class); - $trace2 = $this->app->make(Trace::class); - expect($trace1)->toBe($trace2); - - $timer1 = $this->app->make(LogTimer::class); - $timer2 = $this->app->make(LogTimer::class); - expect($timer1)->toBe($timer2); - } - - public function test_services_return_correct_types() - { - expect($this->app->make(Monitor::class))->toBeInstanceOf(Monitor::class) - ->and($this->app->make(Trace::class))->toBeInstanceOf(Trace::class) - ->and($this->app->make(LogTimer::class))->toBeInstanceOf(LogTimer::class); - } - - public function test_publishes_config_files() - { - // Test that publishing is registered - $provider = new MonitorServiceProvider($this->app); - $provider->boot(); - - $publishGroups = $provider::$publishGroups ?? []; - - // Laravel 11 changed how this works, so let's test the files exist - $monitorConfigPath = __DIR__.'/../../config/monitor.php'; - $loggingConfigPath = __DIR__.'/../../config/logging-monitor.php'; - - expect(file_exists($monitorConfigPath))->toBeTrue('monitor.php config file should exist') - ->and(file_exists($loggingConfigPath))->toBeTrue('logging-monitor.php config file should exist'); - } - - public function test_console_auto_trace_logic_with_started_trace() - { - // Create a trace that has already started - $trace = new Trace; - $trace->start(); - $originalId = $trace->id(); - - // Bind to container - $this->app->instance(Trace::class, $trace); - - // Create a custom service provider to test console logic - $provider = new class($this->app) extends MonitorServiceProvider - { - public function test_boot_console_logic(): void - { - // Simulate the console condition manually (console=true, testing=false, already started) - if (true && ! false && ! app(Trace::class)->hasStarted()) { - app(Trace::class)->start(); - } - } - }; - - $provider->test_boot_console_logic(); - - // Trace should remain unchanged (already started) - expect($trace->hasStarted())->toBeTrue() - ->and($trace->id())->toBe($originalId); - } - - public function test_console_auto_trace_logic_with_unstarted_trace() - { - // Create a fresh trace that hasn't started - $trace = new Trace; - expect($trace->hasNotStarted())->toBeTrue(); - - // Bind to container - $this->app->instance(Trace::class, $trace); - - // Create a custom service provider to test console logic - $provider = new class($this->app) extends MonitorServiceProvider - { - public function test_boot_console_logic(): void - { - // Simulate the console condition manually (console=true, testing=false, not started) - if (true && ! false && ! app(Trace::class)->hasStarted()) { - app(Trace::class)->start(); - } - } - }; - - $provider->test_boot_console_logic(); - - // Trace should now be started - expect($trace->hasStarted())->toBeTrue(); - } - - public function test_console_auto_trace_respects_testing_environment() - { - // Create a fresh trace - $trace = new Trace; - expect($trace->hasNotStarted())->toBeTrue(); - - $this->app->instance(Trace::class, $trace); - - // Create a custom service provider to test testing environment logic - $provider = new class($this->app) extends MonitorServiceProvider - { - public function test_boot_console_logic(): void - { - // Simulate console=true, testing=true condition - if (true && ! true && ! app(Trace::class)->hasStarted()) { - app(Trace::class)->start(); - } - } - }; - - $provider->test_boot_console_logic(); - - // Trace should NOT be started in testing environment - expect($trace->hasNotStarted())->toBeTrue(); - } - - public function test_console_auto_trace_respects_web_environment() - { - // Create a fresh trace - $trace = new Trace; - expect($trace->hasNotStarted())->toBeTrue(); - - $this->app->instance(Trace::class, $trace); - - // Create a custom service provider to test web environment logic - $provider = new class($this->app) extends MonitorServiceProvider - { - public function test_boot_console_logic(): void - { - // Simulate console=false (web environment) - if (false && ! false && ! app(Trace::class)->hasStarted()) { - app(Trace::class)->start(); - } - } - }; - - $provider->test_boot_console_logic(); - - // Trace should NOT be started in web environment - expect($trace->hasNotStarted())->toBeTrue(); - } - - public function test_app_binding_works_through_helper() - { - // Test that app() helper works with our registered services - expect(app(Monitor::class))->toBeInstanceOf(Monitor::class) - ->and(app(Trace::class))->toBeInstanceOf(Trace::class) - ->and(app(LogTimer::class))->toBeInstanceOf(LogTimer::class); - - // Test singleton behavior through app() helper - expect(app(Monitor::class))->toBe(app(Monitor::class)) - ->and(app(Trace::class))->toBe(app(Trace::class)) - ->and(app(LogTimer::class))->toBe(app(LogTimer::class)); - } - - public function test_boot_method_integration() - { - // Test that boot() doesn't throw errors and performs expected operations - $provider = new MonitorServiceProvider($this->app); - - // Test that boot can be called without throwing any exception - $exception = null; - try { - $provider->boot(); - } catch (\Exception $e) { - $exception = $e; - } - - expect($exception)->toBeNull(); - } - - public function test_service_provider_properties() - { - $provider = new MonitorServiceProvider($this->app); - - // Test that service provider is properly configured - expect($provider)->toBeInstanceOf(MonitorServiceProvider::class) - ->and(get_class($provider))->toBe(MonitorServiceProvider::class); - } - - public function test_register_is_complete() - { - // Test the registration process itself by checking that register() method works - $provider = new MonitorServiceProvider($this->app); - - // The services should already be bound by the test setup, so let's test that they are singletons - $monitor1 = $this->app->make(Monitor::class); - $monitor2 = $this->app->make(Monitor::class); - $trace1 = $this->app->make(Trace::class); - $trace2 = $this->app->make(Trace::class); - $timer1 = $this->app->make(LogTimer::class); - $timer2 = $this->app->make(LogTimer::class); - - // Verify they are the same instances (singleton behavior) - expect($monitor1)->toBe($monitor2) - ->and($trace1)->toBe($trace2) - ->and($timer1)->toBe($timer2); - } -} diff --git a/tests/Feature/NestingTest.php b/tests/Feature/NestingTest.php new file mode 100644 index 0000000..c1dedb9 --- /dev/null +++ b/tests/Feature/NestingTest.php @@ -0,0 +1,122 @@ +attempt(function () use (&$child): string { + $child = Monitor::control('payment.charge')->attempt(fn (): string => 'charged'); + + return 'placed'; + }); + + expect($child->parentId)->toBe($parent->id) + ->and($child->stack)->toBe(['order.place', 'payment.charge']) + ->and($parent->stack)->toBe(['order.place']) + ->and($child->traceId)->toBe($parent->traceId) + ->and(Monitor::stack()->depth())->toBe(0); + }); + + it('nests to any depth', function (): void { + $depths = []; + + Monitor::control('a.one')->run(function () use (&$depths): void { + $depths[] = Monitor::stack()->depth(); + Monitor::control('b.two')->run(function () use (&$depths): void { + $depths[] = Monitor::stack()->depth(); + Monitor::control('c.three')->run(function () use (&$depths): void { + $depths[] = Monitor::stack()->names(); + }); + }); + }); + + expect($depths)->toBe([1, 2, ['a.one', 'b.two', 'c.three']]); + }); + + it('hands a child escalation to the parent corrections', function (): void { + $parent = Monitor::control('order.place') + ->recover(CardDeclined::class, fn (): string => 'order held') + ->attempt(fn (): string => Monitor::control('payment.charge')->run(fn () => throw new CardDeclined)); + + expect($parent->status)->toBe(Status::Recovered) + ->and($parent->value)->toBe('order held') + ->and($parent->recoveredFrom)->toBe(CardDeclined::class); + }); + + it('surfaces a child refusal to the parent as BreakerOpen', function (): void { + Monitor::breaker()->open('stripe', 60); + + $parent = Monitor::control('order.place') + ->recover(BreakerOpen::class, fn (BreakerOpen $e): string => "queued: {$e->breaker}") + ->attempt(fn (): string => Monitor::control('payment.charge')->breaker('stripe')->run(fn (): string => 'no')); + + expect($parent->value)->toBe('queued: stripe'); + }); + + it('does not let the parent retry a child that exhausted its own attempts', function (): void { + Sleep::fake(); + $childRuns = 0; + + $parent = Monitor::control('order.place')->retry(times: 3, backoffMs: 0) + ->attempt(function () use (&$childRuns): string { + return Monitor::control('payment.charge')->retry(times: 1, backoffMs: 0)->run(function () use (&$childRuns): string { + $childRuns++; + + throw new Fatal('always'); + }); + }); + + expect($childRuns)->toBe(2) + ->and($parent->attempts)->toBe(1) + ->and($parent->status)->toBe(Status::Escalated) + ->and(ChildEscalations::pointFor($parent->exception))->toBe('payment.charge'); + }); + + it('still lets the parent retry its own failures', function (): void { + Sleep::fake(); + $calls = 0; + + $parent = Monitor::control('order.place')->retry(times: 2, backoffMs: 0)->attempt(function () use (&$calls): string { + Monitor::control('payment.charge')->run(fn (): string => 'fine'); + $calls++; + + if ($calls < 3) { + throw new Fatal('parent flake'); + } + + return 'ok'; + }); + + expect($parent->attempts)->toBe(3)->and($parent->status)->toBe(Status::Succeeded); + }); + + it('records the child outcome as escalated even when the parent recovers', function (): void { + $child = null; + + Monitor::control('order.place') + ->recover(CardDeclined::class, fn (): string => 'held') + ->run(function () use (&$child): string { + $child = Monitor::control('payment.charge')->attempt(fn () => throw new CardDeclined); + + if ($child->escalated()) { + throw $child->exception; + } + + return 'placed'; + }); + + expect($child)->toBeInstanceOf(Outcome::class) + ->and($child->status)->toBe(Status::Escalated); + }); +}); diff --git a/tests/Feature/OncePolicyTest.php b/tests/Feature/OncePolicyTest.php new file mode 100644 index 0000000..d1935f7 --- /dev/null +++ b/tests/Feature/OncePolicyTest.php @@ -0,0 +1,83 @@ +once($key, ttl: 600); +} + +describe('once', function (): void { + it('refuses a second run for the same key inside the window', function (): void { + $calls = 0; + + expect(chargeOnce()->run(function () use (&$calls): int { + return ++$calls; + }))->toBe(1); + + $second = chargeOnce()->attempt(function () use (&$calls): int { + return ++$calls; + }); + + expect($calls)->toBe(1) + ->and($second->status)->toBe(Status::Escalated) + ->and($second->exception)->toBeInstanceOf(Duplicate::class) + ->and($second->exception->key)->toBe('invoice:1') + ->and($second->exception->originalRunId)->toHaveLength(26) + ->and($second->timeline[1]['event'])->toBe('once.duplicate'); + }); + + it('can be recovered from, and separate keys do not collide', function (): void { + chargeOnce()->run(fn (): int => 1); + + expect(chargeOnce()->recover(Duplicate::class, fn (Duplicate $e): string => "already charged ({$e->key})")->run(fn (): int => 2))->toBe('already charged (invoice:1)') + ->and(chargeOnce('invoice:2')->run(fn (): int => 3))->toBe(3); + }); + + it('releases the key when the run fails inside the policies, and keeps it when ensure() fails after', function (): void { + chargeOnce()->attempt(fn () => throw new Fatal('gateway down')); + expect(chargeOnce()->run(fn (): string => 'retried'))->toBe('retried'); + + chargeOnce('invoice:9')->ensure(fn (): bool => false, 'not settled')->attempt(fn (): string => 'charged'); + expect(chargeOnce('invoice:9')->attempt(fn (): string => 'again')->exception)->toBeInstanceOf(Duplicate::class); + }); + + it('releases the key when a declared risk fires, since nothing was done', function (): void { + chargeOnce()->recover(CardDeclined::class, fn (): string => 'declined')->run(fn () => throw new CardDeclined); + + expect(chargeOnce()->run(fn (): string => 'second try'))->toBe('second try'); + }); + + it('expires with the window and honours the configured prefix and store', function (): void { + config()->set('monitor.once.prefix', 'custom:'); + + chargeOnce()->run(fn (): int => 1); + + expect(Cache::has('custom:payment.charge:invoice:1'))->toBeTrue(); + + $this->travel(601)->seconds(); + + expect(chargeOnce()->run(fn (): int => 2))->toBe(2) + ->and(Once::key('k', 5)->describe())->toBe(['type' => 'once', 'key' => 'k', 'ttl' => 5]) + ->and(Monitor::control('a.b')->once('k')->describe()['policies'][0]['type'])->toBe('once'); + }); + + it('runs before the breaker so a refused run never claims the key', function (): void { + Monitor::breaker()->open('stripe', 60); + + chargeOnce()->breaker('stripe')->attempt(fn (): int => 1); + + Monitor::breaker()->close('stripe'); + + expect(chargeOnce()->breaker('stripe')->run(fn (): int => 2))->toBe(2); + }); +}); diff --git a/tests/Feature/ProviderTest.php b/tests/Feature/ProviderTest.php new file mode 100644 index 0000000..cab1ace --- /dev/null +++ b/tests/Feature/ProviderTest.php @@ -0,0 +1,32 @@ +artisan('about', ['--only' => 'monitor']) + ->expectsOutputToContain('Monitor') + ->expectsOutputToContain('Outcome Store') + ->expectsOutputToContain('external, database, messaging, internal') + ->assertSuccessful(); + }); + + it('aliases the middleware', function (): void { + $aliases = resolve('router')->getMiddleware(); + + expect($aliases['monitor.trace'])->toBe(StartTrace::class)->and($aliases['monitor.breakers'])->toBe(CheckBreakers::class); + }); + + it('returns the same fake when faked twice and reports it', function (): void { + $first = Monitor::fake(); + + expect(Monitor::isFake())->toBeTrue() + ->and(Monitor::fake())->toBe($first) + ->and($first)->toBeInstanceOf(MonitorFake::class); + }); +}); diff --git a/tests/Feature/QueueTest.php b/tests/Feature/QueueTest.php new file mode 100644 index 0000000..3421b7c --- /dev/null +++ b/tests/Feature/QueueTest.php @@ -0,0 +1,116 @@ +set('monitor.domains.map', ['Workbench\\Monitor\\ControlPoints\\' => null]); +}); + +describe('dispatchable control points', function (): void { + it('pushes a job named and tagged after the point', function (): void { + Queue::fake(); + + ChargeCard::dispatch(48211, 12900)->onQueue('payments'); + + Queue::assertPushedOn('payments', RunControlPoint::class, fn (RunControlPoint $job): bool => $job->displayName() === 'payment.charge' + && $job->tags() === ['monitor:payment.charge', 'domain:Payments'] + && $job->point instanceof ChargeCard); + }); + + it('runs the point when the job is handled and records the outcome', function (): void { + Monitor::fake(); + + ChargeCard::dispatchSync(1, 100); + + Monitor::assertSucceeded('payment.charge', fn ($o): bool => $o->context === ['invoice' => 1, 'amount' => 100]); + }); + + it('fails the job with the escaped exception when the point escalates', function (): void { + StripeClient::$charge = fn () => throw new RuntimeException('gateway exploded'); + + expect(fn (): ?\Kirschbaum\Monitor\Outcome => (new RunControlPoint(new ChargeCard(1, 1)))->handle())->toThrow(RuntimeException::class, 'gateway exploded'); + }); + + it('releases the job for the breaker retry-after when refused, unless told not to', function (): void { + Monitor::breaker()->open('stripe', 45); + + $queued = Mockery::mock(Job::class); + $queued->shouldReceive('release')->once()->with(45); + $queued->shouldReceive('isReleased')->andReturn(true); + + $job = new RunControlPoint(new ChargeCard(1, 1)); + $job->setJob($queued); + + expect($job->handle()?->refused())->toBeTrue(); + + expect(fn (): ?\Kirschbaum\Monitor\Outcome => (new RunControlPoint(new ChargeCard(1, 1), releaseWhenRefused: false))->handle())->toThrow(BreakerOpen::class); + }); +}); + +describe('WaitForBreaker middleware', function (): void { + it('releases while the circuit is open and passes through when closed', function (): void { + $job = new class + { + public ?int $releasedFor = null; + + public function release(int $delay): void + { + $this->releasedFor = $delay; + } + }; + $ran = false; + $next = function (object $j) use (&$ran): string { + $ran = true; + + return 'ran'; + }; + + Monitor::breaker()->open('efiling', 30); + expect((new WaitForBreaker('efiling'))->handle($job, $next))->toBeNull()->and($job->releasedFor)->toBe(30)->and($ran)->toBeFalse(); + + Monitor::breaker()->close('efiling'); + expect((new WaitForBreaker('efiling'))->handle($job, $next))->toBe('ran'); + + Monitor::breaker()->open('efiling', 60); + expect((new WaitForBreaker('efiling', minimumDelay: 100))->handle($job, $next))->toBeNull()->and($job->releasedFor)->toBe(100) + ->and((new WaitForBreaker('efiling'))->handle(new stdClass, $next))->toBeNull(); + }); +}); + +describe('stack hand-off', function (): void { + it('starts a job with an empty stack and the dispatching run id', function (): void { + $stack = resolve(ControlStack::class); + $stack->push('order.place', 'run-1', 'App\\X'); + $stack->push('payment.charge', 'run-2', 'App\\Y'); + + resolve(PicksUpJobTrace::class)->handle(); + + expect($stack->depth())->toBe(0) + ->and($stack->dispatchedFrom())->toBe('run-2') + ->and(Context::get('dispatched_from_run'))->toBe('run-2') + ->and(Context::get('control_point'))->toBeNull() + ->and(Monitor::trace()->hasStarted())->toBeTrue(); + + $outcome = Monitor::control('search.index')->attempt(fn (): int => 1); + expect($outcome->parentId)->toBeNull()->and($outcome->stack)->toBe(['search.index']); + }); + + it('does nothing to a job that inherited no stack', function (): void { + resolve(PicksUpJobTrace::class)->handle(); + + expect(resolve(ControlStack::class)->dispatchedFrom())->toBeNull(); + }); +}); diff --git a/tests/Feature/RecordsTest.php b/tests/Feature/RecordsTest.php new file mode 100644 index 0000000..83063dd --- /dev/null +++ b/tests/Feature/RecordsTest.php @@ -0,0 +1,182 @@ +logs()->filter(fn (LogEntry $log): bool => ($log->context['event'] ?? null) === $event)->values()->all(); +} + +describe('recorder', function (): void { + it('writes started and ended for a success, with the schema fields', function (): void { + Monitor::control('payment.charge', 'App\\Services\\Payments\\StripeCharger')->with(['invoice' => 7])->run(fn (): string => 'ok'); + + $started = recordsFor('point.started'); + $ended = recordsFor('point.ended'); + + expect($started)->toHaveCount(1)->and($ended)->toHaveCount(1); + + $s = $started[0]; + expect($s->level)->toBe('debug') + ->and($s->message)->toBe('[Payments:StripeCharger] payment.charge started') + ->and($s->context)->toMatchArray([ + 'schema' => Record::SCHEMA, + 'event' => 'point.started', + 'point' => 'payment.charge', + 'domain' => 'Payments', + 'origin' => 'App\\Services\\Payments\\StripeCharger', + 'stack' => ['payment.charge'], + 'attempt' => 1, + 'context' => ['invoice' => 7], + ]) + ->and($s->context['run_id'])->toHaveLength(26) + ->and($s->context['trace_id'])->toMatch('/^[0-9a-f]{32}$/'); + + $e = $ended[0]; + expect($e->level)->toBe('info') + ->and($e->message)->toMatch('/^\[Payments:StripeCharger\] payment\.charge succeeded in [0-9.]+ms$/') + ->and($e->context['status'])->toBe('succeeded') + ->and($e->context['attempts'])->toBe(1) + ->and($e->context)->not->toHaveKey('exception'); + }); + + it('records recovery with the risk and the summarised exception', function (): void { + Monitor::control('payment.charge')->recover(CardDeclined::class, fn (): string => 'declined')->run(fn () => throw new CardDeclined('stolen')); + + $r = recordsFor('point.recovered')[0]; + + expect($r->level)->toBe('warning') + ->and($r->message)->toContain('recovered from '.CardDeclined::class) + ->and($r->context['risk'])->toBe(CardDeclined::class) + ->and($r->context['exception'])->toMatchArray(['class' => CardDeclined::class, 'message' => 'Card declined: stolen']) + ->and($r->context['exception'])->not->toHaveKey('trace') + ->and(recordsFor('point.ended')[0]->context['status'])->toBe('recovered'); + }); + + it('records escalation, a failing escalation handler, retries and limits', function (): void { + Sleep::fake(); + + Monitor::control('payment.charge')->retry(times: 1, backoffMs: 5, jitter: false)->within(0.00001)->escalate(BrokenEscalation::class) + ->attempt(fn () => throw new Fatal('boom')); + + $retried = recordsFor('point.retried')[0]; + $limit = recordsFor('point.limit')[0]; + $escalated = recordsFor('point.escalated')[0]; + $failed = recordsFor('escalation.failed')[0]; + + expect($retried->level)->toBe('notice') + ->and($retried->context)->toMatchArray(['attempt' => 1, 'backoff_ms' => 5]) + ->and($retried->message)->toContain('attempt 1 failed with '.Fatal::class.', retrying after 5ms') + ->and($limit->level)->toBe('warning') + ->and($limit->context['limit']['name'])->toBe('duration') + ->and($limit->message)->toContain('breached the duration limit') + ->and($escalated->level)->toBe('error') + ->and($escalated->message)->toContain('escalated '.Fatal::class.' after 2 attempt(s)') + ->and($escalated->context['status'])->toBe('escalated') + ->and($failed->level)->toBe('critical') + ->and($failed->context['escalation_exception']['class'])->toBe(LogicException::class) + ->and($failed->message)->toContain('escalation handler threw'); + }); + + it('records refusals and breaker transitions', function (): void { + Monitor::breaker()->open('stripe', 30); + Monitor::control('payment.charge')->breaker('stripe')->attempt(fn (): int => 1); + Monitor::breaker()->close('stripe'); + + $refused = recordsFor('point.refused')[0]; + $opened = recordsFor('breaker.opened')[0]; + $closed = recordsFor('breaker.closed')[0]; + + expect($refused->level)->toBe('warning') + ->and($refused->context['breaker'])->toBe(['name' => 'stripe', 'state' => 'open', 'retry_after_s' => 30]) + ->and($refused->message)->toContain('refused: breaker stripe is open') + ->and($opened->level)->toBe('error') + ->and($opened->context['breaker'])->toMatchArray(['name' => 'stripe', 'state' => 'open', 'open_for' => 30]) + ->and($closed->message)->toBe('[Monitor] breaker stripe is now closed'); + }); + + it('redacts context and exception messages but leaves identifiers alone', function (): void { + Monitor::control('payment.charge')->with(['email' => 'bob@example.com', 'invoice' => 42, 'password' => 'hunter2']) + ->attempt(fn () => throw new Fatal('card 4111 1111 1111 1111 failed for bob@example.com')); + + $ended = recordsFor('point.ended')[0]; + + expect($ended->context['context']['invoice'])->toBe(42) + ->and($ended->context['context']['password'])->toBe('[REDACTED]') + ->and($ended->context['context']['email'])->not->toBe('bob@example.com') + ->and($ended->context['context'])->not->toHaveKey('_redacted') + ->and($ended->context['exception']['message'])->not->toContain('4111 1111 1111 1111') + ->and($ended->context['run_id'])->toHaveLength(26) + ->and($ended->context['trace_id'])->toMatch('/^[0-9a-f]{32}$/'); + }); + + it('can turn redaction off and honours the configured channel and levels', function (): void { + config()->set('monitor.records.redaction'); + config()->set('monitor.records.channel', 'audit'); + config()->set('monitor.records.levels', ['point.ended' => 'notice'] + config('monitor.records.levels')); + config()->set('logging.channels.audit', ['driver' => 'single', 'path' => storage_path('logs/audit.log')]); + + Monitor::control('payment.charge')->with(['password' => 'hunter2'])->run(fn (): int => 1); + + $ended = recordsFor('point.ended', 'audit')[0] ?? null; + + expect($ended)->not->toBeNull() + ->and($ended->level)->toBe('notice') + ->and($ended->context['context']['password'])->toBe('hunter2') + ->and(Recorder::level('unknown.event'))->toBe('info'); + }); +}); + +describe('exception summary', function (): void { + it('summarises without a trace by default and includes previous', function (): void { + $summary = ExceptionSummary::from(new Fatal('outer', 7, new CardDeclined)); + + expect($summary)->toMatchArray(['class' => Fatal::class, 'message' => 'outer', 'code' => 7]) + ->and($summary['line'])->toBeInt() + ->and($summary['previous'])->toBe(['class' => CardDeclined::class, 'message' => 'Card declined: do_not_honor']) + ->and($summary)->not->toHaveKey('trace'); + }); + + it('includes a capped trace when configured always, or in debug', function (): void { + config()->set('monitor.records.exception_trace', 'always'); + config()->set('monitor.records.exception_trace_lines', 2); + + $summary = ExceptionSummary::from(new Fatal('x')); + + expect($summary['trace'])->toHaveCount(2)->and($summary['trace_truncated'])->toBeGreaterThan(0); + + config()->set('monitor.records.exception_trace', 'debug'); + config()->set('app.debug', false); + expect(ExceptionSummary::from(new Fatal('x')))->not->toHaveKey('trace'); + + config()->set('app.debug', true); + expect(ExceptionSummary::from(new Fatal('x')))->toHaveKey('trace'); + }); +}); + +describe('redaction helper', function (): void { + it('passes records through untouched when no profile is configured', function (): void { + config()->set('monitor.records.redaction', ''); + + $record = ['context' => ['password' => 'x'], 'exception' => ['message' => 'secret']]; + + expect(Redaction::record($record))->toBe($record)->and(Redaction::profile())->toBeNull(); + }); +}); diff --git a/tests/Feature/RetryAndTransactionTest.php b/tests/Feature/RetryAndTransactionTest.php new file mode 100644 index 0000000..98d6c53 --- /dev/null +++ b/tests/Feature/RetryAndTransactionTest.php @@ -0,0 +1,159 @@ +retry(times: 2, backoffMs: 100, jitter: false)->attempt(failingTimes(2)); + + expect($outcome->status)->toBe(Status::Succeeded) + ->and($outcome->value)->toBe('ok after 3') + ->and($outcome->attempts)->toBe(3); + + Event::assertDispatchedTimes(PointRetried::class, 2); + Event::assertDispatched(PointRetried::class, fn (PointRetried $e): bool => $e->attempt === 1 && $e->backoffMs === 100); + Event::assertDispatched(PointRetried::class, fn (PointRetried $e): bool => $e->attempt === 2 && $e->backoffMs === 200); + Sleep::assertSleptTimes(2); + }); + + it('rethrows the original exception when retries are exhausted', function (): void { + $outcome = Monitor::control('search.index')->retry(times: 1, backoffMs: 0)->attempt(failingTimes(5)); + + expect($outcome->status)->toBe(Status::Escalated) + ->and($outcome->exception)->toBeInstanceOf(Flaky::class) + ->and($outcome->attempts)->toBe(2); + }); + + it('only retries the exceptions it is told to', function (): void { + $outcome = Monitor::control('search.index')->retry(times: 3, backoffMs: 0, on: [Flaky::class])->attempt(failingTimes(1, new Fatal('fatal'))); + + expect($outcome->attempts)->toBe(1)->and($outcome->exception)->toBeInstanceOf(Fatal::class); + }); + + it('never retries an excepted exception', function (): void { + $outcome = Monitor::control('search.index')->retry(times: 3, backoffMs: 0, except: [Fatal::class])->attempt(failingTimes(1, new Fatal('fatal'))); + + expect($outcome->attempts)->toBe(1); + }); + + it('caps attempts with the attempts() limit whatever the policy asks', function (): void { + $outcome = Monitor::control('search.index')->retry(times: 10, backoffMs: 0)->attempts(2)->attempt(failingTimes(5)); + + expect($outcome->attempts)->toBe(2) + ->and($outcome->breachedLimit('attempts'))->toBeTrue() + ->and($outcome->limitsBreached['attempts'])->toBe(['threshold' => 2, 'actual' => 2]); + }); + + it('adds jitter between half and the full delay', function (): void { + $delays = []; + Sleep::whenFakingSleep(function ($duration) use (&$delays): void { + $delays[] = $duration->totalMilliseconds; + }); + + Monitor::control('search.index')->retry(times: 3, backoffMs: 100, multiplier: 1.0, jitter: true)->attempt(failingTimes(3)); + + expect($delays)->toHaveCount(3); + + foreach ($delays as $delay) { + expect($delay)->toBeGreaterThanOrEqual(50)->toBeLessThanOrEqual(100); + } + }); + + it('accepts a policy object directly and replaces the same type', function (): void { + $outcome = Monitor::control('search.index')->retry(times: 0)->policy(Retry::times(2))->attempt(failingTimes(2)); + + expect($outcome->attempts)->toBe(3)->and($outcome->status)->toBe(Status::Succeeded); + }); + + it('does not retry a corrected ensure failure', function (): void { + $calls = 0; + + $outcome = Monitor::control('search.index')->retry(times: 3, backoffMs: 0) + ->ensure(fn (): bool => false, 'never') + ->attempt(function () use (&$calls): int { + return ++$calls; + }); + + expect($calls)->toBe(1)->and($outcome->attempts)->toBe(1)->and($outcome->escalated())->toBeTrue(); + }); +}); + +describe('transaction', function (): void { + beforeEach(function (): void { + Schema::create('ledger', function ($table): void { + $table->id(); + $table->string('entry'); + }); + }); + + it('commits on success', function (): void { + Monitor::control('ledger.post')->transaction()->run(fn () => DB::table('ledger')->insert(['entry' => 'a'])); + + expect(DB::table('ledger')->count())->toBe(1); + }); + + it('rolls back on failure and rethrows', function (): void { + $outcome = Monitor::control('ledger.post')->transaction()->attempt(function (): void { + DB::table('ledger')->insert(['entry' => 'a']); + + throw new Fatal('no'); + }); + + expect($outcome->escalated())->toBeTrue()->and(DB::table('ledger')->count())->toBe(0); + }); + + it('retries deadlocks as whole transactions and counts every attempt', function (): void { + $outcome = Monitor::control('ledger.post')->transaction(retries: 2)->attempt(function (): string { + DB::table('ledger')->insert(['entry' => 'try']); + + if (DB::table('ledger')->count() > 0 && DB::table('ledger')->count() < 3) { + // Each attempt is rolled back, so the count never reaches 3; + // count the real attempts through the outcome instead. + } + + static $tries = 0; + $tries++; + + if ($tries < 3) { + throw new DeadlockException('deadlock'); + } + + return 'posted'; + }); + + expect($outcome->status)->toBe(Status::Succeeded) + ->and($outcome->attempts)->toBe(3) + ->and(DB::table('ledger')->count())->toBe(1); + }); + + it('does not retry a non-deadlock failure inside a transaction', function (): void { + $outcome = Monitor::control('ledger.post')->transaction(retries: 2)->attempt(failingTimes(1, new Fatal('x'))); + + expect($outcome->attempts)->toBe(1); + }); + + it('runs the transaction inside the retry policy, so each retry gets a fresh transaction', function (): void { + $control = Monitor::control('ledger.post')->transaction(retries: 0)->retry(times: 1, backoffMs: 0); + $types = array_map(fn (array $p): string => $p['type'], $control->describe()['policies']); + + expect($types)->toBe(['retry', 'transaction']); + }); +}); diff --git a/tests/Feature/StartMonitorTraceTest.php b/tests/Feature/StartMonitorTraceTest.php deleted file mode 100644 index a826b0e..0000000 --- a/tests/Feature/StartMonitorTraceTest.php +++ /dev/null @@ -1,100 +0,0 @@ -hasStarted())->toBeFalse(); - - $middleware = new StartMonitorTrace; - $request = Request::create('/test'); - - $response = $middleware->handle($request, function ($request) { - // Verify trace is started during request - expect(Monitor::trace()->hasStarted())->toBeTrue(); - - return new Response('Test response'); - }); - - // Verify trace ID is set in response header - expect($response->headers->get('X-Trace-Id'))->not()->toBeEmpty(); - expect($response->headers->get('X-Trace-Id'))->toBe(Monitor::trace()->id()); -}); - -it('uses existing trace ID from header', function () { - $existingTraceId = Str::uuid()->toString(); - - $middleware = new StartMonitorTrace; - $request = Request::create('/test'); - $request->headers->set('X-Trace-Id', $existingTraceId); - - $response = $middleware->handle($request, function ($request) use ($existingTraceId) { - expect(Monitor::trace()->id())->toBe($existingTraceId); - - return new Response('Test response'); - }); - - expect($response->headers->get('X-Trace-Id'))->toBe($existingTraceId); -}); - -it('preserves existing trace if already started', function () { - // Start a trace manually - Monitor::trace()->start(); - $originalTraceId = Monitor::trace()->id(); - - $middleware = new StartMonitorTrace; - $request = Request::create('/test'); - $request->headers->set('X-Trace-Id', Str::uuid()->toString()); - - $response = $middleware->handle($request, function ($request) use ($originalTraceId) { - // Should keep the original trace ID, not the one from header - expect(Monitor::trace()->id())->toBe($originalTraceId); - - return new Response('Test response'); - }); - - expect($response->headers->get('X-Trace-Id'))->toBe($originalTraceId); -}); - -it('uses custom trace header from config', function () { - config()->set('monitor.trace_header', 'Custom-Trace-Header'); - - $customTraceId = Str::uuid()->toString(); - - $middleware = new StartMonitorTrace; - $request = Request::create('/test'); - $request->headers->set('Custom-Trace-Header', $customTraceId); - - $response = $middleware->handle($request, function ($request) use ($customTraceId) { - expect(Monitor::trace()->id())->toBe($customTraceId); - - return new Response('Test response'); - }); - - expect($response->headers->get('Custom-Trace-Header'))->toBe($customTraceId); -}); - -it('generates new trace ID when header value is invalid', function () { - $middleware = new StartMonitorTrace; - $request = Request::create('/test'); - // Set invalid trace ID (array instead of string) - $request->headers->set('X-Trace-Id', ['invalid', 'trace', 'id']); - - $response = $middleware->handle($request, function ($request) { - expect(Monitor::trace()->hasStarted())->toBeTrue(); - expect(Monitor::trace()->id())->toBeString(); - - return new Response('Test response'); - }); - - expect($response->headers->get('X-Trace-Id'))->toBeString(); - expect($response->headers->get('X-Trace-Id'))->not()->toBe('Array'); -}); diff --git a/tests/Feature/StoreBootTest.php b/tests/Feature/StoreBootTest.php new file mode 100644 index 0000000..a278a32 --- /dev/null +++ b/tests/Feature/StoreBootTest.php @@ -0,0 +1,36 @@ +set('monitor.records.store.enabled', true); + } + + public function test_the_migration_is_publishable_but_not_auto_loaded(): void + { + $paths = $this->app->make(Migrator::class)->paths(); + + $this->assertNotContains(realpath(__DIR__.'/../../database/migrations'), array_map(realpath(...), $paths)); + + $published = ServiceProvider::pathsToPublish(MonitorServiceProvider::class, 'monitor-migrations'); + + $this->assertNotEmpty($published); + $this->assertSame(realpath(__DIR__.'/../../database/migrations'), realpath((string) array_key_first($published))); + $this->assertFileExists(__DIR__.'/../../database/migrations/2026_09_14_000000_create_monitor_outcomes_table.php'); + } +} diff --git a/tests/Feature/StoreTest.php b/tests/Feature/StoreTest.php new file mode 100644 index 0000000..6a474df --- /dev/null +++ b/tests/Feature/StoreTest.php @@ -0,0 +1,194 @@ +set('monitor.records.store.enabled', true); + (require glob(__DIR__.'/../../database/migrations/*_create_monitor_outcomes_table.php')[0])->up(); +} + +describe('outcome store', function (): void { + it('does nothing while disabled', function (): void { + Monitor::control('payment.charge')->run(fn (): int => 1); + + expect(resolve(StoreOutcomes::class)->pending())->toBe(0)->and(resolve(OutcomeStore::class)->enabled())->toBeFalse(); + }); + + it('buffers outcomes and writes them when the application terminates', function (): void { + enableStore(); + Sleep::fake(); + + Monitor::control('payment.charge', 'App\\Services\\Payments\\Charger')->with(['invoice' => 1, 'password' => 'x'])->run(fn (): int => 1); + Monitor::control('payment.charge')->retry(times: 1, backoffMs: 0)->recover(CardDeclined::class, fn (): null => null)->run(fn () => throw new CardDeclined('lost')); + Monitor::control('order.place')->attempt(fn (): string => Monitor::control('payment.charge')->run(fn () => throw new Fatal('secret token Xk9vB2mQ7pL4sN8wR1tY6uZ3aC5dF0gHjE2bV'))); + + expect(resolve(StoreOutcomes::class)->pending())->toBe(4)->and(DB::table('monitor_outcomes')->count())->toBe(0); + + resolve(StoreOutcomes::class)->flush(); + + expect(resolve(StoreOutcomes::class)->pending())->toBe(0)->and(DB::table('monitor_outcomes')->count())->toBe(4); + + $rows = resolve(OutcomeStore::class)->recent(); + $first = array_values(array_filter($rows, fn (array $r): bool => $r['status'] === 'succeeded'))[0]; + $recovered = array_values(array_filter($rows, fn (array $r): bool => $r['status'] === 'recovered'))[0]; + $child = array_values(array_filter($rows, fn (array $r): bool => $r['point'] === 'payment.charge' && $r['status'] === 'escalated'))[0]; + $parent = array_values(array_filter($rows, fn (array $r): bool => $r['point'] === 'order.place'))[0]; + + expect($first)->toMatchArray(['point' => 'payment.charge', 'domain' => 'Payments', 'attempts' => 1]) + ->and($first['context']['invoice'])->toBe(1) + ->and($first['context']['password'])->toBe('[REDACTED]') + ->and($first['stack'])->toBe(['payment.charge']) + ->and($recovered['recovered_from'])->toBe(CardDeclined::class) + ->and($recovered['attempts'])->toBe(2) + ->and($recovered['exception_class'])->toBe(CardDeclined::class) + ->and($child['parent_run_id'])->toBe($parent['run_id']) + ->and($child['exception_message'])->not->toContain('Xk9vB2mQ7pL4sN8wR1tY6uZ3aC5dF0gHjE2bV') + ->and($parent['status'])->toBe('escalated'); + }); + + it('writes when the buffer limit is reached', function (): void { + enableStore(); + + for ($i = 0; $i < StoreOutcomes::BUFFER_LIMIT; $i++) { + Monitor::control('payment.charge')->run(fn (): int => 1); + } + + expect(resolve(StoreOutcomes::class)->pending())->toBe(0)->and(DB::table('monitor_outcomes')->count())->toBe(StoreOutcomes::BUFFER_LIMIT); + }); + + it('flushes at the end of a request and a command', function (): void { + enableStore(); + Route::get('/charge', fn (): int => Monitor::control('payment.charge')->run(fn (): int => 1)); + + $this->get('/charge')->assertOk(); + resolve(Kernel::class)->terminate(request(), new Response); + + expect(DB::table('monitor_outcomes')->count())->toBe(1); + + Monitor::control('payment.charge')->run(fn (): int => 1); + resolve('events')->dispatch(new Looping('sync', 'default')); + + expect(DB::table('monitor_outcomes')->count())->toBe(2); + + $console = resolve(Illuminate\Contracts\Console\Kernel::class); + $input = new ArrayInput(['command' => 'list']); + $console->handle($input, new NullOutput); + Monitor::control('payment.charge')->run(fn (): int => 1); + $console->terminate($input, 0); + + expect(DB::table('monitor_outcomes')->count())->toBe(3); + }); + + it('flushes after a queued job finishes', function (): void { + enableStore(); + + Monitor::control('payment.charge')->run(fn (): int => 1); + resolve('events')->dispatch(new JobProcessed('sync', Mockery::mock(Job::class))); + + expect(DB::table('monitor_outcomes')->count())->toBe(1); + }); + + it('never lets a failing write reach the control point, and reports once', function (): void { + config()->set('monitor.records.store.enabled', true); + LogFake::bind(); + + expect(Monitor::control('payment.charge')->run(fn (): string => 'still fine'))->toBe('still fine'); + expect(resolve(StoreOutcomes::class)->flush())->toBe(0); + + Monitor::control('payment.charge')->run(fn (): int => 1); + expect(resolve(StoreOutcomes::class)->flush())->toBe(0); + + Log::assertLoggedTimes(fn ($log): bool => str_contains($log->message, 'outcome store could not be written'), 1); + }); + + it('filters, tallies, reports last seen and prunes', function (): void { + enableStore(); + + Monitor::control('payment.charge')->run(fn (): int => 1); + Monitor::control('payment.charge')->attempt(fn () => throw new Fatal); + Monitor::control('search.index', 'App\\Services\\Search\\Indexer')->run(fn (): int => 1); + resolve(StoreOutcomes::class)->flush(); + + $store = resolve(OutcomeStore::class); + + expect($store->recent(['point' => 'payment.charge']))->toHaveCount(2) + ->and($store->recent(['status' => 'escalated']))->toHaveCount(1) + ->and($store->recent(['domain' => 'Search']))->toHaveCount(1) + ->and($store->recent(['trace' => Monitor::trace()->id()]))->toHaveCount(3) + ->and($store->recent([], 1))->toHaveCount(1) + ->and($store->recent(['since' => now()->addMinute()]))->toHaveCount(0) + ->and($store->tally())->toBe(['escalated' => 1, 'succeeded' => 2]) + ->and($store->tally('search.index'))->toBe(['succeeded' => 1]) + ->and($store->tally('payment.charge', now()->addMinute()))->toBe([]) + ->and(array_keys($store->lastSeen()))->toBe(['payment.charge', 'search.index']) + ->and($store->lastSeen()['payment.charge']['status'])->toBe('escalated'); + + $this->travel(31)->days(); + Monitor::control('payment.charge')->run(fn (): int => 1); + resolve(StoreOutcomes::class)->flush(); + + expect($store->prune())->toBe(3)->and(DB::table('monitor_outcomes')->count())->toBe(1); + }); + + it('does not buffer while disabled even if the listener is bound', function (): void { + Monitor::control('payment.charge')->run(fn (): int => 1); + + expect(resolve(StoreOutcomes::class)->flush())->toBe(0); + }); +}); + +describe('commands', function (): void { + it('refuses monitor:outcomes while the store is disabled', function (): void { + $this->artisan('monitor:outcomes')->assertFailed(); + }); + + it('lists outcomes as a table and as json', function (): void { + enableStore(); + Monitor::control('payment.charge')->attempt(fn () => throw new Fatal('bad')); + resolve(StoreOutcomes::class)->flush(); + + $this->artisan('monitor:outcomes')->expectsOutputToContain('payment.charge')->assertSuccessful(); + $this->artisan('monitor:outcomes', ['--json' => true, '--status' => 'escalated'])->expectsOutputToContain('"status": "escalated"')->assertSuccessful(); + $this->artisan('monitor:outcomes', ['--json' => true])->expectsOutputToContain('Fixtures')->assertSuccessful(); + $this->artisan('monitor:outcomes', ['--since' => '1m', '--point' => 'nope.nope'])->expectsOutputToContain('No outcomes')->assertSuccessful(); + $this->artisan('monitor:outcomes', ['--since' => 'yesterday'])->assertExitCode(2); + }); + + it('parses since windows', function (): void { + expect(OutcomesCommand::parseSince('15m')?->diffInMinutes(now()))->toBeGreaterThanOrEqual(14) + ->and(OutcomesCommand::parseSince('2h')?->diffInHours(now()))->toBeGreaterThanOrEqual(1) + ->and(OutcomesCommand::parseSince('7d')?->diffInDays(now()))->toBeGreaterThanOrEqual(6) + ->and(OutcomesCommand::parseSince('x'))->toBeNull(); + }); + + it('prunes from the command', function (): void { + $this->artisan('monitor:prune')->expectsOutputToContain('disabled')->assertSuccessful(); + + enableStore(); + Monitor::control('payment.charge')->run(fn (): int => 1); + resolve(StoreOutcomes::class)->flush(); + $this->travel(2)->days(); + + $this->artisan('monitor:prune', ['--days' => 1])->expectsOutputToContain('Pruned 1')->assertSuccessful(); + }); +}); diff --git a/tests/Feature/StructuredLoggerTest.php b/tests/Feature/StructuredLoggerTest.php index 84a8ba3..47aa679 100644 --- a/tests/Feature/StructuredLoggerTest.php +++ b/tests/Feature/StructuredLoggerTest.php @@ -2,259 +2,55 @@ declare(strict_types=1); -namespace Tests\Feature; - use Illuminate\Support\Facades\Log; -use InvalidArgumentException; -use Kirschbaum\Monitor\StructuredLogger; - -it('resolves origin from string with prefix and separator', function () { - config()->set('monitor.prefix', 'Monitor'); - config()->set('monitor.origin_separator', ':'); - config()->set('monitor.origin_path_wrapper', 'none'); - - $logger = StructuredLogger::from('TestOrigin'); - - expect($logger->getOrigin())->toBe('Monitor:TestOrigin'); -}); - -it('resolves origin with replacers and separator', function () { - config()->set('monitor.prefix', null); - config()->set('monitor.origin_separator', ':'); - config()->set('monitor.origin_path_replacers', [ - 'App\\' => 'Monitor\\', - ]); - config()->set('monitor.origin_path_wrapper', 'none'); - - $logger = StructuredLogger::from('App\\Something\\Example'); - - expect($logger->getOrigin())->toBe('Monitor:Something:Example'); -}); - -it('applies origin wrapper from config', function () { - config()->set('monitor.prefix', 'Src'); - config()->set('monitor.origin_separator', ':'); - config()->set('monitor.origin_path_wrapper', 'curly'); - - $logger = StructuredLogger::from('Logger'); - - expect($logger->getOrigin())->toBe('{Src:Logger}'); -}); - -it('resolves origin from object', function () { - config()->set('monitor.prefix', null); - config()->set('monitor.origin_path_wrapper', 'none'); - - $obj = new class {}; - $logger = StructuredLogger::from($obj); - - expect($logger->getOrigin())->toContain('class@anonymous'); - expect($logger->getOrigin())->toContain('.php'); -}); - -it('uses empty origin when given empty input', function () { - config()->set('monitor.prefix', null); - config()->set('monitor.origin_path_wrapper', 'none'); - - $logger = StructuredLogger::from(''); - expect($logger->getOrigin())->toBe(''); -}); - -it('handles various empty and whitespace inputs correctly', function () { - config()->set('monitor.prefix', 'Test'); - config()->set('monitor.origin_path_wrapper', 'square'); - - // Empty string gets wrapped but remains semantically empty - $logger1 = StructuredLogger::from(''); - expect($logger1->getOrigin())->toBe('[]'); - - // Whitespace should be processed normally (not treated as empty) - $logger2 = StructuredLogger::from(' '); - expect($logger2->getOrigin())->toBe('[Test: ]'); - - // Single spaces should not be treated as empty - $logger3 = StructuredLogger::from(' '); - expect($logger3->getOrigin())->toBe('[Test: ]'); - - // Test with no wrapper - empty should stay empty - config()->set('monitor.origin_path_wrapper', 'none'); - $logger4 = StructuredLogger::from(''); - expect($logger4->getOrigin())->toBe(''); -}); - -it('logs with enriched context', function () { - $this->setupLogMocking(); - - config()->set('monitor.origin_path_wrapper', 'square'); - - Log::shouldReceive('info') - ->once() - ->withArgs(function ($message, $context) { - if ($message !== '[Test] Enriched logging works') { - return false; +use Kirschbaum\Monitor\Facades\Monitor; +use Kirschbaum\Monitor\Logging\StructuredLogger; +use TiMacDonald\Log\LogEntry; +use TiMacDonald\Log\LogFake; + +beforeEach(function (): void { + LogFake::bind(); + config()->set('monitor.domains.map', ['App\\Services\\' => null]); +}); + +describe('Monitor::log()', function (): void { + it('prefixes the message and adds origin and domain to the context', function (): void { + $logger = Monitor::log('App\\Services\\Payments\\StripeCharger'); + + expect($logger)->toBeInstanceOf(StructuredLogger::class) + ->and($logger->origin())->toBe('App\\Services\\Payments\\StripeCharger') + ->and($logger->domain())->toBe('Payments') + ->and($logger->prefix())->toBe('[Payments:StripeCharger]'); + + $logger->info('charging', ['invoice' => 7]); + $logger->error('failed'); + + Log::assertLogged(fn (LogEntry $log): bool => $log->level === 'info' + && $log->message === '[Payments:StripeCharger] charging' + && $log->context === ['invoice' => 7, 'origin' => 'App\\Services\\Payments\\StripeCharger', 'domain' => 'Payments']); + Log::assertLogged(fn (LogEntry $log): bool => $log->level === 'error' && $log->message === '[Payments:StripeCharger] failed'); + }); + + it('accepts an object origin, carries context with with(), and targets a channel', function (): void { + config()->set('logging.channels.audit', ['driver' => 'single', 'path' => storage_path('logs/audit.log')]); + + $logger = Monitor::log($this)->with(['request' => 'r1'])->channel('audit'); + + $logger->warning('careful', ['step' => 2]); + $logger->log('notice', 'noted'); + $logger->log(new class implements Stringable + { + public function __toString(): string + { + return 'debug'; } - - $expectedKeys = ['level', 'event', 'message', 'trace_id', 'context', 'timestamp', 'duration_ms', 'memory_mb']; - foreach ($expectedKeys as $key) { - if (! array_key_exists($key, $context)) { - return false; - } - } - - return $context['level'] === 'info' - && $context['event'] === 'Test:info' - && $context['message'] === '[Test] Enriched logging works'; - }); - - StructuredLogger::from('Test')->info('Enriched logging works'); -}); - -it('logs all levels correctly', function () { - $this->setupLogMocking(); - - config()->set('monitor.origin_path_wrapper', 'none'); - - $levels = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']; - - foreach ($levels as $level) { - Log::shouldReceive($level) - ->once() - ->withArgs(fn ($message, $context) => $context['level'] === $level); - } - - $logger = StructuredLogger::from('MultiLevel'); - - foreach ($levels as $level) { - $logger->$level("Level: $level"); - } -}); - -it('logs via the generic log() method', function () { - $this->setupLogMocking(); - - config()->set('monitor.origin_path_wrapper', 'none'); - - Log::shouldReceive('info') - ->once() - ->withArgs(fn ($message, $context) => $context['level'] === 'info'); - - StructuredLogger::from('Generic')->log('info', 'Generic log message'); -}); - -// NEW CRITICAL TESTS: - -it('throws exception when prefix is not a string', function () { - config()->set('monitor.prefix', 123); // Non-string prefix - config()->set('monitor.origin_path_wrapper', 'none'); - - expect(fn () => StructuredLogger::from('Test')) - ->toThrow(InvalidArgumentException::class, 'Monitor prefix must be a string'); -}); - -it('handles empty replacer arrays', function () { - config()->set('monitor.prefix', null); - config()->set('monitor.origin_path_replacers', []); - config()->set('monitor.origin_separator', ':'); - config()->set('monitor.origin_path_wrapper', 'none'); - - $logger = StructuredLogger::from('App\\Test\\Example'); - - expect($logger->getOrigin())->toBe('App:Test:Example'); -}); - -it('handles custom context in logging', function () { - $this->setupLogMocking(); - - config()->set('monitor.origin_path_wrapper', 'none'); - - $customContext = ['user_id' => 123, 'action' => 'test']; - - Log::shouldReceive('info') - ->once() - ->withArgs(function ($message, $context) use ($customContext) { - return $context['context'] === $customContext; - }); - - StructuredLogger::from('Test')->info('Test message', $customContext); -}); - -it('handles different origin wrapper types', function () { - $wrapperTests = [ - 'none' => 'Test', - 'square' => '[Test]', - 'curly' => '{Test}', - 'round' => '(Test)', - 'angle' => '', - 'double' => '"Test"', - 'single' => "'Test'", - ]; - - foreach ($wrapperTests as $wrapper => $expected) { - config()->set('monitor.origin_path_wrapper', $wrapper); - $logger = StructuredLogger::from('Test'); - expect($logger->getOrigin())->toBe($expected); - } -}); - -it('applies multiple replacements in order using strtr', function () { - config()->set('monitor.prefix', null); - config()->set('monitor.origin_path_replacers', [ - 'App\\' => 'First\\', - 'Test\\' => 'Second\\', // This WILL be applied with strtr() - ]); - config()->set('monitor.origin_path_wrapper', 'none'); - - $logger = StructuredLogger::from('App\\Test\\Example'); - - // With strtr(), both replacements apply: App\ -> First\, then Test\ -> Second\ - expect($logger->getOrigin())->toBe('First:Second:Example'); -}); - -it('processes replacers in order with cascading functionality', function () { - config()->set('monitor.prefix', null); - config()->set('monitor.origin_path_replacers', [ - 'App\\Services\\' => 'Svc\\', // More specific match - 'App\\' => 'Application\\', // Less specific - will still apply to other parts - 'Services\\' => 'Service\\', // Could apply to different parts - ]); - config()->set('monitor.origin_path_wrapper', 'none'); - - $logger = StructuredLogger::from('App\\Services\\UserService'); - - // With strtr(), the most specific match wins for overlapping patterns - // App\Services\ -> Svc\ (this prevents App\ from matching the same area) - expect($logger->getOrigin())->toBe('Svc:UserService'); - - // Test another case where multiple non-overlapping replacements apply - config()->set('monitor.origin_path_replacers', [ - 'App\\Http\\Controllers\\Admin\\' => 'Admin\\', - 'App\\Http\\Controllers\\' => 'Web\\', - 'App\\Jobs\\' => 'Job\\', - ]); - - $logger2 = StructuredLogger::from('App\\Http\\Controllers\\Admin\\UserController'); - // Most specific match wins: App\Http\Controllers\Admin\ -> Admin\ - expect($logger2->getOrigin())->toBe('Admin:UserController'); - - $logger3 = StructuredLogger::from('App\\Http\\Controllers\\HomeController'); - // App\Http\Controllers\ -> Web\ - expect($logger3->getOrigin())->toBe('Web:HomeController'); - - $logger4 = StructuredLogger::from('App\\Jobs\\ProcessOrder'); - // App\Jobs\ -> Job\ - expect($logger4->getOrigin())->toBe('Job:ProcessOrder'); -}); - -it('uses most specific matching replacer with strtr', function () { - config()->set('monitor.prefix', null); - config()->set('monitor.origin_path_replacers', [ - 'App\\' => 'First\\', - 'App\\Test\\' => 'Second\\', // This is more specific and WILL be used with strtr() - ]); - config()->set('monitor.origin_path_wrapper', 'none'); - - $logger = StructuredLogger::from('App\\Test\\Example'); - - // strtr() chooses the longest/most specific match: App\Test\ -> Second\ - expect($logger->getOrigin())->toBe('Second:Example'); + }, 'stringable level'); + $logger->log(42, 'bad level falls back to info'); + + Log::channel('audit')->assertLogged(fn (LogEntry $log): bool => $log->level === 'warning' && $log->context['request'] === 'r1' && $log->context['step'] === 2 && $log->context['origin'] === static::class); + Log::channel('audit')->assertLogged(fn (LogEntry $log): bool => $log->level === 'notice' && str_ends_with($log->message, 'noted')); + Log::channel('audit')->assertLogged(fn (LogEntry $log): bool => $log->level === 'debug'); + Log::channel('audit')->assertLogged(fn (LogEntry $log): bool => $log->level === 'info' && str_ends_with($log->message, 'bad level falls back to info')); + Log::assertNothingLogged(); + }); }); diff --git a/tests/Feature/StructuredLoggingTapTest.php b/tests/Feature/StructuredLoggingTapTest.php deleted file mode 100644 index 9e27499..0000000 --- a/tests/Feature/StructuredLoggingTapTest.php +++ /dev/null @@ -1,199 +0,0 @@ -pushHandler($streamHandler); - $monologLogger->pushHandler($syslogHandler); - $monologLogger->pushHandler($nullHandler); - - // Create Laravel Logger wrapper - $logger = new Logger($monologLogger); - - // Apply the tap - $tap = new StructuredLoggingTap; - $tap($logger); - - // Verify that AbstractProcessingHandler instances have StructuredJsonFormatter - $handlers = $logger->getHandlers(); - - expect($handlers)->toHaveCount(3); - - // Handlers are in LIFO order: nullHandler[0], syslogHandler[1], streamHandler[2] - - // NullHandler (index 0) does NOT extend AbstractProcessingHandler - should NOT be affected - expect($handlers[0])->toBeInstanceOf(NullHandler::class); - - // SyslogHandler (index 1) extends AbstractProcessingHandler - should have formatter - expect($handlers[1])->toBeInstanceOf(SyslogHandler::class) - ->and($handlers[1]->getFormatter())->toBeInstanceOf(StructuredJsonFormatter::class); - - // StreamHandler (index 2) extends AbstractProcessingHandler - should have formatter - expect($handlers[2])->toBeInstanceOf(StreamHandler::class) - ->and($handlers[2]->getFormatter())->toBeInstanceOf(StructuredJsonFormatter::class); -}); - -it('only affects AbstractProcessingHandler instances', function () { - $monologLogger = new MonologLogger('test'); - - // Add only non-AbstractProcessingHandler - $nullHandler = new NullHandler; - $monologLogger->pushHandler($nullHandler); - - $logger = new Logger($monologLogger); - - // Store original handler reference - $originalHandler = $logger->getHandlers()[0]; - - // Apply the tap - $tap = new StructuredLoggingTap; - $tap($logger); - - // Handler should be unchanged since it's not AbstractProcessingHandler - $handlers = $logger->getHandlers(); - expect($handlers[0])->toBe($originalHandler); -}); - -it('handles empty handler list gracefully', function () { - $monologLogger = new MonologLogger('test'); - $logger = new Logger($monologLogger); - - // No handlers added - expect($logger->getHandlers())->toHaveCount(0); - - // Should not throw any errors - $tap = new StructuredLoggingTap; - $tap($logger); - - // Still no handlers - expect($logger->getHandlers())->toHaveCount(0); -}); - -it('can be invoked multiple times safely', function () { - $monologLogger = new MonologLogger('test'); - $streamHandler = new StreamHandler('php://memory'); - $monologLogger->pushHandler($streamHandler); - - $logger = new Logger($monologLogger); - - $tap = new StructuredLoggingTap; - - // Apply tap multiple times - $tap($logger); - $tap($logger); - $tap($logger); - - // Handler should still have StructuredJsonFormatter - $handlers = $logger->getHandlers(); - expect($handlers[0]->getFormatter())->toBeInstanceOf(StructuredJsonFormatter::class); - - // Should only have one handler still - expect($handlers)->toHaveCount(1); -}); - -it('works with mixed handler types', function () { - $monologLogger = new MonologLogger('test'); - - // Mix of different handler types - $streamHandler1 = new StreamHandler('php://memory'); - $streamHandler2 = new StreamHandler('php://stderr'); - $nullHandler = new NullHandler; - $syslogHandler = new SyslogHandler('app'); - - // Add in specific order to know the LIFO result - $monologLogger->pushHandler($streamHandler1); // Will be index 3 - $monologLogger->pushHandler($nullHandler); // Will be index 2 - $monologLogger->pushHandler($streamHandler2); // Will be index 1 - $monologLogger->pushHandler($syslogHandler); // Will be index 0 - - $logger = new Logger($monologLogger); - - $tap = new StructuredLoggingTap; - $tap($logger); - - $handlers = $logger->getHandlers(); - - // SyslogHandler (index 0) - AbstractProcessingHandler - should have formatter - expect($handlers[0])->toBeInstanceOf(SyslogHandler::class) - ->and($handlers[0]->getFormatter())->toBeInstanceOf(StructuredJsonFormatter::class); - - // StreamHandler (index 1) - AbstractProcessingHandler - should have formatter - expect($handlers[1])->toBeInstanceOf(StreamHandler::class) - ->and($handlers[1]->getFormatter())->toBeInstanceOf(StructuredJsonFormatter::class); - - // NullHandler (index 2) - should be unchanged (no getFormatter method) - expect($handlers[2])->toBeInstanceOf(NullHandler::class); - - // StreamHandler (index 3) - AbstractProcessingHandler - should have formatter - expect($handlers[3])->toBeInstanceOf(StreamHandler::class) - ->and($handlers[3]->getFormatter())->toBeInstanceOf(StructuredJsonFormatter::class); -}); - -it('creates new StructuredJsonFormatter instance for each handler', function () { - $monologLogger = new MonologLogger('test'); - - $streamHandler1 = new StreamHandler('php://memory'); - $streamHandler2 = new StreamHandler('php://stderr'); - - $monologLogger->pushHandler($streamHandler1); - $monologLogger->pushHandler($streamHandler2); - - $logger = new Logger($monologLogger); - - $tap = new StructuredLoggingTap; - $tap($logger); - - $handlers = $logger->getHandlers(); - - $formatter1 = $handlers[0]->getFormatter(); - $formatter2 = $handlers[1]->getFormatter(); - - // Both should be StructuredJsonFormatter but different instances - expect($formatter1)->toBeInstanceOf(StructuredJsonFormatter::class) - ->and($formatter2)->toBeInstanceOf(StructuredJsonFormatter::class) - ->and($formatter1)->not->toBe($formatter2); // Different instances -}); - -it('implements callable interface correctly', function () { - $tap = new StructuredLoggingTap; - - // Should be callable - expect(is_callable($tap))->toBeTrue(); - - // Should be invokable - expect(method_exists($tap, '__invoke'))->toBeTrue(); -}); - -it('validates input parameter type', function () { - $tap = new StructuredLoggingTap; - - // Create a real logger to ensure proper type - $monologLogger = new MonologLogger('test'); - $logger = new Logger($monologLogger); - - // Should accept Logger instance without error - just test it doesn't throw - $tap($logger); - - // If we get here, no exception was thrown - expect(true)->toBeTrue(); -}); diff --git a/tests/Feature/TracePropagationTest.php b/tests/Feature/TracePropagationTest.php new file mode 100644 index 0000000..7e90534 --- /dev/null +++ b/tests/Feature/TracePropagationTest.php @@ -0,0 +1,153 @@ +clear(); + + Route::middleware(StartTrace::class)->get('/traced', fn (): array => ['trace' => Monitor::trace()->current()]); + Route::middleware('monitor.breakers:stripe,efiling')->get('/guarded', fn (): string => 'through'); + Route::middleware('monitor.trace')->get('/aliased', fn (): array => ['trace' => Monitor::trace()->current()]); +}); + +describe('StartTrace middleware', function (): void { + it('starts a trace and returns it as traceparent and the legacy header', function (): void { + $response = $this->get('/traced')->assertOk(); + + $id = $response->json('trace'); + + expect($id)->toMatch('/^[0-9a-f]{32}$/') + ->and($response->headers->get('traceparent'))->toMatch("/^00-{$id}-[0-9a-f]{16}-01$/") + ->and($response->headers->get('X-Trace-Id'))->toBe($id); + }); + + it('adopts an incoming traceparent', function (): void { + $response = $this->withHeader('traceparent', '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01')->get('/traced'); + + expect($response->json('trace'))->toBe('0af7651916cd43dd8448eb211c80319c'); + }); + + it('adopts the legacy header, uuid or hex', function (): void { + expect($this->withHeader('X-Trace-Id', '9D2B4E8F-3A1C-4D5E-8F2A-1B3C4D5E6F7A')->get('/traced')->json('trace'))->toBe('9d2b4e8f3a1c4d5e8f2a1b3c4d5e6f7a'); + resolve(Trace::class)->clear(); + expect($this->withHeader('X-Trace-Id', 'ffffffffffffffffffffffffffffffff')->get('/traced')->json('trace'))->toBe('ffffffffffffffffffffffffffffffff'); + }); + + it('replaces an invalid incoming id and says so once at debug', function (string $header, string $value): void { + LogFake::bind(); + + $response = $this->withHeader($header, $value)->get('/traced'); + + expect($response->json('trace'))->toMatch('/^[0-9a-f]{32}$/')->and($response->json('trace'))->not->toBe($value); + Log::assertLogged(fn ($log): bool => $log->level === 'debug' && str_contains($log->message, 'replaced an invalid incoming')); + })->with([ + ['traceparent', '00-junk-junk-01'], + ['traceparent', ''], + ['X-Trace-Id', 'not a trace'], + ]); + + it('keeps a trace that already started', function (): void { + resolve(Trace::class)->override('abcdefabcdefabcdefabcdefabcdefab'); + + expect($this->withHeader('X-Trace-Id', 'ffffffffffffffffffffffffffffffff')->get('/traced')->json('trace'))->toBe('abcdefabcdefabcdefabcdefabcdefab'); + }); + + it('is registered under the monitor.trace alias', function (): void { + expect($this->get('/aliased')->json('trace'))->toMatch('/^[0-9a-f]{32}$/'); + }); + + it('honours configured header names', function (): void { + config()->set('monitor.trace.legacy_header', 'X-Request-Trace'); + + $response = $this->withHeader('X-Request-Trace', 'ffffffffffffffffffffffffffffffff')->get('/traced'); + + expect($response->json('trace'))->toBe('ffffffffffffffffffffffffffffffff') + ->and($response->headers->get('X-Request-Trace'))->toBe('ffffffffffffffffffffffffffffffff'); + }); +}); + +describe('outgoing propagation', function (): void { + it('adds both headers with Http::traced()', function (): void { + Http::fake(); + $id = resolve(Trace::class)->override('0af7651916cd43dd8448eb211c80319c'); + + Http::traced()->get('https://service-b.test/api'); + Http::baseUrl('https://service-c.test')->traced()->post('/x'); + + Http::assertSent(fn (ClientRequest $r): bool => $r->url() === 'https://service-b.test/api' + && str_starts_with($r->header('traceparent')[0], "00-{$id}-") + && $r->header('X-Trace-Id')[0] === $id); + Http::assertSent(fn (ClientRequest $r): bool => $r->url() === 'https://service-c.test/x' && $r->header('X-Trace-Id')[0] === $id); + }); +}); + +describe('command pickup', function (): void { + it('starts a trace when a command starts unless disabled', function (): void { + resolve(Trace::class)->clear(); + resolve(PicksUpCommandTrace::class)->handle(new CommandStarting('inspire', new ArrayInput([]), new NullOutput)); + expect(resolve(Trace::class)->hasStarted())->toBeTrue(); + + resolve(Trace::class)->clear(); + config()->set('monitor.trace.console', false); + resolve(PicksUpCommandTrace::class)->handle(new CommandStarting('inspire', new ArrayInput([]), new NullOutput)); + expect(resolve(Trace::class)->hasStarted())->toBeFalse(); + }); + + it('is registered on the console event', function (): void { + expect(resolve('events')->getListeners(CommandStarting::class))->not->toBeEmpty(); + }); +}); + +describe('queue pickup', function (): void { + it('starts a trace for a job that arrived without one', function (): void { + expect(resolve(Trace::class)->hasStarted())->toBeFalse(); + + resolve(PicksUpJobTrace::class)->handle(); + + expect(resolve(Trace::class)->hasStarted())->toBeTrue(); + }); + + it('is registered on the queue event', function (): void { + expect(resolve('events')->getListeners(JobProcessing::class))->not->toBeEmpty(); + }); +}); + +describe('CheckBreakers middleware', function (): void { + it('lets requests through when every circuit is closed', function (): void { + $this->get('/guarded')->assertOk()->assertSee('through'); + }); + + it('refuses with 503 and the real retry-after while a circuit is open', function (): void { + Monitor::breaker()->open('efiling', 45); + + $this->get('/guarded') + ->assertStatus(503) + ->assertHeader('Retry-After', '45') + ->assertHeader('X-Circuit-Breaker', 'efiling') + ->assertHeader('X-Circuit-Breaker-Status', 'open'); + }); + + it('does not consume the half-open probe', function (): void { + Monitor::breaker()->open('stripe', 10); + $this->travel(11)->seconds(); + + $this->get('/guarded')->assertOk(); + + expect(Monitor::breaker()->permit('stripe')->probe)->toBeTrue(); + }); +}); diff --git a/tests/Fixtures/BrokenEscalation.php b/tests/Fixtures/BrokenEscalation.php new file mode 100644 index 0000000..7976137 --- /dev/null +++ b/tests/Fixtures/BrokenEscalation.php @@ -0,0 +1,17 @@ +run(fn (): int => 1); + + $start = hrtime(true); + + for ($i = 0; $i < 200; $i++) { + $control->run(fn (): int => $i); + } + + $perRunMs = (hrtime(true) - $start) / 1e6 / 200; + + expect($perRunMs)->toBeLessThan(5.0); + })->skip(runningWithCoverage(), 'timing is meaningless under coverage instrumentation'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 044de62..1689c70 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,47 +1,41 @@ extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) - ->in('Feature', 'Unit'); - -/* -|-------------------------------------------------------------------------- -| Expectations -|-------------------------------------------------------------------------- -| -| When you're writing tests, you often need to check that values meet certain conditions. The -| "expect()" function gives you access to a set of "expectations" methods that you can use -| to assert different things. Of course, you may extend the Expectation API at any time. -| -*/ - -expect()->extend('toBeOne', function () { - return $this->toBe(1); -}); - -/* -|-------------------------------------------------------------------------- -| Functions -|-------------------------------------------------------------------------- -| -| While Pest is very powerful out-of-the-box, you may have some testing code specific to your -| project that you don't want to repeat in every file. Here you can also expose helpers as -| global functions to help you to reduce the number of lines of code in your test files. -| -*/ - -function something() +declare(strict_types=1); + +use Tests\Fixtures\Flaky; +use Tests\TestCase; + +pest()->extend(TestCase::class)->in('Feature', 'Unit', 'Performance'); + +/** + * Whether a coverage driver is actively instrumenting this run. Timing + * assertions skip themselves under instrumentation rather than flake. + */ +function runningWithCoverage(): bool +{ + if (extension_loaded('pcov') && (bool) ini_get('pcov.enabled')) { + return true; + } + + return extension_loaded('xdebug') + && str_contains((string) ini_get('xdebug.mode'), 'coverage'); +} + +/** + * A callback that throws the given exception the first $failures times it is called. + */ +function failingTimes(int $failures, ?Throwable $with = null): Closure { - // .. + $calls = 0; + $with ??= new Flaky('flaky'); + + return function () use (&$calls, $failures, $with): string { + $calls++; + + if ($calls <= $failures) { + throw $with; + } + + return "ok after {$calls}"; + }; } diff --git a/tests/TestCase.php b/tests/TestCase.php index 843d3a0..b2de373 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,12 +4,10 @@ namespace Tests; -use Closure; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Log; use Kirschbaum\Monitor\MonitorServiceProvider; use Kirschbaum\Redactor\RedactorServiceProvider; -use Mockery\MockInterface; +use Laravel\Mcp\Server\McpServiceProvider; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase as BaseTestCase; @@ -20,8 +18,9 @@ class TestCase extends BaseTestCase protected function getPackageProviders($app): array { return [ - MonitorServiceProvider::class, RedactorServiceProvider::class, + McpServiceProvider::class, + MonitorServiceProvider::class, ]; } @@ -31,35 +30,4 @@ protected function setUp(): void Http::preventingStrayRequests(); } - - /** - * Set up Log facade mocking to handle Laravel infrastructure calls without - * interfering with explicit test expectations for application logging. - */ - protected function setupLogMocking(): void - { - // Only mock the methods that Laravel's infrastructure may call in background - // during exception/deprecation handling, class loading, etc. - // These are NOT the methods we want to test explicitly in our application code - Log::shouldReceive('channel') - ->andReturnSelf() - ->byDefault(); - - // Laravel's HandleExceptions calls warning() for deprecation notices - Log::shouldReceive('warning') - ->andReturnSelf() - ->byDefault(); - } - - /** - * Force bind a mock into the container for app() calls with parameters. - */ - protected function mockBind(string $abstract, ?Closure $callback = null): MockInterface - { - $mock = $this->mock($abstract, $callback); - - app()->offsetSet($abstract, $mock); - - return $mock; - } } diff --git a/tests/Unit/CircuitBreakerStateTest.php b/tests/Unit/CircuitBreakerStateTest.php deleted file mode 100644 index bfa8dee..0000000 --- a/tests/Unit/CircuitBreakerStateTest.php +++ /dev/null @@ -1,448 +0,0 @@ - new CircuitBreakerState(failures: -1)) - ->toThrow(InvalidArgumentException::class, 'Failure count cannot be negative.'); - }); - - it('throws exception when lastFailureAt is negative', function () { - expect(fn () => new CircuitBreakerState(lastFailureAt: -1)) - ->toThrow(InvalidArgumentException::class, 'Last failure timestamp cannot be negative.'); - }); - - it('throws exception when decaySeconds is less than 1', function () { - expect(fn () => new CircuitBreakerState(decaySeconds: 0)) - ->toThrow(InvalidArgumentException::class, 'Decay seconds must be at least 1.'); - }); - - it('allows valid parameters', function () { - $state = new CircuitBreakerState( - failures: 5, - lastFailureAt: time(), - decaySeconds: 300 - ); - - expect($state)->toBeInstanceOf(CircuitBreakerState::class) - ->and($state->failures)->toBe(5) - ->and($state->decaySeconds)->toBe(300); - }); - - it('allows null decaySeconds', function () { - $state = new CircuitBreakerState(decaySeconds: null); - - expect($state->decaySeconds)->toBeNull(); - }); - }); - - describe('hasExpired Method', function () { - it('returns false when decaySeconds is zero', function () { - $state = new CircuitBreakerState(failures: 1, lastFailureAt: time() - 100); - - expect($state->hasExpired(0))->toBeFalse(); - }); - - it('returns false when decaySeconds is negative', function () { - $state = new CircuitBreakerState(failures: 1, lastFailureAt: time() - 100); - - expect($state->hasExpired(-10))->toBeFalse(); - }); - - it('returns true when lastFailureAt is zero', function () { - $state = new CircuitBreakerState(failures: 1, lastFailureAt: 0); - - expect($state->hasExpired(300))->toBeTrue(); - }); - - it('returns true when enough time has elapsed', function () { - $state = new CircuitBreakerState(failures: 1, lastFailureAt: time() - 400); - - expect($state->hasExpired(300))->toBeTrue(); - }); - - it('returns false when not enough time has elapsed', function () { - $state = new CircuitBreakerState(failures: 1, lastFailureAt: time() - 100); - - expect($state->hasExpired(300))->toBeFalse(); - }); - }); - - describe('Static Factory Methods', function () { - it('creates healthy state with decaySeconds', function () { - $state = CircuitBreakerState::healthy(300); - - expect($state->failures)->toBe(0) - ->and($state->lastFailureAt)->toBe(0) - ->and($state->decaySeconds)->toBe(300) - ->and($state->isHealthy())->toBeTrue(); - }); - - it('creates healthy state without decaySeconds', function () { - $state = CircuitBreakerState::healthy(); - - expect($state->failures)->toBe(0) - ->and($state->lastFailureAt)->toBe(0) - ->and($state->decaySeconds)->toBeNull() - ->and($state->isHealthy())->toBeTrue(); - }); - - it('creates failed state with threshold', function () { - $beforeTime = time(); - $state = CircuitBreakerState::failed(5, 300); - $afterTime = time(); - - expect($state->failures)->toBe(5) - ->and($state->lastFailureAt)->toBeGreaterThanOrEqual($beforeTime) - ->and($state->lastFailureAt)->toBeLessThanOrEqual($afterTime) - ->and($state->decaySeconds)->toBe(300) - ->and($state->isHealthy())->toBeFalse(); - }); - - it('creates failed state with minimum threshold of 1', function () { - $state = CircuitBreakerState::failed(0); - - expect($state->failures)->toBe(1); - }); - - it('creates failed state with default threshold', function () { - $state = CircuitBreakerState::failed(); - - expect($state->failures)->toBe(999); - }); - }); - - describe('fromArray Method', function () { - it('creates state from valid array data', function () { - $data = [ - 'failures' => 3, - 'last_failure_at' => 1234567890, - 'decay_seconds' => 300, - ]; - - $state = CircuitBreakerState::fromArray($data); - - expect($state->failures)->toBe(3) - ->and($state->lastFailureAt)->toBe(1234567890) - ->and($state->decaySeconds)->toBe(300); - }); - - it('handles missing keys with defaults', function () { - $data = []; - - $state = CircuitBreakerState::fromArray($data); - - expect($state->failures)->toBe(0) - ->and($state->lastFailureAt)->toBe(0) - ->and($state->decaySeconds)->toBeNull(); - }); - - it('handles numeric string values', function () { - $data = [ - 'failures' => '5', - 'last_failure_at' => '1234567890', - 'decay_seconds' => '300', - ]; - - $state = CircuitBreakerState::fromArray($data); - - expect($state->failures)->toBe(5) - ->and($state->lastFailureAt)->toBe(1234567890) - ->and($state->decaySeconds)->toBe(300); - }); - - it('handles non-numeric values with defaults', function () { - $data = [ - 'failures' => 'invalid', - 'last_failure_at' => 'invalid', - 'decay_seconds' => 'invalid', - ]; - - $state = CircuitBreakerState::fromArray($data); - - expect($state->failures)->toBe(0) - ->and($state->lastFailureAt)->toBe(0) - ->and($state->decaySeconds)->toBeNull(); - }); - - it('handles null decay_seconds', function () { - $data = [ - 'failures' => 2, - 'last_failure_at' => 1234567890, - 'decay_seconds' => null, - ]; - - $state = CircuitBreakerState::fromArray($data); - - expect($state->decaySeconds)->toBeNull(); - }); - - it('handles float values by converting to int', function () { - $data = [ - 'failures' => 3.7, - 'last_failure_at' => 1234567890.5, - 'decay_seconds' => 300.9, - ]; - - $state = CircuitBreakerState::fromArray($data); - - expect($state->failures)->toBe(3) - ->and($state->lastFailureAt)->toBe(1234567890) - ->and($state->decaySeconds)->toBe(300); - }); - }); - - describe('extractRequiredIntegerValue Method', function () { - it('returns integer value when present', function () { - $data = ['key' => 42]; - - // Use reflection to test private method - $reflection = new \ReflectionClass(CircuitBreakerState::class); - $method = $reflection->getMethod('extractRequiredIntegerValue'); - $method->setAccessible(true); - - $result = $method->invokeArgs(null, [$data, 'key', 0]); - - expect($result)->toBe(42); - }); - - it('converts numeric string to integer', function () { - $data = ['key' => '42']; - - $reflection = new \ReflectionClass(CircuitBreakerState::class); - $method = $reflection->getMethod('extractRequiredIntegerValue'); - $method->setAccessible(true); - - $result = $method->invokeArgs(null, [$data, 'key', 0]); - - expect($result)->toBe(42); - }); - - it('returns default when key is missing', function () { - $data = []; - - $reflection = new \ReflectionClass(CircuitBreakerState::class); - $method = $reflection->getMethod('extractRequiredIntegerValue'); - $method->setAccessible(true); - - $result = $method->invokeArgs(null, [$data, 'missing_key', 99]); - - expect($result)->toBe(99); - }); - - it('returns default when value is not numeric', function () { - $data = ['key' => 'not_numeric']; - - $reflection = new \ReflectionClass(CircuitBreakerState::class); - $method = $reflection->getMethod('extractRequiredIntegerValue'); - $method->setAccessible(true); - - $result = $method->invokeArgs(null, [$data, 'key', 99]); - - expect($result)->toBe(99); - }); - }); - - describe('extractOptionalIntegerValue Method', function () { - it('returns integer value when present', function () { - $data = ['key' => 42]; - - $reflection = new \ReflectionClass(CircuitBreakerState::class); - $method = $reflection->getMethod('extractOptionalIntegerValue'); - $method->setAccessible(true); - - $result = $method->invokeArgs(null, [$data, 'key']); - - expect($result)->toBe(42); - }); - - it('converts numeric string to integer', function () { - $data = ['key' => '42']; - - $reflection = new \ReflectionClass(CircuitBreakerState::class); - $method = $reflection->getMethod('extractOptionalIntegerValue'); - $method->setAccessible(true); - - $result = $method->invokeArgs(null, [$data, 'key']); - - expect($result)->toBe(42); - }); - - it('returns null when key is missing', function () { - $data = []; - - $reflection = new \ReflectionClass(CircuitBreakerState::class); - $method = $reflection->getMethod('extractOptionalIntegerValue'); - $method->setAccessible(true); - - $result = $method->invokeArgs(null, [$data, 'missing_key']); - - expect($result)->toBeNull(); - }); - - it('returns null when value is explicitly null', function () { - $data = ['key' => null]; - - $reflection = new \ReflectionClass(CircuitBreakerState::class); - $method = $reflection->getMethod('extractOptionalIntegerValue'); - $method->setAccessible(true); - - $result = $method->invokeArgs(null, [$data, 'key']); - - expect($result)->toBeNull(); - }); - - it('returns null when value is not numeric', function () { - $data = ['key' => 'not_numeric']; - - $reflection = new \ReflectionClass(CircuitBreakerState::class); - $method = $reflection->getMethod('extractOptionalIntegerValue'); - $method->setAccessible(true); - - $result = $method->invokeArgs(null, [$data, 'key']); - - expect($result)->toBeNull(); - }); - }); - - describe('toArray Method', function () { - it('converts state to array format', function () { - $state = new CircuitBreakerState( - failures: 5, - lastFailureAt: 1234567890, - decaySeconds: 300 - ); - - $array = $state->toArray(); - - expect($array)->toBe([ - 'failures' => 5, - 'last_failure_at' => 1234567890, - 'decay_seconds' => 300, - ]); - }); - - it('converts state with null decaySeconds to array', function () { - $state = new CircuitBreakerState( - failures: 3, - lastFailureAt: 1234567890, - decaySeconds: null - ); - - $array = $state->toArray(); - - expect($array)->toBe([ - 'failures' => 3, - 'last_failure_at' => 1234567890, - 'decay_seconds' => null, - ]); - }); - - it('converts default state to array', function () { - $state = new CircuitBreakerState; - - $array = $state->toArray(); - - expect($array)->toBe([ - 'failures' => 0, - 'last_failure_at' => 0, - 'decay_seconds' => null, - ]); - }); - }); - - describe('Round-trip Conversion', function () { - it('maintains data integrity through toArray and fromArray', function () { - $originalState = new CircuitBreakerState( - failures: 7, - lastFailureAt: 1234567890, - decaySeconds: 600 - ); - - $array = $originalState->toArray(); - $reconstructedState = CircuitBreakerState::fromArray($array); - - expect($reconstructedState->failures)->toBe($originalState->failures) - ->and($reconstructedState->lastFailureAt)->toBe($originalState->lastFailureAt) - ->and($reconstructedState->decaySeconds)->toBe($originalState->decaySeconds); - }); - - it('maintains data integrity with null decaySeconds', function () { - $originalState = new CircuitBreakerState( - failures: 2, - lastFailureAt: 1234567890, - decaySeconds: null - ); - - $array = $originalState->toArray(); - $reconstructedState = CircuitBreakerState::fromArray($array); - - expect($reconstructedState->failures)->toBe($originalState->failures) - ->and($reconstructedState->lastFailureAt)->toBe($originalState->lastFailureAt) - ->and($reconstructedState->decaySeconds)->toBe($originalState->decaySeconds); - }); - }); - - describe('Additional Edge Cases', function () { - it('handles recordFailure with null decaySeconds', function () { - $state = new CircuitBreakerState(failures: 2, decaySeconds: 300); - - $newState = $state->recordFailure(null); - - expect($newState->failures)->toBe(3) - ->and($newState->decaySeconds)->toBe(300); // Should preserve original - }); - - it('handles recordFailure with new decaySeconds', function () { - $state = new CircuitBreakerState(failures: 2, decaySeconds: 300); - - $newState = $state->recordFailure(600); - - expect($newState->failures)->toBe(3) - ->and($newState->decaySeconds)->toBe(600); // Should use new value - }); - - it('handles exceedsThreshold with zero threshold', function () { - $state = new CircuitBreakerState(failures: 0); - - expect($state->exceedsThreshold(0))->toBeFalse(); // max(1, 0) = 1, so 0 < 1 - }); - - it('handles exceedsThreshold with negative threshold', function () { - $state = new CircuitBreakerState(failures: 0); - - expect($state->exceedsThreshold(-5))->toBeFalse(); // max(1, -5) = 1, so 0 < 1 - }); - - it('handles exceedsThreshold with exact threshold match', function () { - $state = new CircuitBreakerState(failures: 5); - - expect($state->exceedsThreshold(5))->toBeTrue(); // 5 >= 5 - }); - - it('handles exceedsThreshold with failures above threshold', function () { - $state = new CircuitBreakerState(failures: 10); - - expect($state->exceedsThreshold(5))->toBeTrue(); // 10 >= 5 - }); - - it('handles isHealthy with non-zero failures', function () { - $state = new CircuitBreakerState(failures: 1); - - expect($state->isHealthy())->toBeFalse(); - }); - - it('handles isHealthy with zero failures', function () { - $state = new CircuitBreakerState(failures: 0); - - expect($state->isHealthy())->toBeTrue(); - }); - }); -}); diff --git a/tests/Unit/CircuitBreakerTest.php b/tests/Unit/CircuitBreakerTest.php deleted file mode 100644 index c5d7409..0000000 --- a/tests/Unit/CircuitBreakerTest.php +++ /dev/null @@ -1,152 +0,0 @@ - new CircuitBreaker(0)) - ->toThrow(InvalidArgumentException::class, 'Threshold must be at least 1.'); -}); - -it('throws exception when threshold is negative', function () { - expect(fn () => new CircuitBreaker(-1)) - ->toThrow(InvalidArgumentException::class, 'Threshold must be at least 1.'); -}); - -it('throws exception when decay seconds is less than 1', function () { - expect(fn () => new CircuitBreaker(5, 0)) - ->toThrow(InvalidArgumentException::class, 'Decay seconds must be at least 1.'); -}); - -it('throws exception when decay seconds is negative', function () { - expect(fn () => new CircuitBreaker(5, -1)) - ->toThrow(InvalidArgumentException::class, 'Decay seconds must be at least 1.'); -}); - -it('throws exception when both threshold and decay seconds are invalid', function () { - expect(fn () => new CircuitBreaker(0, 0)) - ->toThrow(InvalidArgumentException::class, 'Threshold must be at least 1.'); -}); - -it('creates circuit breaker successfully with valid parameters', function () { - $circuitBreaker = new CircuitBreaker(5, 300); - - expect($circuitBreaker)->toBeInstanceOf(CircuitBreaker::class); -}); - -it('creates circuit breaker successfully with minimum valid parameters', function () { - $circuitBreaker = new CircuitBreaker(1, 1); - - expect($circuitBreaker)->toBeInstanceOf(CircuitBreaker::class); -}); - -it('creates circuit breaker successfully with default parameters', function () { - $circuitBreaker = new CircuitBreaker; - - expect($circuitBreaker)->toBeInstanceOf(CircuitBreaker::class); -}); - -it('returns false when circuit breaker state has expired', function () { - $circuitBreaker = new CircuitBreaker; - - // Record a failure first to create a state - $circuitBreaker->recordFailure('test_breaker', 1); // 1 second decay - - // Wait to ensure expiration - sleep(2); - - // This should trigger the expired state path and reset the breaker - $isOpen = $circuitBreaker->isOpen('test_breaker', 1, 1); - - expect($isOpen)->toBeFalse(); -}); - -it('returns true when circuit is closed', function () { - $circuitBreaker = new CircuitBreaker; - - // New circuit breaker should be closed - expect($circuitBreaker->isClosed('test_breaker'))->toBeTrue(); -}); - -it('returns false when circuit is open', function () { - $circuitBreaker = new CircuitBreaker; - - // Force open the circuit breaker - $circuitBreaker->forceOpen('test_breaker'); - - expect($circuitBreaker->isClosed('test_breaker'))->toBeFalse(); -}); - -it('returns zero failure count when no state exists', function () { - $circuitBreaker = new CircuitBreaker; - - expect($circuitBreaker->getFailureCount('non_existent'))->toBe(0); -}); - -it('returns actual failure count when state exists', function () { - $circuitBreaker = new CircuitBreaker; - - // Record multiple failures - $circuitBreaker->recordFailure('test_breaker'); - $circuitBreaker->recordFailure('test_breaker'); - $circuitBreaker->recordFailure('test_breaker'); - - expect($circuitBreaker->getFailureCount('test_breaker'))->toBe(3); -}); - -it('returns null for last failure time when no state exists', function () { - $circuitBreaker = new CircuitBreaker; - - expect($circuitBreaker->getLastFailureTime('non_existent'))->toBeNull(); -}); - -it('returns null for last failure time when state has zero timestamp', function () { - $circuitBreaker = new CircuitBreaker; - - // Create a healthy state (no failures recorded) - $circuitBreaker->recordSuccess('test_breaker'); - - expect($circuitBreaker->getLastFailureTime('test_breaker'))->toBeNull(); -}); - -it('returns actual timestamp for last failure time when failures exist', function () { - $circuitBreaker = new CircuitBreaker; - - $beforeTime = time(); - $circuitBreaker->recordFailure('test_breaker'); - $afterTime = time(); - - $lastFailureTime = $circuitBreaker->getLastFailureTime('test_breaker'); - - expect($lastFailureTime)->toBeInt() - ->and($lastFailureTime)->toBeGreaterThanOrEqual($beforeTime) - ->and($lastFailureTime)->toBeLessThanOrEqual($afterTime); -}); - -it('returns true for isHealthy when no state exists', function () { - $circuitBreaker = new CircuitBreaker; - - expect($circuitBreaker->isHealthy('non_existent'))->toBeTrue(); -}); - -it('returns true for isHealthy when state exists but is healthy', function () { - $circuitBreaker = new CircuitBreaker; - - // Record a failure then a success to reset to healthy state - $circuitBreaker->recordFailure('test_breaker'); - $circuitBreaker->recordSuccess('test_breaker'); - - expect($circuitBreaker->isHealthy('test_breaker'))->toBeTrue(); -}); - -it('returns false for isHealthy when state has failures', function () { - $circuitBreaker = new CircuitBreaker; - - $circuitBreaker->recordFailure('test_breaker'); - - expect($circuitBreaker->isHealthy('test_breaker'))->toBeFalse(); -}); diff --git a/tests/Unit/ControlStackTest.php b/tests/Unit/ControlStackTest.php new file mode 100644 index 0000000..0e7a8d4 --- /dev/null +++ b/tests/Unit/ControlStackTest.php @@ -0,0 +1,41 @@ +current())->toBeNull()->and($stack->currentRunId())->toBeNull(); + + $stack->push('a.b', 'run-1'); + $stack->push('c.d', 'run-2'); + + expect($stack->depth())->toBe(2) + ->and($stack->names())->toBe(['a.b', 'c.d']) + ->and($stack->currentRunId())->toBe('run-2') + ->and(Context::get('control_point'))->toBe('c.d'); + + $stack->pop(); + + expect(Context::get('control_point'))->toBe('a.b')->and($stack->currentRunId())->toBe('run-1'); + + $stack->pop(); + + expect($stack->isInside())->toBeFalse()->and(Context::get('control_point'))->toBeNull(); + }); + + it('ignores malformed entries and can be cleared', function (): void { + Context::addHidden(ControlStack::KEY, ['junk', ['point' => 1], ['point' => 'ok.one', 'run_id' => 'r']]); + $stack = resolve(ControlStack::class); + + expect($stack->all())->toBe([['point' => 'ok.one', 'run_id' => 'r', 'origin' => null]]); + + $stack->clear(); + + expect($stack->all())->toBe([]); + }); +}); diff --git a/tests/Unit/ControlledContextTest.php b/tests/Unit/ControlledContextTest.php deleted file mode 100644 index b29adbd..0000000 --- a/tests/Unit/ControlledContextTest.php +++ /dev/null @@ -1,69 +0,0 @@ -context = new ControlledContext; - }); - - it('starts with no active context', function () { - expect($this->context->isInside())->toBeFalse() - ->and($this->context->current())->toBeNull(); - }); - - it('tracks entering a controlled block', function () { - $this->context->enter('test-block', 'uuid-123'); - - expect($this->context->isInside())->toBeTrue() - ->and($this->context->current())->toBe([ - 'name' => 'test-block', - 'uuid' => 'uuid-123', - ]); - }); - - it('clears context when exiting', function () { - $this->context->enter('test-block', 'uuid-123'); - $this->context->exit(); - - expect($this->context->isInside())->toBeFalse() - ->and($this->context->current())->toBeNull(); - }); - - it('prevents nested controlled blocks', function () { - $this->context->enter('outer-block', 'uuid-outer'); - - expect(fn () => $this->context->enter('inner-block', 'uuid-inner')) - ->toThrow( - NestedControlledBlockException::class, - "Nested Controlled block detected: attempted to start 'inner-block' while already in 'outer-block' (uuid: uuid-outer)." - ); - }); - - it('allows sequential blocks after exit', function () { - $this->context->enter('first-block', 'uuid-first'); - $this->context->exit(); - - // Should not throw - $this->context->enter('second-block', 'uuid-second'); - - expect($this->context->current())->toBe([ - 'name' => 'second-block', - 'uuid' => 'uuid-second', - ]); - }); - - it('handles multiple exit calls gracefully', function () { - $this->context->enter('test-block', 'uuid-123'); - $this->context->exit(); - $this->context->exit(); // Should not cause errors - - expect($this->context->isInside())->toBeFalse() - ->and($this->context->current())->toBeNull(); - }); -}); diff --git a/tests/Unit/ControlledFailureMetaTest.php b/tests/Unit/ControlledFailureMetaTest.php deleted file mode 100644 index 81f6f1e..0000000 --- a/tests/Unit/ControlledFailureMetaTest.php +++ /dev/null @@ -1,178 +0,0 @@ - 123], - breakerTripped: false, - uncaught: false, - ); - - expect($meta->succeeded())->toBeTrue() - ->and($meta->failed())->toBeFalse() - ->and($meta->wasRetried())->toBeFalse(); - }); - - it('creates meta with failure (with exception)', function () { - $exception = new RuntimeException('Test error'); - - $meta = new ControlledFailureMeta( - name: 'failing-operation', - id: 'block-456', - traceId: 'trace-789', - attempt: 2, - durationMs: 150.0, - exception: $exception, - context: ['operation' => 'critical'], - breakerTripped: true, - uncaught: true, - ); - - expect($meta->failed())->toBeTrue() - ->and($meta->succeeded())->toBeFalse() - ->and($meta->wasRetried())->toBeTrue(); - }); - - it('detects retry attempts correctly', function () { - $firstAttempt = new ControlledFailureMeta( - name: 'test', - id: 'id', - traceId: 'trace', - attempt: 1, - durationMs: 100.0, - exception: null - ); - - $secondAttempt = new ControlledFailureMeta( - name: 'test', - id: 'id', - traceId: 'trace', - attempt: 2, - durationMs: 100.0, - exception: null - ); - - expect($firstAttempt->wasRetried())->toBeFalse() - ->and($secondAttempt->wasRetried())->toBeTrue(); - }); - - it('converts success to array correctly', function () { - $meta = new ControlledFailureMeta( - name: 'success-operation', - id: 'block-success', - traceId: 'trace-success', - attempt: 1, - durationMs: 300.75, - exception: null, - context: ['user_id' => 456, 'action' => 'create'], - breakerTripped: false, - uncaught: false, - ); - - $array = $meta->toArray(); - - expect($array)->toBe([ - 'controlled_block' => 'success-operation', - 'controlled_block_id' => 'block-success', - 'trace_id' => 'trace-success', - 'attempt' => 1, - 'duration_ms' => 300.75, - 'status' => 'succeeded', - 'breaker_tripped' => false, - 'uncaught' => false, - 'exception' => null, - 'block_context' => ['user_id' => 456, 'action' => 'create'], - ]); - }); - - it('converts failure to array correctly', function () { - $exception = new RuntimeException('Database connection failed'); - - $meta = new ControlledFailureMeta( - name: 'db-operation', - id: 'block-error', - traceId: 'trace-error', - attempt: 3, - durationMs: 2500.0, - exception: $exception, - context: ['query' => 'SELECT * FROM users'], - breakerTripped: true, - uncaught: true, - ); - - $array = $meta->toArray(); - - expect($array['controlled_block'])->toBe('db-operation') - ->and($array['controlled_block_id'])->toBe('block-error') - ->and($array['trace_id'])->toBe('trace-error') - ->and($array['attempt'])->toBe(3) - ->and($array['duration_ms'])->toBe(2500.0) - ->and($array['status'])->toBe('failed') - ->and($array['breaker_tripped'])->toBeTrue() - ->and($array['uncaught'])->toBeTrue() - ->and($array['block_context'])->toBe(['query' => 'SELECT * FROM users']) - ->and($array['exception'])->toBeArray() - ->and($array['exception']['class'])->toBe(RuntimeException::class) - ->and($array['exception']['message'])->toBe('Database connection failed') - ->and($array['exception']['file'])->toBeString() - ->and($array['exception']['line'])->toBeInt(); - }); - - it('handles default values correctly', function () { - $meta = new ControlledFailureMeta( - name: 'minimal-operation', - id: 'minimal-id', - traceId: 'minimal-trace', - attempt: 1, - durationMs: 50.0, - exception: null - ); - - $array = $meta->toArray(); - - expect($array['block_context'])->toBe([]) - ->and($array['breaker_tripped'])->toBeFalse() - ->and($array['uncaught'])->toBeFalse() - ->and($array['exception'])->toBeNull(); - }); - - it('builds exception data with different exception types', function () { - $exceptions = [ - new RuntimeException('Runtime error'), - new Exception('Generic exception'), - new \InvalidArgumentException('Invalid argument'), - ]; - - foreach ($exceptions as $exception) { - $meta = new ControlledFailureMeta( - name: 'test', - id: 'test-id', - traceId: 'test-trace', - attempt: 1, - durationMs: 100.0, - exception: $exception - ); - - $array = $meta->toArray(); - - expect($array['exception']['class'])->toBe(get_class($exception)) - ->and($array['exception']['message'])->toBe($exception->getMessage()) - ->and($array['exception']['file'])->toBeString() - ->and($array['exception']['line'])->toBeInt(); - } - }); -}); diff --git a/tests/Unit/ControlledTest.php b/tests/Unit/ControlledTest.php deleted file mode 100644 index a135a96..0000000 --- a/tests/Unit/ControlledTest.php +++ /dev/null @@ -1,180 +0,0 @@ -toBeInstanceOf(Controlled::class); - }); - - it('creates controlled block with constructor', function () { - $controlled = new Controlled('test-operation'); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - }); - - describe('Basic Execution', function () { - it('executes callback and returns result', function () { - // This test would need to be isolated from logging - // We'll mock the internal dependencies - expect(true)->toBeTrue(); // Placeholder - this needs framework mocking - }); - - it('executes callback with different return types', function () { - expect(true)->toBeTrue(); // Placeholder - this needs framework mocking - }); - - it('preserves exceptions from callback', function () { - expect(true)->toBeTrue(); // Placeholder - this needs framework mocking - }); - }); - - describe('Context Management', function () { - it('sets context with overrideContext() method', function () { - $context = ['user_id' => 123, 'action' => 'test']; - - $controlled = Controlled::for('context-test')->overrideContext($context); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - - it('merges context with addContext() method', function () { - $controlled = Controlled::for('merge-context-test') - ->overrideContext(['initial' => 'value']) - ->addContext(['additional' => 'data']); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - - it('handles complex nested context', function () { - $complexContext = [ - 'user' => ['id' => 789, 'name' => 'Test User'], - 'operation' => ['type' => 'complex', 'metadata' => ['version' => '1.0']], - ]; - - $controlled = Controlled::for('complex-context')->overrideContext($complexContext); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - }); - - describe('Configuration Methods', function () { - - it('sets trace ID override', function () { - $controlled = Controlled::for('trace-test')->overrideTraceId('custom-trace-123'); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - - it('sets custom origin', function () { - $controlled = Controlled::for('origin-test', 'CustomOrigin'); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - - it('configures circuit breaker parameters', function () { - $controlled = Controlled::for('breaker-test')->withCircuitBreaker('test-breaker', 10, 600); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - - it('configures transaction parameters', function () { - $controlled = Controlled::for('transaction-test') - ->withDatabaseTransaction(2, [RuntimeException::class], [InvalidArgumentException::class]); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - }); - - describe('Method Chaining', function () { - it('supports fluent interface chaining', function () { - $controlled = Controlled::for('chain-test', 'ChainTest') - ->overrideContext(['initial' => 'data']) - ->addContext(['additional' => 'context']) - ->overrideTraceId('custom-trace') - ->withCircuitBreaker('test-breaker', 3, 120) - ->withDatabaseTransaction(1); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - }); - - describe('Callback Configuration', function () { - it('sets exception handlers', function () { - $handlers = [ - \RuntimeException::class => fn ($exception, $meta) => null, - ]; - - $controlled = Controlled::for('catching-test')->catching($handlers); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - - it('sets uncaught exception callback', function () { - $callback = fn ($exception, $meta) => null; - - $controlled = Controlled::for('uncaught-exception-test')->onUncaughtException($callback); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - }); - - describe('Edge Cases', function () { - it('handles empty context arrays', function () { - $controlled = Controlled::for('empty-context-test') - ->overrideContext([]) - ->addContext([]); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - - it('handles empty values gracefully', function () { - $controlled = Controlled::for('empty-test') - ->overrideContext([]); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - }); - - describe('Retry Logic Configuration', function () { - it('configures retry parameters correctly', function () { - $controlled = Controlled::for('retry-config-test') - ->withDatabaseTransaction(2, [RuntimeException::class], [InvalidArgumentException::class]); - - expect($controlled)->toBeInstanceOf(Controlled::class); - }); - }); - - describe('Circuit Breaker Edge Cases', function () { - it('handles circuit breaker methods when no breaker name is set', function () { - $controlled = Controlled::for('no-breaker-test'); - - // These should not throw exceptions even without a breaker name - expect($controlled)->toBeInstanceOf(Controlled::class); - - // We can't directly test the protected methods, but we can ensure they handle null breaker names - // This is tested indirectly through normal execution - }); - }); - - describe('Exception Trace Configuration', function () { - it('handles exception trace configuration scenarios', function () { - // Test will be handled through feature tests with different config settings - expect(true)->toBeTrue(); - }); - }); - -}); - -// Note: Some tests focus on configuration without execution, others test execution paths -// that require framework integration. Complex scenarios are covered in Feature tests. diff --git a/tests/Unit/CriticalNamespaceRuleTest.php b/tests/Unit/CriticalNamespaceRuleTest.php new file mode 100644 index 0000000..1a125e1 --- /dev/null +++ b/tests/Unit/CriticalNamespaceRuleTest.php @@ -0,0 +1,54 @@ + + */ +final class CriticalNamespaceRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new CriticalNamespaceRule(['Workbench\\Monitor\\ControlPoints']); + } + + public function test_it_flags_only_classes_that_are_neither_control_points_nor_call_one(): void + { + $fixtures = realpath(__DIR__.'/../../workbench/app/ControlPoints'); + + $this->analyse([ + $fixtures.'/Filings/Uncontrolled.php', + $fixtures.'/Filings/Misnamed.php', + $fixtures.'/Filings/FilingStatus.php', + $fixtures.'/Filings/SubmitFiling.php', + $fixtures.'/Payments/InlineCharger.php', + $fixtures.'/Escalations/PagePayments.php', + realpath(__DIR__.'/../../workbench/app/Support/StripeClient.php'), + ], [ + [ + 'Workbench\Monitor\ControlPoints\Filings\Uncontrolled sits in a critical namespace but is not a control point and calls none.', + 10, + 'Extend Kirschbaum\Monitor\ControlPoint, or wrap the operation in Monitor::control().', + ], + [ + 'Workbench\Monitor\ControlPoints\Filings\GhostFiling sits in a critical namespace but is not a control point and calls none.', + 11, + 'Extend Kirschbaum\Monitor\ControlPoint, or wrap the operation in Monitor::control().', + ], + ]); + } + + public function test_it_is_silent_without_namespaces(): void + { + $rule = new CriticalNamespaceRule([]); + + self::assertSame(InClassNode::class, $rule->getNodeType()); + } +} diff --git a/tests/Unit/DomainAndNamesTest.php b/tests/Unit/DomainAndNamesTest.php new file mode 100644 index 0000000..ca90267 --- /dev/null +++ b/tests/Unit/DomainAndNamesTest.php @@ -0,0 +1,38 @@ +toBe('Payments') + ->and(Domain::resolve('App\\Services\\Filings\\Submit'))->toBe('Filings') + ->and(Domain::resolve('App\\Http\\Controllers\\X'))->toBe('Http'); + }); + + it('uses a string-mapped prefix as-is and tries prefixes in order', function (): void { + config()->set('monitor.domains.map', ['App\\Billing\\' => 'Payments', 'App\\' => null]); + + expect(Domain::resolve('App\\Billing\\Invoices\\Send'))->toBe('Payments') + ->and(Domain::resolve('App\\Other\\Thing'))->toBe('Other'); + }); + + it('falls back when nothing matches or the class sits directly under the prefix', function (): void { + config()->set('monitor.domains.fallback', 'Core'); + + expect(Domain::resolve('Vendor\\Package\\Thing'))->toBe('Core') + ->and(Domain::resolve('App\\Thing'))->toBe('Core'); + }); +}); + +describe('point names', function (): void { + it('validates against the configured pattern', function (): void { + expect(PointName::isValid('a.b'))->toBeTrue()->and(PointName::isValid('A.b'))->toBeFalse(); + + config()->set('monitor.point_name_pattern', '/^[A-Z]+$/'); + + expect(PointName::isValid('ABC'))->toBeTrue()->and(PointName::validate('ABC'))->toBe('ABC'); + }); +}); diff --git a/tests/Unit/MonitorTest.php b/tests/Unit/MonitorTest.php deleted file mode 100644 index 68a2724..0000000 --- a/tests/Unit/MonitorTest.php +++ /dev/null @@ -1,262 +0,0 @@ -trace(); - - expect($trace)->toBeInstanceOf(Trace::class); -}); - -it('returns same Trace instance for multiple calls', function () { - $monitor = new Monitor; - - $trace1 = $monitor->trace(); - $trace2 = $monitor->trace(); - - // Should be same instance since Trace is registered as singleton - expect($trace1)->toBe($trace2) - ->and($trace1)->toBeInstanceOf(Trace::class) - ->and($trace2)->toBeInstanceOf(Trace::class); -}); - -it('returns StructuredLogger instance with default origin', function () { - $monitor = new Monitor; - - $logger = $monitor->log(); - - expect($logger)->toBeInstanceOf(StructuredLogger::class); -}); - -it('returns StructuredLogger instance with custom string origin', function () { - $monitor = new Monitor; - - $logger = $monitor->log('CustomOrigin'); - - expect($logger)->toBeInstanceOf(StructuredLogger::class); -}); - -it('returns StructuredLogger instance with object origin', function () { - $monitor = new Monitor; - - $objectOrigin = new class - { - public function __toString(): string - { - return 'ObjectOrigin'; - } - }; - - $logger = $monitor->log($objectOrigin); - - expect($logger)->toBeInstanceOf(StructuredLogger::class); -}); - -it('returns LogTimer instance from service container', function () { - $monitor = new Monitor; - - $timer = $monitor->time(); - - expect($timer)->toBeInstanceOf(LogTimer::class); -}); - -it('returns same LogTimer instance for multiple calls', function () { - $monitor = new Monitor; - - $timer1 = $monitor->time(); - $timer2 = $monitor->time(); - - // Should be same instance since LogTimer is registered as singleton - expect($timer1)->toBe($timer2) - ->and($timer1)->toBeInstanceOf(LogTimer::class) - ->and($timer2)->toBeInstanceOf(LogTimer::class); -}); - -it('returns CircuitBreaker instance from service container', function () { - $monitor = new Monitor; - - $breaker = $monitor->breaker(); - - expect($breaker)->toBeInstanceOf(CircuitBreaker::class); -}); - -it('returns same CircuitBreaker instance for multiple calls', function () { - $monitor = new Monitor; - - $breaker1 = $monitor->breaker(); - $breaker2 = $monitor->breaker(); - - // Should be same instance since CircuitBreaker is registered as singleton - expect($breaker1)->toBe($breaker2) - ->and($breaker1)->toBeInstanceOf(CircuitBreaker::class) - ->and($breaker2)->toBeInstanceOf(CircuitBreaker::class); -}); - -it('returns LogRedactor instance from service container', function () { - $monitor = new Monitor; - - $redactor = $monitor->redactor(); - - expect($redactor)->toBeInstanceOf(LogRedactor::class); -}); - -it('returns new LogRedactor instance for multiple calls', function () { - $monitor = new Monitor; - - $redactor1 = $monitor->redactor(); - $redactor2 = $monitor->redactor(); - - // Should be different instances since LogRedactor is not registered as singleton - expect($redactor1)->not->toBe($redactor2) - ->and($redactor1)->toBeInstanceOf(LogRedactor::class) - ->and($redactor2)->toBeInstanceOf(LogRedactor::class); -}); - -it('creates controlled execution block with origin parameter', function () { - $monitor = new Monitor; - - $result = $monitor->controlled('test_with_origin', 'TestOrigin') - ->run(function () { - return 'origin_result'; - }); - - expect($result)->toBe('origin_result'); -}); - -it('creates controlled execution block with object origin', function () { - $monitor = new Monitor; - - $objectOrigin = new class - { - public function __toString(): string - { - return 'ObjectOrigin'; - } - }; - - $result = $monitor->controlled('test_with_object_origin', $objectOrigin) - ->run(function () { - return 'object_origin_result'; - }); - - expect($result)->toBe('object_origin_result'); -}); - -it('creates controlled execution block and returns result', function () { - $monitor = new Monitor; - - $result = $monitor->controlled('test_operation')->run(function () { - return 'operation_result'; - }); - - expect($result)->toBe('operation_result'); -}); - -it('creates controlled execution block with context', function () { - $monitor = new Monitor; - - $context = ['user_id' => 123, 'action' => 'test']; - - $result = $monitor->controlled('test_with_context') - ->overrideContext($context) - ->run(function () { - return 'success'; - }); - - expect($result)->toBe('success'); -}); - -it('passes through controlled block exceptions correctly', function () { - $monitor = new Monitor; - - expect(fn () => $monitor->controlled('failing_operation')->run(function () { - throw new \RuntimeException('Operation failed'); - })) - ->toThrow(\RuntimeException::class, 'Operation failed'); -}); - -it('handles empty context array correctly', function () { - $monitor = new Monitor; - - $result = $monitor->controlled('empty_context_test') - ->overrideContext([]) - ->run(function () { - return 'context_success'; - }); - - expect($result)->toBe('context_success'); -}); - -it('handles complex context data correctly', function () { - $monitor = new Monitor; - - $complexContext = [ - 'user' => ['id' => 456, 'name' => 'Test User'], - 'operation' => 'complex_test', - 'metadata' => ['timestamp' => time(), 'version' => '1.0'], - ]; - - $result = $monitor->controlled('complex_context_test') - ->overrideContext($complexContext) - ->run(function () { - return 'complex_success'; - }); - - expect($result)->toBe('complex_success'); -}); - -it('routes to correct underlying services', function () { - $monitor = new Monitor; - - // Test that all methods return the expected types - expect($monitor->trace())->toBeInstanceOf(Trace::class) - ->and($monitor->log())->toBeInstanceOf(StructuredLogger::class) - ->and($monitor->log('test'))->toBeInstanceOf(StructuredLogger::class) - ->and($monitor->time())->toBeInstanceOf(LogTimer::class) - ->and($monitor->breaker())->toBeInstanceOf(CircuitBreaker::class) - ->and($monitor->redactor())->toBeInstanceOf(LogRedactor::class) - ->and($monitor->controlled('test'))->toBeInstanceOf(\Kirschbaum\Monitor\Controlled::class); -}); - -it('handles different return types from controlled block callbacks', function () { - $monitor = new Monitor; - - // Test different return types - $stringResult = $monitor->controlled('string_test')->run(fn () => 'string'); - $intResult = $monitor->controlled('int_test')->run(fn () => 42); - $arrayResult = $monitor->controlled('array_test')->run(fn () => ['key' => 'value']); - $nullResult = $monitor->controlled('null_test')->run(fn () => null); - $boolResult = $monitor->controlled('bool_test')->run(fn () => true); - - expect($stringResult)->toBe('string') - ->and($intResult)->toBe(42) - ->and($arrayResult)->toBe(['key' => 'value']) - ->and($nullResult)->toBeNull() - ->and($boolResult)->toBeTrue(); -}); - -it('preserves controlled block callback parameter and return types', function () { - $monitor = new Monitor; - - $callback = function (string $input): string { - return "processed: {$input}"; - }; - - // Test that closures work correctly within controlled blocks - $result = $monitor->controlled('closure_test')->run(function () use ($callback) { - return $callback('test_data'); - }); - - expect($result)->toBe('processed: test_data'); -}); diff --git a/tests/Unit/OriginWrapperTest.php b/tests/Unit/OriginWrapperTest.php deleted file mode 100644 index 6388d89..0000000 --- a/tests/Unit/OriginWrapperTest.php +++ /dev/null @@ -1,152 +0,0 @@ -wrap('TestOrigin'))->toBe('[TestOrigin]'); -}); - -it('wraps origin with curly braces', function () { - $wrapper = OriginWrapper::CurlyBraces; - expect($wrapper->wrap('TestOrigin'))->toBe('{TestOrigin}'); -}); - -it('wraps origin with parentheses', function () { - $wrapper = OriginWrapper::Parentheses; - expect($wrapper->wrap('TestOrigin'))->toBe('(TestOrigin)'); -}); - -it('wraps origin with angle brackets', function () { - $wrapper = OriginWrapper::AngleBrackets; - expect($wrapper->wrap('TestOrigin'))->toBe(''); -}); - -it('wraps origin with double quotes', function () { - $wrapper = OriginWrapper::DoubleQuotes; - expect($wrapper->wrap('TestOrigin'))->toBe('"TestOrigin"'); -}); - -it('wraps origin with single quotes', function () { - $wrapper = OriginWrapper::SingleQuotes; - expect($wrapper->wrap('TestOrigin'))->toBe("'TestOrigin'"); -}); - -it('wraps origin with asterisks', function () { - $wrapper = OriginWrapper::Asterisks; - expect($wrapper->wrap('TestOrigin'))->toBe('*TestOrigin*'); -}); - -it('does not wrap origin when set to none', function () { - $wrapper = OriginWrapper::None; - expect($wrapper->wrap('TestOrigin'))->toBe('TestOrigin'); -}); - -it('handles empty strings correctly for all wrapper types', function () { - expect(OriginWrapper::None->wrap(''))->toBe(''); - expect(OriginWrapper::SquareBrackets->wrap(''))->toBe('[]'); - expect(OriginWrapper::CurlyBraces->wrap(''))->toBe('{}'); - expect(OriginWrapper::Parentheses->wrap(''))->toBe('()'); - expect(OriginWrapper::AngleBrackets->wrap(''))->toBe('<>'); - expect(OriginWrapper::DoubleQuotes->wrap(''))->toBe('""'); - expect(OriginWrapper::SingleQuotes->wrap(''))->toBe("''"); - expect(OriginWrapper::Asterisks->wrap(''))->toBe('**'); -}); - -it('creates OriginWrapper from config value square', function () { - $wrapper = OriginWrapper::fromConfig('square'); - expect($wrapper)->toBe(OriginWrapper::SquareBrackets); -}); - -it('creates OriginWrapper from config value curly', function () { - $wrapper = OriginWrapper::fromConfig('curly'); - expect($wrapper)->toBe(OriginWrapper::CurlyBraces); -}); - -it('creates OriginWrapper from config value round', function () { - $wrapper = OriginWrapper::fromConfig('round'); - expect($wrapper)->toBe(OriginWrapper::Parentheses); -}); - -it('creates OriginWrapper from config value angle', function () { - $wrapper = OriginWrapper::fromConfig('angle'); - expect($wrapper)->toBe(OriginWrapper::AngleBrackets); -}); - -it('creates OriginWrapper from config value double', function () { - $wrapper = OriginWrapper::fromConfig('double'); - expect($wrapper)->toBe(OriginWrapper::DoubleQuotes); -}); - -it('creates OriginWrapper from config value single', function () { - $wrapper = OriginWrapper::fromConfig('single'); - expect($wrapper)->toBe(OriginWrapper::SingleQuotes); -}); - -it('creates OriginWrapper from config value asterisks', function () { - $wrapper = OriginWrapper::fromConfig('asterisks'); - expect($wrapper)->toBe(OriginWrapper::Asterisks); -}); - -it('defaults to None for unknown config values', function () { - $unknownValues = ['unknown', 'invalid', 'random', '']; - - foreach ($unknownValues as $value) { - $wrapper = OriginWrapper::fromConfig($value); - expect($wrapper)->toBe(OriginWrapper::None); - } -}); - -it('defaults to None for null config value', function () { - $wrapper = OriginWrapper::fromConfig(null); - expect($wrapper)->toBe(OriginWrapper::None); -}); - -it('handles special characters in origin correctly', function () { - $specialOrigin = 'Test::Class\\Namespace@method'; - - expect(OriginWrapper::None->wrap($specialOrigin))->toBe('Test::Class\\Namespace@method'); - expect(OriginWrapper::SquareBrackets->wrap($specialOrigin))->toBe('[Test::Class\\Namespace@method]'); - expect(OriginWrapper::CurlyBraces->wrap($specialOrigin))->toBe('{Test::Class\\Namespace@method}'); - expect(OriginWrapper::Parentheses->wrap($specialOrigin))->toBe('(Test::Class\\Namespace@method)'); - expect(OriginWrapper::AngleBrackets->wrap($specialOrigin))->toBe(''); - expect(OriginWrapper::DoubleQuotes->wrap($specialOrigin))->toBe('"Test::Class\\Namespace@method"'); - expect(OriginWrapper::SingleQuotes->wrap($specialOrigin))->toBe("'Test::Class\\Namespace@method'"); - expect(OriginWrapper::Asterisks->wrap($specialOrigin))->toBe('*Test::Class\\Namespace@method*'); -}); - -it('maintains wrapper consistency between fromConfig and wrap', function () { - $configMappings = [ - 'square' => OriginWrapper::SquareBrackets, - 'curly' => OriginWrapper::CurlyBraces, - 'round' => OriginWrapper::Parentheses, - 'angle' => OriginWrapper::AngleBrackets, - 'double' => OriginWrapper::DoubleQuotes, - 'single' => OriginWrapper::SingleQuotes, - 'asterisks' => OriginWrapper::Asterisks, - ]; - - foreach ($configMappings as $configValue => $expectedWrapper) { - $wrapper = OriginWrapper::fromConfig($configValue); - expect($wrapper)->toBe($expectedWrapper); - - // Test that wrapping works as expected - $result = $wrapper->wrap('Test'); - expect($result)->not->toBe('Test'); // Should be wrapped (except for None) - } -}); - -it('has correct string values for all enum cases', function () { - expect(OriginWrapper::None->value)->toBe('none'); - expect(OriginWrapper::SquareBrackets->value)->toBe('square'); - expect(OriginWrapper::CurlyBraces->value)->toBe('curly'); - expect(OriginWrapper::Parentheses->value)->toBe('round'); - expect(OriginWrapper::AngleBrackets->value)->toBe('angle'); - expect(OriginWrapper::DoubleQuotes->value)->toBe('double'); - expect(OriginWrapper::SingleQuotes->value)->toBe('single'); - expect(OriginWrapper::Asterisks->value)->toBe('asterisks'); -}); diff --git a/tests/Unit/ProfilesTest.php b/tests/Unit/ProfilesTest.php new file mode 100644 index 0000000..fc6cf16 --- /dev/null +++ b/tests/Unit/ProfilesTest.php @@ -0,0 +1,47 @@ +toBe(['external', 'database', 'messaging', 'internal']) + ->and(Profiles::exists('external'))->toBeTrue() + ->and(Profiles::exists('nope'))->toBeFalse(); + }); + + it('builds every policy and limit from a definition', function (): void { + config()->set('monitor.profiles.full', [ + 'retry' => ['times' => '3', 'backoff_ms' => 10, 'multiplier' => 1.5, 'jitter' => false, 'on' => [Fatal::class, 'NotAClass'], 'except' => []], + 'transaction' => ['retries' => 1, 'connection' => 'other', 'on' => [Fatal::class], 'backoff_ms' => 5], + 'breaker' => ['name' => 'shared', 'after' => 2, 'within' => 20, 'for' => 200, 'except' => [Fatal::class]], + 'within' => 3, + 'attempts' => 4, + ]); + + $policies = Profiles::policies('full', 'x.y'); + + expect(array_keys($policies))->toBe(['retry', 'transaction', 'breaker']) + ->and($policies['retry']->describe())->toMatchArray(['times' => 3, 'backoff_ms' => 10, 'multiplier' => 1.5, 'jitter' => false, 'on' => [Fatal::class]]) + ->and($policies['transaction']->describe())->toMatchArray(['retries' => 1, 'connection' => 'other', 'on' => [Fatal::class], 'backoff_ms' => 5]) + ->and($policies['breaker']->describe())->toMatchArray(['name' => 'shared', 'after' => 2, 'within' => 20, 'for' => 200, 'except' => [Fatal::class]]) + ->and(Profiles::within('full')?->seconds)->toBe(3.0) + ->and(Profiles::attempts('full')?->max)->toBe(4); + }); + + it('names an unnamed breaker after the point and leaves unset limits null', function (): void { + config()->set('monitor.profiles.min', ['breaker' => []]); + + expect(Profiles::policies('min', 'p.q')['breaker']->describe()['name'])->toBe('p.q') + ->and(Profiles::within('min'))->toBeNull() + ->and(Profiles::attempts('min'))->toBeNull() + ->and(Profiles::policies('internal', 'p.q'))->toBe([]); + }); + + it('rejects an unknown profile', function (): void { + expect(fn (): array => Profiles::definition('missing'))->toThrow(InvalidProfile::class); + }); +}); diff --git a/tests/Unit/RecordFormatterTest.php b/tests/Unit/RecordFormatterTest.php new file mode 100644 index 0000000..06d9086 --- /dev/null +++ b/tests/Unit/RecordFormatterTest.php @@ -0,0 +1,52 @@ + 'point.recovered', 'point' => 'payment.charge', 'trace_id' => 'abc', + ], ['trace_id' => 'from-extra', 'control_point' => 'payment.charge']); + + $line = json_decode((new RecordFormatter)->format($record), true); + + expect(array_keys($line))->toBe(['ts', 'level', 'channel', 'message', 'trace_id', 'control_point', 'event', 'point']) + ->and($line['ts'])->toBe('2026-09-14T18:10:24.118+00:00') + ->and($line['level'])->toBe('warning') + ->and($line['trace_id'])->toBe('abc'); + }); + + it('never loses a line to bad bytes', function (): void { + $record = new LogRecord(new DateTimeImmutable, 'app', Level::Info, "bad \xB1\x31 bytes", ['raw' => "\xB1"], []); + + $out = (new RecordFormatter)->format($record); + + expect($out)->toEndWith("\n")->and(json_decode($out, true))->toBeArray()->and(json_decode($out, true)['message'])->toContain('bad'); + }); + + it('is installed on every formattable handler by the tap', function (): void { + $handler = new TestHandler; + $logger = new Logger(new Monolog('t', [$handler])); + + (new JsonTap)($logger); + + expect($handler->getFormatter())->toBeInstanceOf(RecordFormatter::class); + }); + + it('ignores a channel that is not a Monolog logger', function (): void { + $logger = new Logger(new NullLogger); + + (new JsonTap)($logger); + + expect(true)->toBeTrue(); + }); +}); diff --git a/tests/Unit/StructuredJsonFormatterTest.php b/tests/Unit/StructuredJsonFormatterTest.php deleted file mode 100644 index bd87a21..0000000 --- a/tests/Unit/StructuredJsonFormatterTest.php +++ /dev/null @@ -1,360 +0,0 @@ - 123, 'action' => 'login'], - extra: ['event' => 'user.authenticated'] - ); - - $result = $formatter->format($record); - $decoded = json_decode(trim($result), true); - - expect($decoded)->toBeArray() - ->and($decoded['timestamp'])->toBe('2024-01-15T10:30:45+00:00') - ->and($decoded['level'])->toBe('INFO') - ->and($decoded['message'])->toBe('Test log message') - ->and($decoded['context'])->toBe(['user_id' => 123, 'action' => 'login']) - ->and($decoded['channel'])->toBe('test-channel') - ->and($decoded['event'])->toBe('user.authenticated') - ->and($decoded['trace_id'])->toBeNull() - ->and($decoded['duration_ms'])->toBeNull() - ->and($decoded['memory_mb'])->toBeNull(); -}); - -it('formats log record with trace_id from context', function () { - $formatter = new StructuredJsonFormatter; - - $record = new LogRecord( - datetime: new DateTimeImmutable('2024-01-15 10:30:45'), - channel: 'monitor', - level: Level::Debug, - message: 'Debug message', - context: ['trace_id' => 'abc123def456', 'operation' => 'database_query'], - extra: [] - ); - - $result = $formatter->format($record); - $decoded = json_decode(trim($result), true); - - expect($decoded['trace_id'])->toBe('abc123def456') - ->and($decoded['context'])->toBe(['trace_id' => 'abc123def456', 'operation' => 'database_query']); -}); - -it('formats log record with duration and memory metrics from context', function () { - $formatter = new StructuredJsonFormatter; - - $record = new LogRecord( - datetime: new DateTimeImmutable('2024-01-15 10:30:45'), - channel: 'performance', - level: Level::Notice, - message: 'Operation completed', - context: [ - 'duration_ms' => 150.75, - 'memory_mb' => 25.6, - 'operation' => 'heavy_computation', - ], - extra: [] - ); - - $result = $formatter->format($record); - $decoded = json_decode(trim($result), true); - - expect($decoded['duration_ms'])->toBe(150.75) - ->and($decoded['memory_mb'])->toBe(25.6) - ->and($decoded['context']['operation'])->toBe('heavy_computation'); -}); - -it('handles all log levels correctly', function () { - $formatter = new StructuredJsonFormatter; - - $testCases = [ - [Level::Debug, 'DEBUG'], - [Level::Info, 'INFO'], - [Level::Notice, 'NOTICE'], - [Level::Warning, 'WARNING'], - [Level::Error, 'ERROR'], - [Level::Critical, 'CRITICAL'], - [Level::Alert, 'ALERT'], - [Level::Emergency, 'EMERGENCY'], - ]; - - foreach ($testCases as [$level, $expectedName]) { - $record = new LogRecord( - datetime: new DateTimeImmutable('2024-01-15 10:30:45'), - channel: 'test', - level: $level, - message: 'Test message', - context: [], - extra: [] - ); - - $result = $formatter->format($record); - $decoded = json_decode(trim($result), true); - - expect($decoded['level'])->toBe($expectedName); - } -}); - -it('handles empty context and extra arrays', function () { - $formatter = new StructuredJsonFormatter; - - $record = new LogRecord( - datetime: new DateTimeImmutable('2024-01-15 10:30:45'), - channel: 'empty-test', - level: Level::Info, - message: 'Message with empty context', - context: [], - extra: [] - ); - - $result = $formatter->format($record); - $decoded = json_decode(trim($result), true); - - expect($decoded['context'])->toBe([]) - ->and($decoded['event'])->toBeNull() - ->and($decoded['trace_id'])->toBeNull() - ->and($decoded['duration_ms'])->toBeNull() - ->and($decoded['memory_mb'])->toBeNull(); -}); - -it('prioritizes trace_id from context over null', function () { - $formatter = new StructuredJsonFormatter; - - $record = new LogRecord( - datetime: new DateTimeImmutable('2024-01-15 10:30:45'), - channel: 'test', - level: Level::Info, - message: 'Test message', - context: ['trace_id' => 'user-defined-123'], - extra: [] - ); - - $result = $formatter->format($record); - $decoded = json_decode(trim($result), true); - - expect($decoded['trace_id'])->toBe('user-defined-123'); -}); - -it('handles special characters in message correctly', function () { - $formatter = new StructuredJsonFormatter; - - $specialMessage = 'Test with "quotes", \'apostrophes\', and unicode: 🚀 emoji, newlines:\n\nand tabs:\t\t'; - - $record = new LogRecord( - datetime: new DateTimeImmutable('2024-01-15 10:30:45'), - channel: 'special-chars', - level: Level::Warning, - message: $specialMessage, - context: [], - extra: [] - ); - - $result = $formatter->format($record); - $decoded = json_decode(trim($result), true); - - expect($decoded['message'])->toBe($specialMessage); - expect(json_last_error())->toBe(JSON_ERROR_NONE); -}); - -it('produces valid NDJSON format with newline', function () { - $formatter = new StructuredJsonFormatter; - - $record = new LogRecord( - datetime: new DateTimeImmutable('2024-01-15 10:30:45'), - channel: 'ndjson-test', - level: Level::Info, - message: 'NDJSON format test', - context: [], - extra: [] - ); - - $result = $formatter->format($record); - - // Should end with exactly one newline - expect($result)->toEndWith("\n") - ->and(substr_count($result, "\n"))->toBe(1) - ->and(rtrim($result, "\n"))->not->toContain("\n"); - - // Should be valid JSON when newline is removed - $jsonPart = trim($result); - expect(json_decode($jsonPart))->not->toBeNull(); -}); - -it('handles complex nested context data', function () { - $formatter = new StructuredJsonFormatter; - - $complexContext = [ - 'user' => [ - 'id' => 456, - 'profile' => [ - 'name' => 'Test User', - 'settings' => ['theme' => 'dark', 'notifications' => true], - ], - ], - 'operation' => [ - 'type' => 'data_processing', - 'metrics' => ['records_processed' => 1000, 'errors' => 0], - ], - 'trace_id' => 'complex-trace-789', - ]; - - $record = new LogRecord( - datetime: new DateTimeImmutable('2024-01-15 10:30:45'), - channel: 'complex-test', - level: Level::Info, - message: 'Complex data test', - context: $complexContext, - extra: [] - ); - - $result = $formatter->format($record); - $decoded = json_decode(trim($result), true); - - expect($decoded['context'])->toBe($complexContext) - ->and($decoded['trace_id'])->toBe('complex-trace-789') - ->and($decoded['context']['user']['profile']['name'])->toBe('Test User'); -}); - -it('extracts event from extra field while metrics come from context', function () { - $formatter = new StructuredJsonFormatter; - - $record = new LogRecord( - datetime: new DateTimeImmutable('2024-01-15 10:30:45'), - channel: 'metrics-test', - level: Level::Info, - message: 'Metrics test', - context: [ - 'duration_ms' => 250.5, - 'memory_mb' => 32.8, - ], - extra: [ - 'event' => 'operation.completed', - ] - ); - - $result = $formatter->format($record); - $decoded = json_decode(trim($result), true); - - expect($decoded['event'])->toBe('operation.completed') - ->and($decoded['duration_ms'])->toBe(250.5) - ->and($decoded['memory_mb'])->toBe(32.8); -}); - -it('prioritizes context over extra for special fields', function () { - $formatter = new StructuredJsonFormatter; - - $record = new LogRecord( - datetime: new DateTimeImmutable('2024-01-15 10:30:45'), - channel: 'priority-test', - level: Level::Info, - message: 'Priority test', - context: [ - 'trace_id' => 'context-trace-123', - 'duration_ms' => 100, - ], - extra: [ - 'trace_id' => 'extra-trace-456', // Should be overridden - 'duration_ms' => 200, // Should be overridden - 'event' => 'test.event', - ] - ); - - $result = $formatter->format($record); - $decoded = json_decode(trim($result), true); - - expect($decoded['trace_id'])->toBe('context-trace-123') // From context - ->and($decoded['duration_ms'])->toBe(100) // From context - ->and($decoded['event'])->toBe('test.event'); // From extra -}); - -it('handles null and false values correctly', function () { - $formatter = new StructuredJsonFormatter; - - $record = new LogRecord( - datetime: new DateTimeImmutable('2024-01-15 10:30:45'), - channel: 'null-test', - level: Level::Info, - message: 'Null test', - context: [ - 'null_value' => null, - 'false_value' => false, - 'zero_value' => 0, - 'empty_string' => '', - ], - extra: [] - ); - - $result = $formatter->format($record); - $decoded = json_decode(trim($result), true); - - expect($decoded['context']['null_value'])->toBeNull() - ->and($decoded['context']['false_value'])->toBeFalse() - ->and($decoded['context']['zero_value'])->toBe(0) - ->and($decoded['context']['empty_string'])->toBe(''); -}); - -it('handles very large numbers correctly', function () { - $formatter = new StructuredJsonFormatter; - - $record = new LogRecord( - datetime: new DateTimeImmutable('2024-01-15 10:30:45'), - channel: 'large-numbers', - level: Level::Info, - message: 'Large numbers test', - context: [ - 'duration_ms' => 999999.999, - 'memory_mb' => 1024, - 'large_int' => 9223372036854775807, // Max int64 - ], - extra: [] - ); - - $result = $formatter->format($record); - $decoded = json_decode(trim($result), true); - - expect($decoded['duration_ms'])->toBe(999999.999) - ->and($decoded['memory_mb'])->toBe(1024) - ->and($decoded['context']['large_int'])->toBe(9223372036854775807); -}); - -it('maintains JSON structure integrity', function () { - $formatter = new StructuredJsonFormatter; - - $record = new LogRecord( - datetime: new DateTimeImmutable('2024-01-15 10:30:45'), - channel: 'structure-test', - level: Level::Info, - message: 'Structure test', - context: ['test' => 'value'], - extra: ['event' => 'test.event'] - ); - - $result = $formatter->format($record); - - // Ensure it's valid JSON - expect(json_decode(trim($result), true))->not->toBeNull() - ->and(json_last_error())->toBe(JSON_ERROR_NONE); - - // Ensure all required fields are present - $decoded = json_decode(trim($result), true); - $requiredFields = ['timestamp', 'level', 'message', 'context', 'channel', 'event', 'trace_id', 'duration_ms', 'memory_mb']; - - foreach ($requiredFields as $field) { - expect($decoded)->toHaveKey($field); - } -}); diff --git a/tests/Unit/TraceTest.php b/tests/Unit/TraceTest.php index d874009..cabc207 100644 --- a/tests/Unit/TraceTest.php +++ b/tests/Unit/TraceTest.php @@ -2,213 +2,47 @@ declare(strict_types=1); -namespace Tests\Unit; - -use Kirschbaum\Monitor\Trace; -use LogicException; - -it('starts a new trace with generated UUID', function () { - $trace = new Trace; - - expect($trace->hasNotStarted())->toBeTrue() - ->and($trace->hasStarted())->toBeFalse(); - - $trace->start(); - - expect($trace->hasStarted())->toBeTrue() - ->and($trace->hasNotStarted())->toBeFalse(); - - $traceId = $trace->id(); - expect($traceId)->toBeString() - ->and(strlen($traceId))->toBe(36) // UUID length - ->and($traceId)->toMatch('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'); -}); - -it('throws exception when starting already started trace', function () { - $trace = new Trace; - $trace->start(); - - expect(fn () => $trace->start()) - ->toThrow(LogicException::class, 'Trace has already been started.'); -}); - -it('throws exception when accessing ID of unstarted trace', function () { - $trace = new Trace; - - expect(fn () => $trace->id()) - ->toThrow(LogicException::class, 'Trace ID has not been started.'); -}); - -it('overrides trace ID with custom UUID', function () { - $trace = new Trace; - $customUuid = 'custom-test-uuid-12345'; - - $trace->override($customUuid); - - expect($trace->hasStarted())->toBeTrue() - ->and($trace->id())->toBe($customUuid); -}); - -it('pickup starts new trace when no trace ID provided and trace not started', function () { - $trace = new Trace; - - expect($trace->hasNotStarted())->toBeTrue(); - - $result = $trace->pickup(); - - expect($result)->toBe($trace) // Should return same instance - ->and($trace->hasStarted())->toBeTrue() - ->and($trace->id())->toBeString() - ->and(strlen($trace->id()))->toBe(36); -}); - -it('pickup overrides trace ID when custom trace ID provided and trace not started', function () { - $trace = new Trace; - $customUuid = 'pickup-custom-uuid-67890'; - - expect($trace->hasNotStarted())->toBeTrue(); - - $result = $trace->pickup($customUuid); - - expect($result)->toBe($trace) // Should return same instance - ->and($trace->hasStarted())->toBeTrue() - ->and($trace->id())->toBe($customUuid); -}); - -it('pickup returns same instance when trace already started', function () { - $trace = new Trace; - $trace->start(); - $originalId = $trace->id(); - - // Should return same instance and not change the trace ID - $result = $trace->pickup('should-not-override'); - - expect($result)->toBe($trace) - ->and($trace->id())->toBe($originalId); // Original ID preserved -}); - -it('pickup with custom ID returns same instance when trace already started', function () { - $trace = new Trace; - $customUuid = 'already-started-uuid'; - $trace->override($customUuid); - - // Should return same instance and not change the trace ID - $result = $trace->pickup('different-uuid'); - - expect($result)->toBe($trace) - ->and($trace->id())->toBe($customUuid); // Original ID preserved -}); - -it('hasStarted returns correct boolean values', function () { - $trace = new Trace; - - // Initially not started - expect($trace->hasStarted())->toBeFalse(); - - // After starting - $trace->start(); - expect($trace->hasStarted())->toBeTrue(); -}); - -it('hasNotStarted returns correct boolean values', function () { - $trace = new Trace; - - // Initially not started - expect($trace->hasNotStarted())->toBeTrue(); - - // After starting - $trace->start(); - expect($trace->hasNotStarted())->toBeFalse(); -}); - -it('hasNotStarted returns false after override', function () { - $trace = new Trace; - - expect($trace->hasNotStarted())->toBeTrue(); - - $trace->override('override-test-uuid'); - - expect($trace->hasNotStarted())->toBeFalse(); -}); - -it('generates different UUIDs for multiple trace instances', function () { - $trace1 = new Trace; - $trace2 = new Trace; - - $trace1->start(); - $trace2->start(); - - expect($trace1->id())->not->toBe($trace2->id()); -}); - -it('can override trace ID multiple times before starting', function () { - $trace = new Trace; - - $firstUuid = 'first-uuid'; - $secondUuid = 'second-uuid'; - - $trace->override($firstUuid); - expect($trace->id())->toBe($firstUuid); - - $trace->override($secondUuid); - expect($trace->id())->toBe($secondUuid); -}); - -it('maintains state correctly through fluent interface', function () { - $trace = new Trace; - - // Test fluent interface with generated UUID - $result = $trace->pickup(); - expect($result)->toBe($trace) - ->and($trace->hasStarted())->toBeTrue(); - - // Test fluent interface when already started - $originalId = $trace->id(); - $result2 = $trace->pickup('ignored-uuid'); - expect($result2)->toBe($trace) - ->and($trace->id())->toBe($originalId); -}); - -it('handles empty string override - started but ID throws exception', function () { - $trace = new Trace; - - $trace->override(''); - - // Empty string is not null, so hasStarted() returns true - expect($trace->hasStarted())->toBeTrue() - ->and($trace->hasNotStarted())->toBeFalse(); - - // But empty string is falsy, so id() throws exception - expect(fn () => $trace->id()) - ->toThrow(LogicException::class, 'Trace ID has not been started.'); -}); - -it('pickup handles null explicitly vs no parameters', function () { - $trace1 = new Trace; - $trace2 = new Trace; - - // Explicit null - $trace1->pickup(null); - - // No parameter (defaults to null) - $trace2->pickup(); - - expect($trace1->hasStarted())->toBeTrue() - ->and($trace2->hasStarted())->toBeTrue() - ->and($trace1->id())->toBeString() - ->and($trace2->id())->toBeString() - ->and(strlen($trace1->id()))->toBe(36) - ->and(strlen($trace2->id()))->toBe(36); -}); - -it('returns correct ID after successful start', function () { - $trace = new Trace; - $trace->start(); - - $id = $trace->id(); - - // This specifically tests line 45: return $this->traceId; - expect($id)->toBeString() - ->and($id)->toBe($trace->id()) // Should be consistent - ->and(strlen($id))->toBe(36); +use Illuminate\Support\Facades\Context; +use Kirschbaum\Monitor\Exceptions\InvalidTraceId; +use Kirschbaum\Monitor\Trace\Trace; +use Kirschbaum\Monitor\Trace\TraceParent; + +describe('trace', function (): void { + beforeEach(fn () => resolve(Trace::class)->clear()); + + it('starts a 32 hex trace on demand and keeps it', function (): void { + $trace = resolve(Trace::class); + + expect($trace->hasStarted())->toBeFalse()->and($trace->current())->toBeNull(); + + $id = $trace->id(); + + expect($id)->toMatch('/^[0-9a-f]{32}$/') + ->and($trace->id())->toBe($id) + ->and($trace->pickup('ffffffffffffffffffffffffffffffff'))->toBe($id) + ->and(Context::get('trace_id'))->toBe($id); + }); + + it('adopts a valid id and normalises a uuid', function (): void { + $trace = resolve(Trace::class); + + expect($trace->pickup('9D2B4E8F-3A1C-4D5E-8F2A-1B3C4D5E6F7A'))->toBe('9d2b4e8f3a1c4d5e8f2a1b3c4d5e6f7a') + ->and($trace->override('00000000000000000000000000000001'))->toBe('00000000000000000000000000000001') + ->and($trace->start())->not->toBe('00000000000000000000000000000001'); + }); + + it('rejects an invalid id', function (string $bad): void { + expect(fn () => resolve(Trace::class)->override($bad))->toThrow(InvalidTraceId::class); + })->with(['nope', '', '00000000000000000000000000000000', 'zz2b4e8f3a1c4d5e8f2a1b3c4d5e6f7a', '