From 6fca8257236f9e9a3293172394db77c6988a3375 Mon Sep 17 00:00:00 2001 From: Belisar Hoxholli Date: Mon, 14 Sep 2026 21:04:45 +0200 Subject: [PATCH 01/13] Rebuild the core as control points with outcomes, policies, limits --- .gitattributes | 18 + .githooks/commit-msg | 64 +++ .github/dependabot.yml | 8 +- .github/workflows/automerge-dependabot.yml | 2 +- .github/workflows/changelog-update.yml | 4 +- .github/workflows/php-tests.yml | 46 +- .github/workflows/security.yml | 61 +++ .github/workflows/static-analysis.yml | 23 +- .github/workflows/style-check.yml | 8 +- composer.json | 68 ++- config/logging-monitor.php | 25 - config/monitor.php | 311 ++++-------- phpunit.xml.dist | 10 + rector.php | 24 + scripts/preflight.sh | 15 +- src/Breaker/BreakerConfig.php | 37 ++ src/Breaker/BreakerState.php | 114 +++++ src/Breaker/CircuitBreaker.php | 167 +++++++ src/Breaker/Decision.php | 29 ++ src/Breaker/State.php | 12 + src/CircuitBreaker.php | 134 ------ src/Control.php | 404 ++++++++++++++++ src/ControlStack.php | 107 +++++ src/Controlled.php | 400 ---------------- src/Data/CircuitBreakerState.php | 142 ------ src/Data/ControlledFailureMeta.php | 76 --- src/Enums/OriginWrapper.php | 45 -- src/Escalations/Escalation.php | 19 + src/Events/BreakerClosed.php | 12 + src/Events/BreakerHalfOpen.php | 12 + src/Events/BreakerOpened.php | 12 + src/Events/EscalationFailed.php | 16 + src/Events/PointEnded.php | 12 + src/Events/PointEscalated.php | 12 + src/Events/PointLimitBreached.php | 17 + src/Events/PointRecovered.php | 12 + src/Events/PointRefused.php | 12 + src/Events/PointRetried.php | 22 + src/Events/PointStarted.php | 12 + src/Exceptions/InvalidControlPoint.php | 7 + src/Exceptions/InvalidPointName.php | 7 + src/Exceptions/InvalidProfile.php | 7 + src/Exceptions/InvalidTraceId.php | 7 + ...lockException.php => MonitorException.php} | 2 +- src/Facades/Monitor.php | 55 ++- src/Formatters/StructuredJsonFormatter.php | 28 -- src/Http/Middleware/CheckCircuitBreakers.php | 54 --- src/Http/Middleware/StartMonitorTrace.php | 34 -- src/Limits/Attempts.php | 23 + src/Limits/Ensure.php | 35 ++ src/Limits/Limit.php | 19 + src/Limits/Within.php | 36 ++ src/LiveRunner.php | 25 + src/LogTimer.php | 20 - src/Logging/StructuredLogger.php | 85 ++++ src/Monitor.php | 39 +- src/MonitorServiceProvider.php | 33 +- src/Outcome.php | 123 +++++ src/Policies/Breaker.php | 129 +++++ src/Policies/Concerns/FiltersExceptions.php | 61 +++ src/Policies/Policy.php | 39 ++ src/Policies/Retry.php | 115 +++++ src/Policies/Transaction.php | 89 ++++ src/Risks/BreakerOpen.php | 16 + src/Risks/EnsureFailed.php | 16 + src/Risks/Risk.php | 13 + src/Run.php | 304 ++++++++++++ src/RunInfo.php | 46 ++ src/Runner.php | 18 + src/Status.php | 28 ++ src/StructuredLogger.php | 158 ------ src/Support/ChildEscalations.php | 44 ++ src/Support/ControlledContext.php | 40 -- src/Support/Domain.php | 43 ++ src/Support/LogRedactor.php | 351 -------------- src/Support/PointName.php | 31 ++ src/Support/Profiles.php | 164 +++++++ src/Support/RedactorConfig.php | 78 --- src/Taps/StructuredLoggingTap.php | 21 - src/Testing/MonitorFake.php | 237 +++++++++ src/Trace.php | 60 --- src/Trace/Trace.php | 108 +++++ src/Trace/TraceParent.php | 42 ++ testbench.yaml | 1 + tests/Feature/BreakerTest.php | 210 ++++++++ .../CheckCircuitBreakersMiddlewareTest.php | 256 ---------- tests/Feature/ControlTest.php | 348 ++++++++++++++ tests/Feature/ControlledConfigurationTest.php | 130 ----- tests/Feature/ControlledFeatureTest.php | 354 -------------- tests/Feature/ControlledIntegrationTest.php | 247 ---------- .../ControlledTransactionRetryTest.php | 387 --------------- tests/Feature/FakeTest.php | 108 +++++ tests/Feature/LogRedactorTest.php | 122 ----- tests/Feature/MonitorServiceProviderTest.php | 223 --------- tests/Feature/NestingTest.php | 122 +++++ tests/Feature/RetryAndTransactionTest.php | 159 +++++++ tests/Feature/StartMonitorTraceTest.php | 100 ---- tests/Feature/StructuredLoggerTest.php | 260 ---------- tests/Feature/StructuredLoggingTapTest.php | 199 -------- tests/Fixtures/BrokenEscalation.php | 17 + tests/Fixtures/CardDeclined.php | 15 + tests/Fixtures/Fatal.php | 9 + tests/Fixtures/Flaky.php | 9 + tests/Fixtures/PagePayments.php | 18 + tests/Pest.php | 80 ++-- tests/TestCase.php | 36 +- tests/Unit/CircuitBreakerStateTest.php | 448 ------------------ tests/Unit/CircuitBreakerTest.php | 152 ------ tests/Unit/ControlStackTest.php | 41 ++ tests/Unit/ControlledContextTest.php | 69 --- tests/Unit/ControlledFailureMetaTest.php | 178 ------- tests/Unit/ControlledTest.php | 180 ------- tests/Unit/DomainAndNamesTest.php | 38 ++ tests/Unit/MonitorTest.php | 262 ---------- tests/Unit/OriginWrapperTest.php | 152 ------ tests/Unit/ProfilesTest.php | 47 ++ tests/Unit/StructuredJsonFormatterTest.php | 360 -------------- tests/Unit/TraceTest.php | 252 ++-------- workbench/app/Models/User.php | 48 -- workbench/database/factories/UserFactory.php | 2 +- 120 files changed, 4675 insertions(+), 6378 deletions(-) create mode 100644 .gitattributes create mode 100755 .githooks/commit-msg create mode 100644 .github/workflows/security.yml delete mode 100644 config/logging-monitor.php create mode 100644 rector.php create mode 100644 src/Breaker/BreakerConfig.php create mode 100644 src/Breaker/BreakerState.php create mode 100644 src/Breaker/CircuitBreaker.php create mode 100644 src/Breaker/Decision.php create mode 100644 src/Breaker/State.php delete mode 100644 src/CircuitBreaker.php create mode 100644 src/Control.php create mode 100644 src/ControlStack.php delete mode 100644 src/Controlled.php delete mode 100644 src/Data/CircuitBreakerState.php delete mode 100644 src/Data/ControlledFailureMeta.php delete mode 100644 src/Enums/OriginWrapper.php create mode 100644 src/Escalations/Escalation.php create mode 100644 src/Events/BreakerClosed.php create mode 100644 src/Events/BreakerHalfOpen.php create mode 100644 src/Events/BreakerOpened.php create mode 100644 src/Events/EscalationFailed.php create mode 100644 src/Events/PointEnded.php create mode 100644 src/Events/PointEscalated.php create mode 100644 src/Events/PointLimitBreached.php create mode 100644 src/Events/PointRecovered.php create mode 100644 src/Events/PointRefused.php create mode 100644 src/Events/PointRetried.php create mode 100644 src/Events/PointStarted.php create mode 100644 src/Exceptions/InvalidControlPoint.php create mode 100644 src/Exceptions/InvalidPointName.php create mode 100644 src/Exceptions/InvalidProfile.php create mode 100644 src/Exceptions/InvalidTraceId.php rename src/Exceptions/{NestedControlledBlockException.php => MonitorException.php} (60%) delete mode 100644 src/Formatters/StructuredJsonFormatter.php delete mode 100644 src/Http/Middleware/CheckCircuitBreakers.php delete mode 100644 src/Http/Middleware/StartMonitorTrace.php create mode 100644 src/Limits/Attempts.php create mode 100644 src/Limits/Ensure.php create mode 100644 src/Limits/Limit.php create mode 100644 src/Limits/Within.php create mode 100644 src/LiveRunner.php delete mode 100644 src/LogTimer.php create mode 100644 src/Logging/StructuredLogger.php create mode 100644 src/Outcome.php create mode 100644 src/Policies/Breaker.php create mode 100644 src/Policies/Concerns/FiltersExceptions.php create mode 100644 src/Policies/Policy.php create mode 100644 src/Policies/Retry.php create mode 100644 src/Policies/Transaction.php create mode 100644 src/Risks/BreakerOpen.php create mode 100644 src/Risks/EnsureFailed.php create mode 100644 src/Risks/Risk.php create mode 100644 src/Run.php create mode 100644 src/RunInfo.php create mode 100644 src/Runner.php create mode 100644 src/Status.php delete mode 100644 src/StructuredLogger.php create mode 100644 src/Support/ChildEscalations.php delete mode 100644 src/Support/ControlledContext.php create mode 100644 src/Support/Domain.php delete mode 100644 src/Support/LogRedactor.php create mode 100644 src/Support/PointName.php create mode 100644 src/Support/Profiles.php delete mode 100644 src/Support/RedactorConfig.php delete mode 100644 src/Taps/StructuredLoggingTap.php create mode 100644 src/Testing/MonitorFake.php delete mode 100644 src/Trace.php create mode 100644 src/Trace/Trace.php create mode 100644 src/Trace/TraceParent.php create mode 100644 tests/Feature/BreakerTest.php delete mode 100644 tests/Feature/CheckCircuitBreakersMiddlewareTest.php create mode 100644 tests/Feature/ControlTest.php delete mode 100644 tests/Feature/ControlledConfigurationTest.php delete mode 100644 tests/Feature/ControlledFeatureTest.php delete mode 100644 tests/Feature/ControlledIntegrationTest.php delete mode 100644 tests/Feature/ControlledTransactionRetryTest.php create mode 100644 tests/Feature/FakeTest.php delete mode 100644 tests/Feature/LogRedactorTest.php delete mode 100644 tests/Feature/MonitorServiceProviderTest.php create mode 100644 tests/Feature/NestingTest.php create mode 100644 tests/Feature/RetryAndTransactionTest.php delete mode 100644 tests/Feature/StartMonitorTraceTest.php delete mode 100644 tests/Feature/StructuredLoggerTest.php delete mode 100644 tests/Feature/StructuredLoggingTapTest.php create mode 100644 tests/Fixtures/BrokenEscalation.php create mode 100644 tests/Fixtures/CardDeclined.php create mode 100644 tests/Fixtures/Fatal.php create mode 100644 tests/Fixtures/Flaky.php create mode 100644 tests/Fixtures/PagePayments.php delete mode 100644 tests/Unit/CircuitBreakerStateTest.php delete mode 100644 tests/Unit/CircuitBreakerTest.php create mode 100644 tests/Unit/ControlStackTest.php delete mode 100644 tests/Unit/ControlledContextTest.php delete mode 100644 tests/Unit/ControlledFailureMetaTest.php delete mode 100644 tests/Unit/ControlledTest.php create mode 100644 tests/Unit/DomainAndNamesTest.php delete mode 100644 tests/Unit/MonitorTest.php delete mode 100644 tests/Unit/OriginWrapperTest.php create mode 100644 tests/Unit/ProfilesTest.php delete mode 100644 tests/Unit/StructuredJsonFormatterTest.php delete mode 100644 workbench/app/Models/User.php 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 0d3e84b..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.4.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 2327916..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@v4 + uses: actions/checkout@v7 with: ref: main @@ -25,7 +25,7 @@ jobs: release-notes: ${{ github.event.release.body }} - name: Commit updated CHANGELOG - uses: stefanzweifel/git-auto-commit-action@v5 + uses: stefanzweifel/git-auto-commit-action@v7 with: branch: main commit_message: Update CHANGELOG diff --git a/.github/workflows/php-tests.yml b/.github/workflows/php-tests.yml index 193f986..5a58f1c 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@v4 + 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 --compact --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..b6aaba4 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,61 @@ +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 + + # The package's own rule samples and test fixtures are credential-shaped + # by design; the Stripe sample is Stripe's public documentation key. + # 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 dfcbae4..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@v4 + - 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 981eedd..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@v4 - - - 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/composer.json b/composer.json index 5222b94..8ff4740 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,35 @@ "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", + "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 +88,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 +97,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..5fdb18a 100644 --- a/config/monitor.php +++ b/config/monitor.php @@ -1,291 +1,186 @@ env('MONITOR_ENABLED', true), - - /* - |-------------------------------------------------------------------------- - | Origin Prefix - |-------------------------------------------------------------------------- - | - | 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 - | - */ - - 'prefix' => null, + 'domains' => [ + 'map' => [ + 'App\\ControlPoints\\' => null, + 'App\\Services\\' => null, + 'App\\' => null, + ], + 'fallback' => 'App', + ], /* |-------------------------------------------------------------------------- - | Origin Path Replacers + | Critical Namespaces |-------------------------------------------------------------------------- | - | 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 + | 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. | */ - 'origin_path_replacers' => [ - 'App\\' => 'Monitor\\', + 'critical_namespaces' => [ + 'App\\ControlPoints', ], /* |-------------------------------------------------------------------------- - | Origin Separator + | Point Names |-------------------------------------------------------------------------- | - | 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: ':', '.', '-', '_' + | 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_separator' => ':', + 'point_name_pattern' => '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/', /* |-------------------------------------------------------------------------- - | Origin Path Wrapper + | Discovery |-------------------------------------------------------------------------- | - | 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]). - | - | 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) + | Where the inventory looks for control point classes and for inline + | Monitor::control() calls. Paths are absolute or relative to base_path(). | */ - 'origin_path_wrapper' => 'square', + 'discovery' => [ + 'paths' => ['app'], + ], /* |-------------------------------------------------------------------------- - | 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 + | Records |-------------------------------------------------------------------------- | - | Configuration for circuit breaker middleware and behavior. + | 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. | */ - '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), + '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, + 'store' => [ + 'enabled' => env('MONITOR_STORE', false), + 'connection' => env('MONITOR_STORE_CONNECTION'), + 'table' => 'monitor_outcomes', + 'retention_days' => 30, + ], ], /* |-------------------------------------------------------------------------- - | Redactor Configuration + | MCP |-------------------------------------------------------------------------- | - | 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. + | 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' => [ - - /* - |---------------------------------------------------------------------- - | Redactor Enabled - |---------------------------------------------------------------------- - | - | Whether the log redactor is enabled. When disabled, no redaction - | will be performed on log context data. - | - */ - - 'enabled' => env('MONITOR_REDACTOR_ENABLED', true), - - /* - |---------------------------------------------------------------------- - | Redactor Profile - |---------------------------------------------------------------------- - | - | The redactor profile to use. This corresponds to profiles configured - | in the Kirschbaum Redactor package. - | - */ - - 'redactor_profile' => env('MONITOR_REDACTOR_PROFILE', 'default'), + 'mcp' => [ + 'enabled' => env('MONITOR_MCP', false), + 'handle' => 'monitor', ], ]; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index db92607..528217c 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 + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..c06bca6 --- /dev/null +++ b/rector.php @@ -0,0 +1,24 @@ +withPaths([ + __DIR__.'/src', + __DIR__.'/config', + __DIR__.'/tests', + ]) + ->withPhpSets(php83: true) + ->withPreparedSets( + deadCode: true, + codeQuality: true, + typeDeclarations: true, + earlyReturn: true, + ) + ->withSets([ + LaravelSetList::LARAVEL_CODE_QUALITY, + LaravelSetList::LARAVEL_COLLECTION, + ]); 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/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..f3827c3 --- /dev/null +++ b/src/Breaker/BreakerState.php @@ -0,0 +1,114 @@ + $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); + } +} diff --git a/src/Breaker/CircuitBreaker.php b/src/Breaker/CircuitBreaker.php new file mode 100644 index 0000000..8a197d4 --- /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 attempt(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/Control.php b/src/Control.php new file mode 100644 index 0000000..a529551 --- /dev/null +++ b/src/Control.php @@ -0,0 +1,404 @@ + */ + 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}> */ + private array $risks = []; + + /** @var Closure|class-string|null */ + private Closure|string|null $escalation = null; + + public function __construct(private readonly string $name, string|object|null $origin = null) + { + PointName::validate($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; + } + + /** + * 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 $handler + */ + public function recover(string $class, Closure $handler): self + { + if (! is_a($class, Throwable::class, true)) { + throw new InvalidControlPoint(sprintf('recover() expects a Throwable class, got "%s".', $class)); + } + + $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; + } + + /** + * Execute and return the value, or throw what escaped. + * + * @param Closure(): mixed $callback + */ + 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}> + */ + 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(), + 'catch_all' => $this->hasCatchAll(), + 'escalation' => is_string($escalation) ? $escalation : ($escalation instanceof Closure ? 'closure' : null), + ]; + } + + 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', + default => $policy::class, + }; + } +} diff --git a/src/ControlStack.php b/src/ControlStack.php new file mode 100644 index 0000000..11ccc7f --- /dev/null +++ b/src/ControlStack.php @@ -0,0 +1,107 @@ +all(); + $stack[] = ['point' => $point, 'run_id' => $runId]; + + 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}|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()); + } + + /** + * @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']]; + } + } + + 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/Escalations/Escalation.php b/src/Escalations/Escalation.php new file mode 100644 index 0000000..0de6ff1 --- /dev/null +++ b/src/Escalations/Escalation.php @@ -0,0 +1,19 @@ + outcomes(?string $point = null) + * + * @see \Kirschbaum\Monitor\Monitor */ class Monitor extends Facade { @@ -20,4 +38,29 @@ 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. + * + * @param array $returning canned values keyed by point name + */ + public static function fake(array $returning = []): MonitorFake + { + $app = Container::getInstance(); + $fake = new MonitorFake($app, $app->make(Runner::class)); + + foreach ($returning as $point => $value) { + $fake->returning($point, $value); + } + + $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/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/Limits/Attempts.php b/src/Limits/Attempts.php new file mode 100644 index 0000000..88508b3 --- /dev/null +++ b/src/Limits/Attempts.php @@ -0,0 +1,23 @@ + 'attempts', 'max' => $this->max]; + } +} diff --git a/src/Limits/Ensure.php b/src/Limits/Ensure.php new file mode 100644 index 0000000..146ff21 --- /dev/null +++ b/src/Limits/Ensure.php @@ -0,0 +1,35 @@ +check)($value) === true; + } + + public function describe(): array + { + return ['type' => 'ensure', 'reason' => $this->reason]; + } +} diff --git a/src/Limits/Limit.php b/src/Limits/Limit.php new file mode 100644 index 0000000..4110fc3 --- /dev/null +++ b/src/Limits/Limit.php @@ -0,0 +1,19 @@ + + */ + public function describe(): array; +} diff --git a/src/Limits/Within.php b/src/Limits/Within.php new file mode 100644 index 0000000..8fccc30 --- /dev/null +++ b/src/Limits/Within.php @@ -0,0 +1,36 @@ +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..5d7e30f --- /dev/null +++ b/src/LiveRunner.php @@ -0,0 +1,25 @@ +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/StructuredLogger.php b/src/Logging/StructuredLogger.php new file mode 100644 index 0000000..efe4b8a --- /dev/null +++ b/src/Logging/StructuredLogger.php @@ -0,0 +1,85 @@ + */ + private array $context = []; + + public function __construct(private readonly Container $container, string|object $origin, private readonly ?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 + { + return new self($this->container, $this->origin, $channel); + } + + /** + * @param array $context + */ + public function log($level, string|Stringable $message, array $context = []): void + { + $logger = $this->container->make(LogManager::class); + $writer = $this->channel !== null ? $logger->channel($this->channel) : $logger; + + $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/Monitor.php b/src/Monitor.php index 6d05b8b..3880b38 100644 --- a/src/Monitor.php +++ b/src/Monitor.php @@ -4,37 +4,46 @@ namespace Kirschbaum\Monitor; -use Kirschbaum\Monitor\Support\LogRedactor; +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 $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 $origin): StructuredLogger { - return app(LogRedactor::class); + return new StructuredLogger($this->container, $origin); } } diff --git a/src/MonitorServiceProvider.php b/src/MonitorServiceProvider.php index c273903..486200f 100644 --- a/src/MonitorServiceProvider.php +++ b/src/MonitorServiceProvider.php @@ -4,8 +4,10 @@ namespace Kirschbaum\Monitor; +use Illuminate\Contracts\Container\Container; use Illuminate\Support\ServiceProvider; -use Kirschbaum\Monitor\Support\ControlledContext; +use Kirschbaum\Monitor\Breaker\CircuitBreaker; +use Kirschbaum\Monitor\Trace\Trace; class MonitorServiceProvider extends ServiceProvider { @@ -13,13 +15,11 @@ 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->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->bind(Runner::class, LiveRunner::class); } public function boot(): void @@ -28,21 +28,8 @@ public function boot(): void __DIR__.'/../config/monitor.php' => config_path('monitor.php'), ], 'monitor-config'); - $this->publishes([ - __DIR__.'/../config/logging-monitor.php' => config_path('logging-monitor.php'), - ], 'monitor-logging'); - - // 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); - - $shouldAutoTrace = $consoleAutoTraceEnabled - && $this->app->runningInConsole() - && ($enableInTesting || ! $this->app->environment('testing')) - && ! app(Trace::class)->hasStarted(); - - if ($shouldAutoTrace) { - app(Trace::class)->start(); + if ($this->app->runningInConsole() && $this->app->make('config')->get('monitor.trace.console', true)) { + $this->app->make(Trace::class)->pickup(); } } } diff --git a/src/Outcome.php b/src/Outcome.php new file mode 100644 index 0000000..d259fd4 --- /dev/null +++ b/src/Outcome.php @@ -0,0 +1,123 @@ + $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 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, + ]; + } +} diff --git a/src/Policies/Breaker.php b/src/Policies/Breaker.php new file mode 100644 index 0000000..fea6ee5 --- /dev/null +++ b/src/Policies/Breaker.php @@ -0,0 +1,129 @@ +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->attempt($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/Policy.php b/src/Policies/Policy.php new file mode 100644 index 0000000..b9aa0f8 --- /dev/null +++ b/src/Policies/Policy.php @@ -0,0 +1,39 @@ + + */ + public function describe(): array; +} diff --git a/src/Policies/Retry.php b/src/Policies/Retry.php new file mode 100644 index 0000000..baa470d --- /dev/null +++ b/src/Policies/Retry.php @@ -0,0 +1,115 @@ +setTimes($times); + } + + public function setTimes(int $times): self + { + $this->times = max(0, $times); + + return $this; + } + + 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..6086eec --- /dev/null +++ b/src/Policies/Transaction.php @@ -0,0 +1,89 @@ +only = [DeadlockException::class]; + } + + public static function retries(int $retries): self + { + return (new self)->setRetries($retries); + } + + public function setRetries(int $retries): self + { + $this->retries = max(0, $retries); + + return $this; + } + + 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/Risks/BreakerOpen.php b/src/Risks/BreakerOpen.php new file mode 100644 index 0000000..bd19e11 --- /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; + } + + 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; + $parentId = $this->stack->currentRunId(); + $id = (string) Str::ulid(); + + $this->stack->push($this->control->name(), $id); + + $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 { + $value = $risk['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->notifyEscalation($outcome); + } + + $this->events->dispatch(new PointEnded($outcome)); + } + + private function notifyEscalation(Outcome $outcome): void + { + $escalation = $this->control->escalation(); + + if ($escalation === null) { + 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, + ); + } + + 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..210cbe1 --- /dev/null +++ b/src/RunInfo.php @@ -0,0 +1,46 @@ + $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, + ) {} + + /** + * @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, + 'stack' => $this->stack, + 'context' => $this->context, + ]; + } +} diff --git a/src/Runner.php b/src/Runner.php new file mode 100644 index 0000000..524e9f3 --- /dev/null +++ b/src/Runner.php @@ -0,0 +1,18 @@ +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..28efefb --- /dev/null +++ b/src/Support/ChildEscalations.php @@ -0,0 +1,44 @@ +|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..c9c59db --- /dev/null +++ b/src/Support/Domain.php @@ -0,0 +1,43 @@ + $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/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..0feb627 --- /dev/null +++ b/src/Support/PointName.php @@ -0,0 +1,31 @@ + + */ + 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/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/MonitorFake.php b/src/Testing/MonitorFake.php new file mode 100644 index 0000000..72b893a --- /dev/null +++ b/src/Testing/MonitorFake.php @@ -0,0 +1,237 @@ + */ + private array $outcomes = []; + + /** @var array */ + private array $returning = []; + + /** @var array */ + private array $failing = []; + + public function __construct(Container $container, private readonly Runner $live) + { + parent::__construct($container); + } + + 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 $point, mixed $value): static + { + $this->returning[$point] = $value; + unset($this->failing[$point]); + + return $this; + } + + /** + * Make the point's callback throw this, exercising its own corrections. + */ + public function failing(string $point, Throwable $exception): static + { + $this->failing[$point] = $exception; + unset($this->returning[$point]); + + return $this; + } + + /** + * @return list + */ + public function outcomes(?string $point = null): array + { + if ($point === null) { + return $this->outcomes; + } + + return array_values(array_filter($this->outcomes, fn (Outcome $o): bool => $o->point === $point)); + } + + public function forget(): void + { + $this->outcomes = []; + } + + /** + * @param Closure(Outcome): bool|null $callback + */ + public function assertRan(string $point, ?Closure $callback = null): static + { + $matching = $this->matching($point, $callback); + + Assert::assertNotEmpty($matching, $callback instanceof Closure + ? "Control point [{$point}] ran, but no outcome matched the callback." + : "Control point [{$point}] did not run."); + + return $this; + } + + public function assertRanTimes(string $point, int $times): static + { + $count = count($this->outcomes($point)); + + Assert::assertSame($times, $count, "Control point [{$point}] ran {$count} times, expected {$times}."); + + return $this; + } + + public function assertNeverRan(string $point): static + { + Assert::assertEmpty($this->outcomes($point), "Control point [{$point}] ran, but was expected not to."); + + return $this; + } + + /** + * @param Closure(Outcome): bool|null $callback + */ + public function assertSucceeded(string $point, ?Closure $callback = null): static + { + $this->assertStatus($point, Status::Succeeded, $callback); + + return $this; + } + + /** + * @param class-string|null $from + */ + public function assertRecovered(string $point, ?string $from = null): static + { + $this->assertStatus($point, Status::Recovered, $from === null ? null : fn (Outcome $o): bool => $o->recoveredFrom === $from); + + return $this; + } + + /** + * @param class-string|null $with + */ + public function assertEscalated(string $point, ?string $with = null): static + { + $this->assertStatus($point, Status::Escalated, $with === null ? null : fn (Outcome $o): bool => $o->exception instanceof $with); + + return $this; + } + + public function assertRefused(string $point): static + { + $this->assertStatus($point, Status::Refused, null); + + return $this; + } + + public function assertRetried(string $point, ?int $times = null): static + { + $this->assertRan($point); + + $matching = $this->matching($point, $times === null + ? fn (Outcome $o): bool => $o->attempts > 1 + : fn (Outcome $o): bool => $o->attempts === $times + 1); + + Assert::assertNotEmpty($matching, $times === null + ? "Control point [{$point}] was not retried." + : "Control point [{$point}] was not retried exactly {$times} times."); + + return $this; + } + + public function assertLimitBreached(string $point, string $limit): static + { + $this->assertRan($point); + + Assert::assertNotEmpty( + $this->matching($point, fn (Outcome $o): bool => $o->breachedLimit($limit)), + "Control point [{$point}] did not breach the [{$limit}] limit.", + ); + + return $this; + } + + public function assertNothingEscalated(): static + { + $escalated = array_map(fn (Outcome $o): string => $o->point, array_filter($this->outcomes, fn (Outcome $o): bool => $o->escalated())); + + Assert::assertEmpty($escalated, 'Control points escalated: '.implode(', ', $escalated).'.'); + + return $this; + } + + public function assertNothingRan(): static + { + Assert::assertEmpty($this->outcomes, 'Control points ran: '.implode(', ', array_map(fn (Outcome $o): string => $o->point, $this->outcomes)).'.'); + + return $this; + } + + /** + * @param Closure(Outcome): bool|null $callback + */ + private function assertStatus(string $point, Status $status, ?Closure $callback): void + { + $this->assertRan($point); + + $matching = $this->matching($point, fn (Outcome $o): bool => $o->status === $status && (! $callback instanceof Closure || $callback($o))); + + $seen = implode(', ', array_map(fn (Outcome $o): string => $o->status->value, $this->outcomes($point))); + + Assert::assertNotEmpty($matching, "Control point [{$point}] did not end {$status->value}; saw: {$seen}."); + } + + /** + * @param Closure(Outcome): bool|null $callback + * @return list + */ + private function matching(string $point, ?Closure $callback): array + { + return array_values(array_filter( + $this->outcomes($point), + fn (Outcome $o): bool => ! $callback instanceof Closure || $callback($o), + )); + } +} 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/Trace.php b/src/Trace/Trace.php new file mode 100644 index 0000000..2e3910d --- /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..c3eea64 --- /dev/null +++ b/src/Trace/TraceParent.php @@ -0,0 +1,42 @@ +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()->attempt('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/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/FakeTest.php b/tests/Feature/FakeTest.php new file mode 100644 index 0000000..a35e7de --- /dev/null +++ b/tests/Feature/FakeTest.php @@ -0,0 +1,108 @@ +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') + ->assertNeverRan('payment.refund') + ->assertNothingEscalated(); + }); + + it('returns a canned value without running the callback', function (): void { + Monitor::fake(['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::assertNeverRan('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, expected 2'); + }); + + 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(); + }); +}); 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/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/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/StructuredLoggerTest.php b/tests/Feature/StructuredLoggerTest.php deleted file mode 100644 index 84a8ba3..0000000 --- a/tests/Feature/StructuredLoggerTest.php +++ /dev/null @@ -1,260 +0,0 @@ -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; - } - - $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'); -}); 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/Fixtures/BrokenEscalation.php b/tests/Fixtures/BrokenEscalation.php new file mode 100644 index 0000000..b8012fa --- /dev/null +++ b/tests/Fixtures/BrokenEscalation.php @@ -0,0 +1,17 @@ +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..0648057 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,12 +4,9 @@ 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 Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase as BaseTestCase; @@ -20,8 +17,8 @@ class TestCase extends BaseTestCase protected function getPackageProviders($app): array { return [ - MonitorServiceProvider::class, RedactorServiceProvider::class, + MonitorServiceProvider::class, ]; } @@ -31,35 +28,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..acd4a8c --- /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']]); + + $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/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/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', ''], + ['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('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('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()->attempt('stripe')->probe)->toBeTrue(); + }); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 0648057..b2de373 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Http; use Kirschbaum\Monitor\MonitorServiceProvider; use Kirschbaum\Redactor\RedactorServiceProvider; +use Laravel\Mcp\Server\McpServiceProvider; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase as BaseTestCase; @@ -18,6 +19,7 @@ protected function getPackageProviders($app): array { return [ RedactorServiceProvider::class, + McpServiceProvider::class, MonitorServiceProvider::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(); + }); +}); From 5036a83c7dcce7d2d65ca38106861aeb85853a5a Mon Sep 17 00:00:00 2001 From: Belisar Hoxholli Date: Mon, 14 Sep 2026 21:35:34 +0200 Subject: [PATCH 03/13] Add the class form, the inventory, its rules and commands [no-verify] --- extension.neon | 25 ++ .../PhpStan/CriticalNamespaceRule.php | 101 ++++++++ src/Attributes/Point.php | 21 ++ src/Console/ExplainCommand.php | 40 ++++ src/Console/MakeControlPointCommand.php | 122 ++++++++++ src/Console/PointsCommand.php | 59 +++++ src/Control.php | 9 +- src/ControlPoint.php | 164 +++++++++++++ src/Inventory/AstScanner.php | 106 +++++++++ src/Inventory/Discovery.php | 180 +++++++++++++++ src/Inventory/Explainer.php | 97 ++++++++ src/Inventory/Finding.php | 41 ++++ src/Inventory/Inventory.php | 87 +++++++ src/Inventory/PointDescription.php | 70 ++++++ src/Inventory/Reports/JsonReport.php | 15 ++ src/Inventory/Reports/SarifReport.php | 60 +++++ src/Inventory/Reports/TableReport.php | 67 ++++++ .../Rules/CatchAllWithoutEscalation.php | 35 +++ .../Rules/CriticalNamespaceUncontrolled.php | 89 ++++++++ src/Inventory/Rules/DuplicateNames.php | 48 ++++ src/Inventory/Rules/DynamicName.php | 35 +++ src/Inventory/Rules/MissingEscalation.php | 35 +++ src/Inventory/Rules/NamePattern.php | 32 +++ src/Inventory/Rules/Rule.php | 18 ++ src/Inventory/Rules/Rules.php | 47 ++++ src/Inventory/Rules/UnreadableControl.php | 37 +++ src/Inventory/ScannedFile.php | 22 ++ src/Testing/Expectations.php | 137 +++++++++++ stubs/control-point-test.stub | 22 ++ stubs/control-point.stub | 48 ++++ tests/Feature/ControlPointClassTest.php | 110 +++++++++ tests/Feature/EnforcementTest.php | 72 ++++++ tests/Feature/InventoryTest.php | 215 ++++++++++++++++++ tests/Fixtures/PhpStan/critical.neon | 9 + .../Escalations/PagePayments.php | 19 ++ .../ControlPoints/Filings/FilingStatus.php | 14 ++ .../ControlPoints/Filings/SubmitFiling.php | 27 +++ .../ControlPoints/Filings/Uncontrolled.php | 16 ++ .../app/ControlPoints/Payments/BadName.php | 26 +++ .../app/ControlPoints/Payments/ChargeCard.php | 40 ++++ .../Payments/DuplicateCharge.php | 26 +++ .../ControlPoints/Payments/InlineCharger.php | 23 ++ .../Payments/NeedsConstructor.php | 28 +++ .../app/ControlPoints/Payments/RefundCard.php | 27 +++ workbench/app/Support/CardDeclined.php | 15 ++ workbench/app/Support/StripeClient.php | 28 +++ 46 files changed, 2562 insertions(+), 2 deletions(-) create mode 100644 extension.neon create mode 100644 src/Analysis/PhpStan/CriticalNamespaceRule.php create mode 100644 src/Attributes/Point.php create mode 100644 src/Console/ExplainCommand.php create mode 100644 src/Console/MakeControlPointCommand.php create mode 100644 src/Console/PointsCommand.php create mode 100644 src/ControlPoint.php create mode 100644 src/Inventory/AstScanner.php create mode 100644 src/Inventory/Discovery.php create mode 100644 src/Inventory/Explainer.php create mode 100644 src/Inventory/Finding.php create mode 100644 src/Inventory/Inventory.php create mode 100644 src/Inventory/PointDescription.php create mode 100644 src/Inventory/Reports/JsonReport.php create mode 100644 src/Inventory/Reports/SarifReport.php create mode 100644 src/Inventory/Reports/TableReport.php create mode 100644 src/Inventory/Rules/CatchAllWithoutEscalation.php create mode 100644 src/Inventory/Rules/CriticalNamespaceUncontrolled.php create mode 100644 src/Inventory/Rules/DuplicateNames.php create mode 100644 src/Inventory/Rules/DynamicName.php create mode 100644 src/Inventory/Rules/MissingEscalation.php create mode 100644 src/Inventory/Rules/NamePattern.php create mode 100644 src/Inventory/Rules/Rule.php create mode 100644 src/Inventory/Rules/Rules.php create mode 100644 src/Inventory/Rules/UnreadableControl.php create mode 100644 src/Inventory/ScannedFile.php create mode 100644 src/Testing/Expectations.php create mode 100644 stubs/control-point-test.stub create mode 100644 stubs/control-point.stub create mode 100644 tests/Feature/ControlPointClassTest.php create mode 100644 tests/Feature/EnforcementTest.php create mode 100644 tests/Feature/InventoryTest.php create mode 100644 tests/Fixtures/PhpStan/critical.neon create mode 100644 workbench/app/ControlPoints/Escalations/PagePayments.php create mode 100644 workbench/app/ControlPoints/Filings/FilingStatus.php create mode 100644 workbench/app/ControlPoints/Filings/SubmitFiling.php create mode 100644 workbench/app/ControlPoints/Filings/Uncontrolled.php create mode 100644 workbench/app/ControlPoints/Payments/BadName.php create mode 100644 workbench/app/ControlPoints/Payments/ChargeCard.php create mode 100644 workbench/app/ControlPoints/Payments/DuplicateCharge.php create mode 100644 workbench/app/ControlPoints/Payments/InlineCharger.php create mode 100644 workbench/app/ControlPoints/Payments/NeedsConstructor.php create mode 100644 workbench/app/ControlPoints/Payments/RefundCard.php create mode 100644 workbench/app/Support/CardDeclined.php create mode 100644 workbench/app/Support/StripeClient.php 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/src/Analysis/PhpStan/CriticalNamespaceRule.php b/src/Analysis/PhpStan/CriticalNamespaceRule.php new file mode 100644 index 0000000..643ae4d --- /dev/null +++ b/src/Analysis/PhpStan/CriticalNamespaceRule.php @@ -0,0 +1,101 @@ + + */ +final readonly class CriticalNamespaceRule implements Rule +{ + /** + * @param list $namespaces + */ + public function __construct(private 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..018d0fd --- /dev/null +++ b/src/Attributes/Point.php @@ -0,0 +1,21 @@ +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/MakeControlPointCommand.php b/src/Console/MakeControlPointCommand.php new file mode 100644 index 0000000..a91a068 --- /dev/null +++ b/src/Console/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/PointsCommand.php b/src/Console/PointsCommand.php new file mode 100644 index 0000000..f90501b --- /dev/null +++ b/src/Console/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/Control.php b/src/Control.php index a529551..0e29008 100644 --- a/src/Control.php +++ b/src/Control.php @@ -55,9 +55,14 @@ final class Control /** @var Closure|class-string|null */ private Closure|string|null $escalation = null; - public function __construct(private readonly string $name, string|object|null $origin = null) + /** + * @param bool $validate false only when describing a point whose name may be invalid + */ + public function __construct(private readonly string $name, string|object|null $origin = null, bool $validate = true) { - PointName::validate($name); + if ($validate) { + PointName::validate($name); + } $this->origin = $this->originOf($origin); } diff --git a/src/ControlPoint.php b/src/ControlPoint.php new file mode 100644 index 0000000..c748327 --- /dev/null +++ b/src/ControlPoint.php @@ -0,0 +1,164 @@ +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)); + } + + /** + * 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("name")] 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/Inventory/AstScanner.php b/src/Inventory/AstScanner.php new file mode 100644 index 0000000..96cbc74 --- /dev/null +++ b/src/Inventory/AstScanner.php @@ -0,0 +1,106 @@ +createForNewestSupportedVersion()->parse($code); + } catch (Error) { + return new ScannedFile($path, [], []); + } + + if ($ast === null) { + 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..f68cf7d --- /dev/null +++ b/src/Inventory/Discovery.php @@ -0,0 +1,180 @@ +|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: $this->arrays($described['policies'] ?? null), + limits: $this->arrays($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), + ); + } + + /** + * @return list> + */ + private function arrays(mixed $value): array + { + $out = []; + + if (! is_array($value)) { + return $out; + } + + foreach ($value as $item) { + if (! is_array($item)) { + continue; + } + + $row = []; + + foreach ($item as $key => $itemValue) { + $row[(string) $key] = $itemValue; + } + + $out[] = $row; + } + + return $out; + } + + /** + * @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..7fd22e0 --- /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..4957bde --- /dev/null +++ b/src/Inventory/Finding.php @@ -0,0 +1,41 @@ +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, + ]; + } +} diff --git a/src/Inventory/Inventory.php b/src/Inventory/Inventory.php new file mode 100644 index 0000000..835b8f8 --- /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..bd86fe8 --- /dev/null +++ b/src/Inventory/PointDescription.php @@ -0,0 +1,70 @@ +> $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, + ]; + } +} diff --git a/src/Inventory/Reports/JsonReport.php b/src/Inventory/Reports/JsonReport.php new file mode 100644 index 0000000..ac1d1e2 --- /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..22fd61f --- /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..e066bd9 --- /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..2b37278 --- /dev/null +++ b/src/Inventory/Rules/CatchAllWithoutEscalation.php @@ -0,0 +1,35 @@ +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..1202440 --- /dev/null +++ b/src/Inventory/Rules/CriticalNamespaceUncontrolled.php @@ -0,0 +1,89 @@ +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..d3dfed0 --- /dev/null +++ b/src/Inventory/Rules/DuplicateNames.php @@ -0,0 +1,48 @@ +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..d446aed --- /dev/null +++ b/src/Inventory/Rules/DynamicName.php @@ -0,0 +1,35 @@ +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..e239ff0 --- /dev/null +++ b/src/Inventory/Rules/MissingEscalation.php @@ -0,0 +1,35 @@ +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..eee053d --- /dev/null +++ b/src/Inventory/Rules/NamePattern.php @@ -0,0 +1,32 @@ +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/Rule.php b/src/Inventory/Rules/Rule.php new file mode 100644 index 0000000..0dea54f --- /dev/null +++ b/src/Inventory/Rules/Rule.php @@ -0,0 +1,18 @@ + + */ + public function check(Inventory $inventory): array; +} diff --git a/src/Inventory/Rules/Rules.php b/src/Inventory/Rules/Rules.php new file mode 100644 index 0000000..1f6add3 --- /dev/null +++ b/src/Inventory/Rules/Rules.php @@ -0,0 +1,47 @@ +> + */ + 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..2e65bbe --- /dev/null +++ b/src/Inventory/Rules/UnreadableControl.php @@ -0,0 +1,37 @@ +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..924e7ef --- /dev/null +++ b/src/Inventory/ScannedFile.php @@ -0,0 +1,22 @@ + $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/Testing/Expectations.php b/src/Testing/Expectations.php new file mode 100644 index 0000000..bf5b959 --- /dev/null +++ b/src/Testing/Expectations.php @@ -0,0 +1,137 @@ +toBeControlled(); + * expect('App\ControlPoints')->toHaveCompleteControlPoints(); + * + * The assertions are also plain static methods, for PHPUnit. + */ +final 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 + { + if (! function_exists('expect')) { + return; + } + + $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}:")); + } + + 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/stubs/control-point-test.stub b/stubs/control-point-test.stub new file mode 100644 index 0000000..2e35896 --- /dev/null +++ b/stubs/control-point-test.stub @@ -0,0 +1,22 @@ +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/tests/Feature/ControlPointClassTest.php b/tests/Feature/ControlPointClassTest.php new file mode 100644 index 0000000..d1a8d73 --- /dev/null +++ b/tests/Feature/ControlPointClassTest.php @@ -0,0 +1,110 @@ +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'); + }); + + 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/EnforcementTest.php b/tests/Feature/EnforcementTest.php new file mode 100644 index 0000000..2989f15 --- /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(1)->toHaveKey('Uncontrolled.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/InventoryTest.php b/tests/Feature/InventoryTest.php new file mode 100644 index 0000000..3353601 --- /dev/null +++ b/tests/Feature/InventoryTest.php @@ -0,0 +1,215 @@ +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', 'filing.submit', 'payment.capture', 'payment.charge', 'payment.charge', '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($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(2) + ->and($scanned->inlinePoints[0]['name'])->toBe('payment.inline') + ->and($scanned->inlinePoints[1]['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(8)->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 __DIR__.'/../../database/migrations/create_monitor_outcomes_table.php')->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 __DIR__.'/../../database/migrations/create_monitor_outcomes_table.php')->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/Fixtures/PhpStan/critical.neon b/tests/Fixtures/PhpStan/critical.neon new file mode 100644 index 0000000..031ecad --- /dev/null +++ b/tests/Fixtures/PhpStan/critical.neon @@ -0,0 +1,9 @@ +includes: + - ../../../extension.neon +parameters: + level: 0 + paths: + - ../../../workbench/app/ControlPoints + monitor: + criticalNamespaces: + - Workbench\Monitor\ControlPoints diff --git a/workbench/app/ControlPoints/Escalations/PagePayments.php b/workbench/app/ControlPoints/Escalations/PagePayments.php new file mode 100644 index 0000000..98b2f42 --- /dev/null +++ b/workbench/app/ControlPoints/Escalations/PagePayments.php @@ -0,0 +1,19 @@ + */ + public static array $paged = []; + + public function handle(Outcome $outcome): void + { + self::$paged[] = $outcome; + } +} diff --git a/workbench/app/ControlPoints/Filings/FilingStatus.php b/workbench/app/ControlPoints/Filings/FilingStatus.php new file mode 100644 index 0000000..ebb0ad4 --- /dev/null +++ b/workbench/app/ControlPoints/Filings/FilingStatus.php @@ -0,0 +1,14 @@ +transaction(retries: 1)->within(5)->recover(Throwable::class, fn (): string => 'queued for manual filing'); + } + + public function handle(): string + { + return 'filed'; + } +} diff --git a/workbench/app/ControlPoints/Filings/Uncontrolled.php b/workbench/app/ControlPoints/Filings/Uncontrolled.php new file mode 100644 index 0000000..2c2381d --- /dev/null +++ b/workbench/app/ControlPoints/Filings/Uncontrolled.php @@ -0,0 +1,16 @@ +escalate(fn (): null => null); + } + + public function handle(): bool + { + return true; + } +} diff --git a/workbench/app/ControlPoints/Payments/ChargeCard.php b/workbench/app/ControlPoints/Payments/ChargeCard.php new file mode 100644 index 0000000..64c0e37 --- /dev/null +++ b/workbench/app/ControlPoints/Payments/ChargeCard.php @@ -0,0 +1,40 @@ +breaker('stripe') + ->ensure(fn (array $r): bool => $r['settled'] === true, 'charge must be settled') + ->recover(CardDeclined::class, fn (CardDeclined $e): array => ['declined' => $e->declineCode]) + ->escalate(PagePayments::class); + } + + public function context(): array + { + return ['invoice' => $this->invoiceId, 'amount' => $this->amount]; + } + + /** + * @return array + */ + public function handle(StripeClient $stripe): array + { + return $stripe->charge($this->amount); + } +} diff --git a/workbench/app/ControlPoints/Payments/DuplicateCharge.php b/workbench/app/ControlPoints/Payments/DuplicateCharge.php new file mode 100644 index 0000000..7c056f0 --- /dev/null +++ b/workbench/app/ControlPoints/Payments/DuplicateCharge.php @@ -0,0 +1,26 @@ +escalate(fn (): null => null); + } + + public function handle(): bool + { + return true; + } +} diff --git a/workbench/app/ControlPoints/Payments/InlineCharger.php b/workbench/app/ControlPoints/Payments/InlineCharger.php new file mode 100644 index 0000000..2be0231 --- /dev/null +++ b/workbench/app/ControlPoints/Payments/InlineCharger.php @@ -0,0 +1,23 @@ +run(fn (): string => 'ok'); + } + + public function dynamic(string $name): string + { + return Monitor::control($name, $this)->run(fn (): string => 'ok'); + } +} diff --git a/workbench/app/ControlPoints/Payments/NeedsConstructor.php b/workbench/app/ControlPoints/Payments/NeedsConstructor.php new file mode 100644 index 0000000..d3d9edb --- /dev/null +++ b/workbench/app/ControlPoints/Payments/NeedsConstructor.php @@ -0,0 +1,28 @@ +breaker($this->gateway)->escalate(fn (): null => null); + } + + public function handle(): bool + { + return true; + } +} diff --git a/workbench/app/ControlPoints/Payments/RefundCard.php b/workbench/app/ControlPoints/Payments/RefundCard.php new file mode 100644 index 0000000..efdd33f --- /dev/null +++ b/workbench/app/ControlPoints/Payments/RefundCard.php @@ -0,0 +1,27 @@ +recover(CardDeclined::class, fn (): null => null); + } + + public function handle(): string + { + return 'refunded'; + } +} diff --git a/workbench/app/Support/CardDeclined.php b/workbench/app/Support/CardDeclined.php new file mode 100644 index 0000000..a6bf949 --- /dev/null +++ b/workbench/app/Support/CardDeclined.php @@ -0,0 +1,15 @@ +)|null */ + public static $charge = null; + + /** + * @return array + */ + public function charge(int $amount): array + { + $charge = self::$charge; + + if ($charge !== null) { + return $charge($amount); + } + + return ['id' => 'ch_'.bin2hex(random_bytes(4)), 'amount' => $amount, 'settled' => true]; + } +} From ac3f6f942588cc7cef799333f16b06912a07bc57 Mon Sep 17 00:00:00 2001 From: Belisar Hoxholli Date: Mon, 14 Sep 2026 21:35:35 +0200 Subject: [PATCH 04/13] Rewrite README and changelog, add workbench demo and logger tests --- .ai/guidelines/core.blade.php | 67 +++ .github/workflows/security.yml | 3 +- CHANGELOG.md | 144 ++++++ LICENCE.md => LICENSE.md | 0 README.md | 683 ++++--------------------- composer.json | 1 + src/Mcp/MonitorServer.php | 65 +++ src/Mcp/Prompts/WrapOperation.php | 68 +++ src/Mcp/Resources/Guidelines.php | 29 ++ src/Mcp/Resources/RecordSchema.php | 24 + src/Mcp/ResponseRedactor.php | 147 ++++++ src/Mcp/Tools/Escalations.php | 69 +++ src/Mcp/Tools/ExplainPoint.php | 48 ++ src/Mcp/Tools/ListPoints.php | 51 ++ src/Mcp/Tools/RecentOutcomes.php | 66 +++ src/MonitorServiceProvider.php | 54 ++ tests/Feature/McpTest.php | 139 +++++ tests/Feature/StructuredLoggerTest.php | 56 ++ tests/Performance/OverheadTest.php | 31 ++ workbench/routes/web.php | 40 +- 20 files changed, 1200 insertions(+), 585 deletions(-) create mode 100644 .ai/guidelines/core.blade.php rename LICENCE.md => LICENSE.md (100%) create mode 100644 src/Mcp/MonitorServer.php create mode 100644 src/Mcp/Prompts/WrapOperation.php create mode 100644 src/Mcp/Resources/Guidelines.php create mode 100644 src/Mcp/Resources/RecordSchema.php create mode 100644 src/Mcp/ResponseRedactor.php create mode 100644 src/Mcp/Tools/Escalations.php create mode 100644 src/Mcp/Tools/ExplainPoint.php create mode 100644 src/Mcp/Tools/ListPoints.php create mode 100644 src/Mcp/Tools/RecentOutcomes.php create mode 100644 tests/Feature/McpTest.php create mode 100644 tests/Feature/StructuredLoggerTest.php create mode 100644 tests/Performance/OverheadTest.php diff --git a/.ai/guidelines/core.blade.php b/.ai/guidelines/core.blade.php new file mode 100644 index 0000000..901290a --- /dev/null +++ b/.ai/guidelines/core.blade.php @@ -0,0 +1,67 @@ +## 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 +``` + +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. 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. + +### 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(['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::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/.github/workflows/security.yml b/.github/workflows/security.yml index b6aaba4..b92fa11 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -25,8 +25,7 @@ jobs: with: persist-credentials: false - # The package's own rule samples and test fixtures are credential-shaped - # by design; the Stripe sample is Stripe's public documentation key. + # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6361e43..9e63933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,147 @@ # 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. + +### 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. + +### Changed + +- Requires PHP 8.3 to 8.5 and Laravel 12 or 13. Laravel 11 is no longer + supported. +- `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..a4c6cbb 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. +Every application has a short list of operations where failure is expensive: charging a card, filing with a court system, posting a ledger entry, sending the one email a client is waiting for. What happens when those fail is usually decided by whoever touched the code last, in a try/catch nobody reviews as carefully as the happy path. Nobody can list those operations, each one behaves differently, and the recovery paths are the least tested code in the codebase. -> 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. +Monitor gives each of them a **control point**: a named declaration of what the operation tolerates, what bounds it, how every outcome is recorded, and who is told when it fails. Because it is a declaration, it can be listed, tested by name, enforced in CI, queried from a log backend, and taught to an agent by the shape of the API alone. ```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() - ]); - } -} - -// 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 +// The ritual, written by hand, slightly differently in every service. +try { + DB::beginTransaction(); + $charge = $this->stripe->charge($amount); + DB::commit(); +} catch (CardDeclined $e) { + DB::rollBack(); + Log::warning('card declined: '.$e->getMessage()); + return null; // the caller has to know null means declined +} catch (\Throwable $e) { + DB::rollBack(); + Log::error($e); // no operation name, no attempt, no trace + throw $e; } ``` -**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] -``` - -### 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** - -```php -use Kirschbaum\Monitor\Facades\Monitor; - -// Create and execute controlled block -$result = Monitor::controlled('payment_processing', $this) - ->run(function() { - return processPayment($data); - }); -``` - -#### **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); - }) +// 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)); ``` -**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 +Read that once and you know the operation's name, its domain, what it tolerates, what it tries again, when it stops calling Stripe, what counts as success, and who gets paged. The same declaration produces one log record per transition with `point`, `domain`, `status`, `run_id` and `trace_id` as fields. -#### **Circuit Breaker & Database Protection** +## Quick Start -**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 - -**What it does:** Provides correlation IDs that follow requests across services, jobs, and operations. + public function __construct(private readonly Invoice $invoice) {} -```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 +ChargeCard::run($invoice); // the value, or throws what escaped +ChargeCard::attempt($invoice); // an Outcome: ->status, ->value, ->exception, ->attempts, ->durationMs ``` -**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; - } - } -} -``` - -### 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; +Monitor::fake()->failing('payments.charge_card', new CardDeclined('insufficient_funds')); -// Direct redaction using configured profile -$redactedData = Monitor::redactor()->redact($sensitiveData); +ChargeCard::run($invoice); -// Custom profile redaction -$redactedData = Monitor::redactor()->redact($sensitiveData, 'strict'); - -// 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 +And prove in CI that every critical operation is declared and complete: -**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`: - -```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: - -```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) -]); -``` +## How It Works -For detailed redaction configuration, rules, patterns, and profiles, see the [Kirschbaum Redactor documentation](https://github.com/kirschbaum-development/redactor). +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. -## Complete API Reference +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. -The Monitor facade provides access to all monitoring components: +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. -```php -use Kirschbaum\Monitor\Facades\Monitor; +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. -// Structured logging -Monitor::log($origin)->info('message', $context); +## What It Covers -// Controlled execution blocks -Monitor::controlled($name, $origin)->run($callback); +- **Critical operations** through `Monitor::control()` inline or `ControlPoint` classes, with typed outcomes. +- **Resilience** through retry with backoff, whole-transaction retry on deadlock, and a closed/open/half-open circuit breaker shared across processes, also usable standalone and as the `CheckBreakers` route 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 guidelines 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. -// Distributed tracing -Monitor::trace()->start(); -Monitor::trace()->pickup($traceId); +## Documentation -// Performance timing -Monitor::time()->elapsed(); +The full documentation lives in [`docs/`](docs/README.md): -// Circuit breaker management -Monitor::breaker()->isOpen($name); +| 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. | +| [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. | +| [Agents](docs/agents.md) | The Boost guidelines, 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. | -// 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 +composer test +composer preflight # pint, rector, phpstan and pest, as the pre-commit hook runs them +composer setup-hooks # point git at .githooks ``` -**Logging Channel:** Configure a dedicated Monitor logging channel: +## Changelog -```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 -} -``` - -**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 -} -``` - -## 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 8ff4740..0edb014 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,7 @@ "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" }, diff --git a/src/Mcp/MonitorServer.php b/src/Mcp/MonitorServer.php new file mode 100644 index 0000000..bcd919b --- /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..d6e6433 --- /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..fade7dc --- /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..6bc758a --- /dev/null +++ b/src/Mcp/Tools/Escalations.php @@ -0,0 +1,69 @@ + $schema->string()->description('How far back: 15m, 2h, 7d (default 24h)'), + 'domain' => $schema->string()->description('Only this domain'), + ]; + } + + public function handle(Request $request): Response + { + $store = Container::getInstance()->make(OutcomeStore::class); + + if (! $store->enabled()) { + return Response::error('The outcome store is disabled. Set MONITOR_STORE=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..ac5fd9e --- /dev/null +++ b/src/Mcp/Tools/ExplainPoint.php @@ -0,0 +1,48 @@ + $schema->string()->description('The control point name, e.g. payment.charge')->required(), + ]; + } + + public function handle(Request $request): Response + { + $name = $request->get('point'); + + if (! is_string($name) || $name === '') { + return Response::error('Pass the control point name as "point".'); + } + + $container = Container::getInstance(); + $point = $container->make(Discovery::class)->build()->find($name); + + if ($point === null) { + return Response::error(sprintf('No control point named "%s". Use list_points to see what exists.', $name)); + } + + $store = $container->make(OutcomeStore::class); + + return Response::text($container->make(Explainer::class)->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..96b1eea --- /dev/null +++ b/src/Mcp/Tools/ListPoints.php @@ -0,0 +1,51 @@ + $schema->string()->description('Only points in this domain'), + ]; + } + + public function handle(Request $request): Response + { + $inventory = Container::getInstance()->make(Discovery::class)->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..6679039 --- /dev/null +++ b/src/Mcp/Tools/RecentOutcomes.php @@ -0,0 +1,66 @@ + $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): Response + { + $store = Container::getInstance()->make(OutcomeStore::class); + + if (! $store->enabled()) { + return Response::error('The outcome store is disabled. Set MONITOR_STORE=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/MonitorServiceProvider.php b/src/MonitorServiceProvider.php index 486200f..e29e123 100644 --- a/src/MonitorServiceProvider.php +++ b/src/MonitorServiceProvider.php @@ -5,9 +5,23 @@ namespace Kirschbaum\Monitor; use Illuminate\Contracts\Container\Container; +use Illuminate\Queue\Events\JobProcessing; use Illuminate\Support\ServiceProvider; use Kirschbaum\Monitor\Breaker\CircuitBreaker; +use Kirschbaum\Monitor\Console\ExplainCommand; +use Kirschbaum\Monitor\Console\MakeControlPointCommand; +use Kirschbaum\Monitor\Console\OutcomesCommand; +use Kirschbaum\Monitor\Console\PointsCommand; +use Kirschbaum\Monitor\Console\PruneCommand; +use Kirschbaum\Monitor\Mcp\MonitorServer; +use Kirschbaum\Monitor\Records\Recorder; +use Kirschbaum\Monitor\Store\OutcomeStore; +use Kirschbaum\Monitor\Store\StoreOutcomes; +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 { @@ -20,6 +34,26 @@ public function register(): void $this->app->singleton(ControlStack::class); $this->app->singleton(CircuitBreaker::class); $this->app->bind(Runner::class, LiveRunner::class); + $this->app->singleton(OutcomeStore::class); + $this->app->singleton(StoreOutcomes::class); + } + + /** + * Register the MCP server when laravel/mcp is installed and the server is enabled. + */ + public static function registerMcpServer(Container $app): bool + { + $config = $app->make('config'); + + if (! class_exists(Server::class) || ! $config->get('monitor.mcp.enabled', false)) { + return false; + } + + $handle = $config->get('monitor.mcp.handle', 'monitor'); + + Mcp::local(is_string($handle) && $handle !== '' ? $handle : 'monitor', MonitorServer::class); + + return true; } public function boot(): void @@ -28,6 +62,26 @@ public function boot(): void __DIR__.'/../config/monitor.php' => config_path('monitor.php'), ], 'monitor-config'); + $this->publishes([ + __DIR__.'/../database/migrations/create_monitor_outcomes_table.php' => database_path('migrations/'.date('Y_m_d_His').'_create_monitor_outcomes_table.php'), + ], 'monitor-migrations'); + + if ($this->app->make('config')->get('monitor.records.store.enabled', false)) { + $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); + } + + $this->app->make('events')->subscribe(Recorder::class); + $this->app->make('events')->subscribe(StoreOutcomes::class); + + if ($this->app->runningInConsole()) { + $this->commands([OutcomesCommand::class, PruneCommand::class, PointsCommand::class, ExplainCommand::class, MakeControlPointCommand::class]); + } + $this->app->make('events')->listen(JobProcessing::class, PicksUpJobTrace::class); + + PropagatesTrace::register($this->app->make(Trace::class)); + + self::registerMcpServer($this->app); + if ($this->app->runningInConsole() && $this->app->make('config')->get('monitor.trace.console', true)) { $this->app->make(Trace::class)->pickup(); } diff --git a/tests/Feature/McpTest.php b/tests/Feature/McpTest.php new file mode 100644 index 0000000..ac441ad --- /dev/null +++ b/tests/Feature/McpTest.php @@ -0,0 +1,139 @@ +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 __DIR__.'/../../database/migrations/create_monitor_outcomes_table.php')->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 only registered as a local server when enabled', function (): void { + expect(MonitorServiceProvider::registerMcpServer(app()))->toBeFalse() + ->and(Mcp::getLocalServer('monitor'))->toBeNull(); + + config()->set('monitor.mcp.enabled', true); + config()->set('monitor.mcp.handle', 'monitor'); + + expect(MonitorServiceProvider::registerMcpServer(app()))->toBeTrue() + ->and(Mcp::getLocalServer('monitor'))->not->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/StructuredLoggerTest.php b/tests/Feature/StructuredLoggerTest.php new file mode 100644 index 0000000..47aa679 --- /dev/null +++ b/tests/Feature/StructuredLoggerTest.php @@ -0,0 +1,56 @@ +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'; + } + }, '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/Performance/OverheadTest.php b/tests/Performance/OverheadTest.php new file mode 100644 index 0000000..8552d72 --- /dev/null +++ b/tests/Performance/OverheadTest.php @@ -0,0 +1,31 @@ +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/workbench/routes/web.php b/workbench/routes/web.php index 86a06c5..0928e65 100644 --- a/workbench/routes/web.php +++ b/workbench/routes/web.php @@ -1,7 +1,43 @@ group(function (): void { + Route::get('/', fn (): JsonResponse => response()->json([ + 'scenarios' => ['/demo/charge/ok', '/demo/charge/declined', '/demo/charge/unsettled', '/demo/charge/down', '/demo/breaker/open', '/demo/breaker/close'], + ])); + + Route::get('/demo/charge/{scenario}', function (string $scenario): JsonResponse { + StripeClient::$charge = match ($scenario) { + 'declined' => fn () => throw new CardDeclined('insufficient_funds'), + 'unsettled' => fn (int $amount): array => ['amount' => $amount, 'settled' => false], + 'down' => fn () => throw new RuntimeException('gateway unreachable'), + default => null, + }; + + $outcome = ChargeCard::attempt(48211, 12900); + + return response()->json(['scenario' => $scenario] + $outcome->toArray() + ['value' => $outcome->value]); + }); -Route::get('/', function () { - return view('welcome'); + Route::get('/demo/breaker/open', fn (): JsonResponse => response()->json(Monitor::breaker()->open('stripe', 60)->toArray())); + Route::get('/demo/breaker/close', fn (): JsonResponse => response()->json(Monitor::breaker()->close('stripe')->toArray())); }); From ba5a92e579382b2e8d64ef37f7b4115c7a31490c Mon Sep 17 00:00:00 2001 From: Belisar Hoxholli Date: Mon, 14 Sep 2026 21:37:09 +0200 Subject: [PATCH 05/13] Add the documentation and middleware aliases --- docs/README.md | 45 +++++ docs/agents.md | 100 ++++++++++ docs/breakers.md | 160 ++++++++++++++++ docs/configuration.md | 160 ++++++++++++++++ docs/control-points.md | 168 +++++++++++++++++ docs/extending.md | 238 ++++++++++++++++++++++++ docs/getting-started.md | 184 ++++++++++++++++++ docs/inventory.md | 193 +++++++++++++++++++ docs/policies-and-limits.md | 131 +++++++++++++ docs/records.md | 247 +++++++++++++++++++++++++ docs/risks-and-corrections.md | 157 ++++++++++++++++ docs/store.md | 165 +++++++++++++++++ docs/testing.md | 170 +++++++++++++++++ docs/tracing.md | 120 ++++++++++++ docs/upgrading.md | 97 ++++++++++ src/MonitorServiceProvider.php | 7 + tests/Feature/TracePropagationTest.php | 8 +- 17 files changed, 2348 insertions(+), 2 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/agents.md create mode 100644 docs/breakers.md create mode 100644 docs/configuration.md create mode 100644 docs/control-points.md create mode 100644 docs/extending.md create mode 100644 docs/getting-started.md create mode 100644 docs/inventory.md create mode 100644 docs/policies-and-limits.md create mode 100644 docs/records.md create mode 100644 docs/risks-and-corrections.md create mode 100644 docs/store.md create mode 100644 docs/testing.md create mode 100644 docs/tracing.md create mode 100644 docs/upgrading.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..6e8d2f5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,45 @@ +# 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. | +| [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, and `monitor:explain`. | +| [Testing](testing.md) | `Monitor::fake()` and its assertions, the Pest expectations, the PHPStan rule, and the package's own conventions. | +| [Agents](agents.md) | The guidelines shipped for Laravel Boost, the MCP server's tools, resources and prompt, and `make:control-point`. | +| [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..70a4983 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,100 @@ +# 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 `.ai/guidelines/core.blade.php`. [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', false), + 'handle' => 'monitor', +], +``` + +With `MONITOR_MCP=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 +``` + +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=true` must be set in the environment the command runs in. diff --git a/docs/breakers.md b/docs/breakers.md new file mode 100644 index 0000000..3a89143 --- /dev/null +++ b/docs/breakers.md @@ -0,0 +1,160 @@ +# Breakers + +- [Introduction](#introduction) +- [The State Machine](#the-state-machine) +- [Storage](#storage) +- [On a Control Point](#on-a-control-point) +- [The Standalone API](#the-standalone-api) +- [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. + +```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. + +## 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->attempt('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 +``` + +`attempt()` 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. + +`attempt()` 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()->attempt('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; +} +``` + +## 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..4eda915 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,160 @@ +# 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) +- [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 the application boots in the console, 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. | + +## 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` | Keep outcomes in a table. When enabled the package loads its migration. | +| `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` | 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_LOG_CHANNEL` | `records.channel` | +| `MONITOR_REDACTION_PROFILE` | `records.redaction` | +| `MONITOR_EXCEPTION_TRACE` | `records.exception_trace` | +| `MONITOR_STORE` | `records.store.enabled` | +| `MONITOR_STORE_CONNECTION` | `records.store.connection` | +| `MONITOR_MCP` | `mcp.enabled` | diff --git a/docs/control-points.md b/docs/control-points.md new file mode 100644 index 0000000..457369d --- /dev/null +++ b/docs/control-points.md @@ -0,0 +1,168 @@ +# 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 `InvalidPointName` 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(...)`, `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 $handler)` | Declare a risk and its correction; see [Risks and Corrections](risks-and-corrections.md). | +| `escalate(Closure\|string $escalation)` | Who is told when a failure no correction covers gets out. | +| `run(Closure $callback)` | Execute; return the value or throw what escaped. | +| `attempt(Closure $callback)` | Execute; return the `Outcome`. | +| `describe()` | The static description the inventory uses: name, origin, domain, profile, policies, limits, risk classes, whether there is a catch-all, and the escalation as a class name, `'closure'` or `null`. | + +`name()`, `origin()`, `resolvedDomain()`, `context()` and `profileName()` read back what was declared. `resolvedPolicies()`, `resolvedWithin()` and `resolvedAttempts()` return the effective policies and limits after the profile is merged. + +## 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`. | +| `(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..8c53b13 --- /dev/null +++ b/docs/extending.md @@ -0,0 +1,238 @@ +# Extending + +- [Introduction](#introduction) +- [A Custom Policy](#a-custom-policy) +- [A Custom Escalation](#a-custom-escalation) +- [Listening to Events](#listening-to-events) +- [A Custom Inventory Rule](#a-custom-inventory-rule) +- [Domain Resolution](#domain-resolution) + +## 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\Policies\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, `note($event, $detail)` to add an entry to the outcome's timeline, and `info()` for the `RunInfo`. + +A policy that logs how long the attempt took and adds it to the timeline: + +```php +namespace App\Monitor; + +use Closure; +use Kirschbaum\Monitor\Policies\Policy; +use Kirschbaum\Monitor\Run; + +final 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\Escalations\Escalation`: + +```php +namespace App\Escalations; + +use Kirschbaum\Monitor\Escalations\Escalation; +use Kirschbaum\Monitor\Outcome; + +final 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. + +## 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; + +final 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\Inventory\Rules\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\Inventory\Inventory; +use Kirschbaum\Monitor\Inventory\Rules\Rule; + +final 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). diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..29bd214 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,184 @@ +# 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. | +| `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, and `toArray()` for a scalar view with the exception summarised. + +## 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..c213e16 --- /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 the question no Laravel application could answer before: 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/policies-and-limits.md b/docs/policies-and-limits.md new file mode 100644 index 0000000..3b6a132 --- /dev/null +++ b/docs/policies-and-limits.md @@ -0,0 +1,131 @@ +# Policies and Limits + +- [Introduction](#introduction) +- [Policies](#policies) + - [Retry](#retry) + - [Transaction](#transaction) + - [Breaker](#breaker) + - [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`: `Retry::times(2)->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`: `Transaction::retries(2)->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). + +### Pipeline Order + +Policies wrap the attempt outermost first, ordered by `Policy::order()` ascending: `Breaker` is 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\Policies\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..a1f6c65 --- /dev/null +++ b/docs/records.md @@ -0,0 +1,247 @@ +# 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. | + +`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. 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. + +## 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\BreakerOpened` | `breaker` (the name), `state`: a `BreakerState`. | +| `Kirschbaum\Monitor\Events\BreakerHalfOpen` | `breaker`, `state`. | +| `Kirschbaum\Monitor\Events\BreakerClosed` | `breaker`, `state`. | + +`Outcome` is described in [Control Points](control-points.md). 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..5f694cf --- /dev/null +++ b/docs/risks-and-corrections.md @@ -0,0 +1,157 @@ +# 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) +- [The Catch-All](#the-catch-all) +- [escalate()](#escalate) + - [Escalation Classes](#escalation-classes) + - [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`. + +## 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\Escalations\Escalation` is resolved from the container, so it can take constructor dependencies: + +```php +use Kirschbaum\Monitor\Escalations\Escalation; +use Kirschbaum\Monitor\Outcome; + +final 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`. + +### 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 + +Two 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. diff --git a/docs/store.md b/docs/store.md new file mode 100644 index 0000000..9cb58e3 --- /dev/null +++ b/docs/store.md @@ -0,0 +1,165 @@ +# 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=true +``` + +```bash +php artisan vendor:publish --tag=monitor-migrations +php artisan migrate +``` + +Publishing copies the migration into `database/migrations` with a timestamp. Publishing is optional: while the store is enabled, the service provider also loads the migration straight from the package, so `php artisan migrate` alone creates the table. Publish it when you want to change it. + +```php +'records' => [ + 'store' => [ + 'enabled' => env('MONITOR_STORE', 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. | +| `ended_at` | timestamp(3), indexed | 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: + +- when the application terminates, after the response has been sent, and +- after each queued job, on `JobProcessed` and `JobExceptionOccurred`, so a long-running worker never holds outcomes across jobs. + +Rows are upserted on `run_id`, so writing the same buffer twice is harmless. + +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. `query()` returns a query builder on the table for anything else. + +## 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..01f9f95 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,170 @@ +# 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) + +## 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. + +The fake is also 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()); + +// or several at once +Monitor::fake([ + 'payment.charge' => ChargeResult::settled(), + '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. + +### Reading Outcomes + +```php +Monitor::outcomes(); // every Outcome, in order +Monitor::outcomes('payment.charge'); // for one point + +$fake->forget(); // discard what was recorded +``` + +## Assertions + +Every assertion returns the fake, so they chain. + +| Assertion | Passes when | +| --- | --- | +| `assertRan(string $point, ?Closure $callback = null)` | The point ran at least once; with a callback, at least one `Outcome` satisfies it. | +| `assertRanTimes(string $point, int $times)` | The point ran exactly that many times. | +| `assertNeverRan(string $point)` | The point did not run. | +| `assertSucceeded(string $point, ?Closure $callback = null)` | An outcome of the point is `succeeded`, and satisfies the callback if given. | +| `assertRecovered(string $point, ?string $from = null)` | An outcome is `recovered`; with `$from`, recovered from that exception class. | +| `assertEscalated(string $point, ?string $with = null)` | An outcome is `escalated`; with `$with`, the escaped exception is an instance of that class. | +| `assertRefused(string $point)` | An outcome is `refused` by a breaker. | +| `assertRetried(string $point, ?int $times = null)` | An outcome took more than one attempt; with `$times`, exactly `$times + 1` attempts. | +| `assertLimitBreached(string $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') + ->assertNeverRan('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. + +## 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 `Escalation` or `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. diff --git a/docs/tracing.md b/docs/tracing.md new file mode 100644 index 0000000..33923e1 --- /dev/null +++ b/docs/tracing.md @@ -0,0 +1,120 @@ +# 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`. + +## 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()`. + +## Console + +In console, the service provider starts a trace when the application boots, so a command's records share one ID. 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..1100629 --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,97 @@ +# 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) +- [Removed Surfaces](#removed-surfaces) +- [Logging and Middleware](#logging-and-middleware) +- [Configuration Keys](#configuration-keys) +- [The Log Shape](#the-log-shape) + +## 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)` | +| `Monitor::log($origin)` | Kept; now a PSR-3 logger whose records carry `origin` and `domain` fields. | +| `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 below for the changed methods. | +| `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`, ...). | +| `Monitor::breaker()->isOpen($name, $threshold, $decay)` | `isOpen($name)`; thresholds come from the policy or `breakers`. | +| `Monitor::breaker()->recordFailure($name, $decay)` | `recordFailure($name, ?BreakerConfig $config)`. | +| `Monitor::breaker()->forceOpen($name)`, `->reset($name)` | `open($name, ?int $forSeconds)`, `close($name)`. | +| `Monitor::breaker()->getState($name)` | `state($name)` returning a `BreakerState` with `state`, `failures`, `openedAt`, `openFor`. | + +New in 1.0 with no 0.1 equivalent: `->profile()`, `->within()`, `->attempts()`, `->ensure()`, `->policy()`, `->domain()`, `->from()`, 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 and the MCP server. + +## 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`. | + +## 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`; reads `traceparent` first, validates what it gets. | +| `Kirschbaum\Monitor\Http\Middleware\CheckCircuitBreakers` aliased `circuit:a,b` | `Kirschbaum\Monitor\Http\Middleware\CheckBreakers`; alias it as you like, e.g. `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`. | +| `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`. | + +## 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`. diff --git a/src/MonitorServiceProvider.php b/src/MonitorServiceProvider.php index e29e123..88f87ae 100644 --- a/src/MonitorServiceProvider.php +++ b/src/MonitorServiceProvider.php @@ -6,6 +6,7 @@ use Illuminate\Contracts\Container\Container; use Illuminate\Queue\Events\JobProcessing; +use Illuminate\Routing\Router; use Illuminate\Support\ServiceProvider; use Kirschbaum\Monitor\Breaker\CircuitBreaker; use Kirschbaum\Monitor\Console\ExplainCommand; @@ -13,6 +14,8 @@ use Kirschbaum\Monitor\Console\OutcomesCommand; use Kirschbaum\Monitor\Console\PointsCommand; use Kirschbaum\Monitor\Console\PruneCommand; +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; @@ -82,6 +85,10 @@ public function boot(): void self::registerMcpServer($this->app); + $router = $this->app->make(Router::class); + $router->aliasMiddleware('monitor.trace', StartTrace::class); + $router->aliasMiddleware('monitor.breakers', CheckBreakers::class); + if ($this->app->runningInConsole() && $this->app->make('config')->get('monitor.trace.console', true)) { $this->app->make(Trace::class)->pickup(); } diff --git a/tests/Feature/TracePropagationTest.php b/tests/Feature/TracePropagationTest.php index 729594c..e8d5571 100644 --- a/tests/Feature/TracePropagationTest.php +++ b/tests/Feature/TracePropagationTest.php @@ -8,7 +8,6 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; use Kirschbaum\Monitor\Facades\Monitor; -use Kirschbaum\Monitor\Http\Middleware\CheckBreakers; use Kirschbaum\Monitor\Http\Middleware\StartTrace; use Kirschbaum\Monitor\Trace\PicksUpJobTrace; use Kirschbaum\Monitor\Trace\Trace; @@ -18,7 +17,8 @@ resolve(Trace::class)->clear(); Route::middleware(StartTrace::class)->get('/traced', fn (): array => ['trace' => Monitor::trace()->current()]); - Route::middleware(CheckBreakers::class.':stripe,efiling')->get('/guarded', fn (): string => 'through'); + 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 { @@ -63,6 +63,10 @@ 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'); From 754ee9ecc3abe30705376e5ca1d982afed23070c Mon Sep 17 00:00:00 2001 From: Belisar Hoxholli Date: Mon, 14 Sep 2026 21:40:26 +0200 Subject: [PATCH 06/13] Print the coverage table in CI [no-verify] --- .github/workflows/php-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/php-tests.yml b/.github/workflows/php-tests.yml index 5a58f1c..5ca9338 100644 --- a/.github/workflows/php-tests.yml +++ b/.github/workflows/php-tests.yml @@ -53,7 +53,7 @@ jobs: run: composer show -D - name: Execute tests - run: vendor/bin/pest --ci --compact --memory --coverage --min=100 + run: vendor/bin/pest --ci --memory --coverage --min=100 performance: name: performance guards From f2549046904fe01d3279a5dc1336cfbdcabab341 Mon Sep 17 00:00:00 2001 From: Belisar Hoxholli Date: Mon, 14 Sep 2026 21:48:53 +0200 Subject: [PATCH 07/13] Reach full coverage and drop unused limit names and run accessors --- phpunit.xml.dist | 1 + src/Inventory/AstScanner.php | 6 +- src/Inventory/Discovery.php | 33 +--- src/Limits/Attempts.php | 5 - src/Limits/Ensure.php | 5 - src/Limits/Limit.php | 2 - src/Limits/Within.php | 5 - src/Run.php | 5 - src/RunInfo.php | 17 -- src/Store/OutcomeStore.php | 12 +- src/Support/Lists.php | 34 ++++ src/Testing/Expectations.php | 4 - tests/Feature/ControlPointClassTest.php | 3 +- tests/Feature/CoverageGapsTest.php | 146 ++++++++++++++++++ tests/Feature/EnforcementTest.php | 2 +- tests/Feature/InventoryTest.php | 11 +- tests/Feature/StoreBootTest.php | 28 ++++ tests/Unit/CriticalNamespaceRuleTest.php | 53 +++++++ .../app/ControlPoints/Filings/Misnamed.php | 17 ++ .../app/ControlPoints/Filings/Unnamed.php | 18 +++ .../app/ControlPoints/Payments/ChargeCard.php | 1 + .../ControlPoints/Payments/InlineCharger.php | 6 + 22 files changed, 323 insertions(+), 91 deletions(-) create mode 100644 src/Support/Lists.php create mode 100644 tests/Feature/CoverageGapsTest.php create mode 100644 tests/Feature/StoreBootTest.php create mode 100644 tests/Unit/CriticalNamespaceRuleTest.php create mode 100644 workbench/app/ControlPoints/Filings/Misnamed.php create mode 100644 workbench/app/ControlPoints/Filings/Unnamed.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 528217c..7557ade 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -35,6 +35,7 @@ + diff --git a/src/Inventory/AstScanner.php b/src/Inventory/AstScanner.php index 96cbc74..ecd256e 100644 --- a/src/Inventory/AstScanner.php +++ b/src/Inventory/AstScanner.php @@ -29,15 +29,11 @@ public function scan(string $path): ScannedFile } try { - $ast = (new ParserFactory)->createForNewestSupportedVersion()->parse($code); + $ast = (new ParserFactory)->createForNewestSupportedVersion()->parse($code) ?? []; } catch (Error) { return new ScannedFile($path, [], []); } - if ($ast === null) { - return new ScannedFile($path, [], []); - } - $visitor = new class extends NodeVisitorAbstract { /** @var list */ diff --git a/src/Inventory/Discovery.php b/src/Inventory/Discovery.php index f68cf7d..a815842 100644 --- a/src/Inventory/Discovery.php +++ b/src/Inventory/Discovery.php @@ -9,6 +9,7 @@ use Kirschbaum\Monitor\ControlPoint; use Kirschbaum\Monitor\Inventory\Rules\Rule; use Kirschbaum\Monitor\Support\Domain; +use Kirschbaum\Monitor\Support\Lists; use Symfony\Component\Finder\Finder; use Throwable; @@ -121,8 +122,8 @@ private function describeClass(string $class, string $file): PointDescription file: $file, line: (new \ReflectionClass($class))->getStartLine() ?: null, profile: is_string($described['profile']) ? $described['profile'] : null, - policies: $this->arrays($described['policies'] ?? null), - limits: $this->arrays($described['limits'] ?? 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, @@ -131,34 +132,6 @@ private function describeClass(string $class, string $file): PointDescription ); } - /** - * @return list> - */ - private function arrays(mixed $value): array - { - $out = []; - - if (! is_array($value)) { - return $out; - } - - foreach ($value as $item) { - if (! is_array($item)) { - continue; - } - - $row = []; - - foreach ($item as $key => $itemValue) { - $row[(string) $key] = $itemValue; - } - - $out[] = $row; - } - - return $out; - } - /** * @param array{name: string|null, line: int, class: string|null} $inline */ diff --git a/src/Limits/Attempts.php b/src/Limits/Attempts.php index 88508b3..07c1f47 100644 --- a/src/Limits/Attempts.php +++ b/src/Limits/Attempts.php @@ -11,11 +11,6 @@ { public function __construct(public int $max) {} - public function name(): string - { - return 'attempts'; - } - public function describe(): array { return ['type' => 'attempts', 'max' => $this->max]; diff --git a/src/Limits/Ensure.php b/src/Limits/Ensure.php index 146ff21..0104627 100644 --- a/src/Limits/Ensure.php +++ b/src/Limits/Ensure.php @@ -18,11 +18,6 @@ */ public function __construct(public Closure $check, public string $reason) {} - public function name(): string - { - return 'ensure'; - } - public function holdsFor(mixed $value): bool { return ($this->check)($value) === true; diff --git a/src/Limits/Limit.php b/src/Limits/Limit.php index 4110fc3..4db52cd 100644 --- a/src/Limits/Limit.php +++ b/src/Limits/Limit.php @@ -10,8 +10,6 @@ */ interface Limit { - public function name(): string; - /** * @return array */ diff --git a/src/Limits/Within.php b/src/Limits/Within.php index 8fccc30..53f94e1 100644 --- a/src/Limits/Within.php +++ b/src/Limits/Within.php @@ -14,11 +14,6 @@ { public function __construct(public float $seconds) {} - public function name(): string - { - return 'duration'; - } - public function thresholdMs(): float { return $this->seconds * 1000; diff --git a/src/Run.php b/src/Run.php index 4be7d00..6919807 100644 --- a/src/Run.php +++ b/src/Run.php @@ -74,11 +74,6 @@ public function execute(): Outcome return $outcome; } - public function info(): RunInfo - { - return $this->info; - } - public function attempt(): int { return $this->attempt; diff --git a/src/RunInfo.php b/src/RunInfo.php index 210cbe1..aa458a8 100644 --- a/src/RunInfo.php +++ b/src/RunInfo.php @@ -26,21 +26,4 @@ public function __construct( public array $context, ) {} - /** - * @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, - 'stack' => $this->stack, - 'context' => $this->context, - ]; - } } diff --git a/src/Store/OutcomeStore.php b/src/Store/OutcomeStore.php index 21e917f..6d57a43 100644 --- a/src/Store/OutcomeStore.php +++ b/src/Store/OutcomeStore.php @@ -137,14 +137,12 @@ public function lastSeen(): array $seen = []; foreach ($rows as $row) { - if (! is_string($row->point) || ! is_string($row->last_at)) { - continue; - } + $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(); - $last = $this->query()->where('point', $row->point)->where('ended_at', $row->last_at)->orderByDesc('id')->first(); - - $seen[$row->point] = [ - 'ended_at' => $row->last_at, + $seen[$point] = [ + 'ended_at' => $lastAt, 'status' => is_object($last) && is_string($last->status) ? $last->status : 'unknown', ]; } diff --git a/src/Support/Lists.php b/src/Support/Lists.php new file mode 100644 index 0000000..ccd61fd --- /dev/null +++ b/src/Support/Lists.php @@ -0,0 +1,34 @@ +> + */ + 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/Testing/Expectations.php b/src/Testing/Expectations.php index bf5b959..83b06e4 100644 --- a/src/Testing/Expectations.php +++ b/src/Testing/Expectations.php @@ -35,10 +35,6 @@ final class Expectations public static function register(): void { - if (! function_exists('expect')) { - return; - } - $mixin = new self; expect()->extend('toBeControlled', $mixin->toBeControlled()); diff --git a/tests/Feature/ControlPointClassTest.php b/tests/Feature/ControlPointClassTest.php index d1a8d73..086d2da 100644 --- a/tests/Feature/ControlPointClassTest.php +++ b/tests/Feature/ControlPointClassTest.php @@ -87,7 +87,8 @@ final class NoHandle extends ControlPoint {} 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['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 { diff --git a/tests/Feature/CoverageGapsTest.php b/tests/Feature/CoverageGapsTest.php new file mode 100644 index 0000000..6cee3ab --- /dev/null +++ b/tests/Feature/CoverageGapsTest.php @@ -0,0 +1,146 @@ +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']); + }); +}); diff --git a/tests/Feature/EnforcementTest.php b/tests/Feature/EnforcementTest.php index 2989f15..e389546 100644 --- a/tests/Feature/EnforcementTest.php +++ b/tests/Feature/EnforcementTest.php @@ -66,7 +66,7 @@ } } - expect($flagged)->toHaveCount(1)->toHaveKey('Uncontrolled.php') + 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/InventoryTest.php b/tests/Feature/InventoryTest.php index 3353601..d176472 100644 --- a/tests/Feature/InventoryTest.php +++ b/tests/Feature/InventoryTest.php @@ -14,6 +14,7 @@ use Kirschbaum\Monitor\Store\OutcomeStore; use Kirschbaum\Monitor\Store\StoreOutcomes; use Workbench\Monitor\ControlPoints\Escalations\PagePayments; +use Workbench\Monitor\ControlPoints\Filings\Unnamed; use Workbench\Monitor\ControlPoints\Payments\ChargeCard; use Workbench\Monitor\ControlPoints\Payments\InlineCharger; use Workbench\Monitor\Support\CardDeclined; @@ -35,7 +36,7 @@ function workbenchPath(): string $names = array_map(fn (PointDescription $p): string => $p->name, $inventory->points); sort($names); - expect($names)->toBe(['(dynamic)', 'BadName', 'filing.submit', 'payment.capture', 'payment.charge', 'payment.charge', 'payment.inline', 'payment.refund']); + 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'); @@ -47,6 +48,7 @@ function workbenchPath(): string ->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() @@ -95,9 +97,10 @@ function workbenchPath(): string $scanned = resolve(AstScanner::class)->scan(workbenchPath().'/ControlPoints/Payments/InlineCharger.php'); expect($scanned->classes)->toBe([InlineCharger::class]) - ->and($scanned->inlinePoints)->toHaveCount(2) + ->and($scanned->inlinePoints)->toHaveCount(3) ->and($scanned->inlinePoints[0]['name'])->toBe('payment.inline') - ->and($scanned->inlinePoints[1]['name'])->toBeNull(); + ->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, 'render($inventory), true); $sarif = json_decode((new SarifReport)->render($inventory, workbenchPath()), true); - expect($json['points'])->toHaveCount(8)->and($json['findings'])->not->toBeEmpty() + 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() diff --git a/tests/Feature/StoreBootTest.php b/tests/Feature/StoreBootTest.php new file mode 100644 index 0000000..038561b --- /dev/null +++ b/tests/Feature/StoreBootTest.php @@ -0,0 +1,28 @@ +set('monitor.records.store.enabled', true); + } + + public function test_the_migration_path_is_registered_when_the_store_is_enabled(): void + { + $paths = $this->app->make(Migrator::class)->paths(); + + $this->assertNotEmpty(array_filter($paths, fn (string $p): bool => str_ends_with($p, 'database/migrations'))); + } +} diff --git a/tests/Unit/CriticalNamespaceRuleTest.php b/tests/Unit/CriticalNamespaceRuleTest.php new file mode 100644 index 0000000..e9fd32f --- /dev/null +++ b/tests/Unit/CriticalNamespaceRuleTest.php @@ -0,0 +1,53 @@ + + */ +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', + ], [ + [ + '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/workbench/app/ControlPoints/Filings/Misnamed.php b/workbench/app/ControlPoints/Filings/Misnamed.php new file mode 100644 index 0000000..871ae1b --- /dev/null +++ b/workbench/app/ControlPoints/Filings/Misnamed.php @@ -0,0 +1,17 @@ +breaker('stripe') + ->attempts(3) ->ensure(fn (array $r): bool => $r['settled'] === true, 'charge must be settled') ->recover(CardDeclined::class, fn (CardDeclined $e): array => ['declined' => $e->declineCode]) ->escalate(PagePayments::class); diff --git a/workbench/app/ControlPoints/Payments/InlineCharger.php b/workbench/app/ControlPoints/Payments/InlineCharger.php index 2be0231..d326906 100644 --- a/workbench/app/ControlPoints/Payments/InlineCharger.php +++ b/workbench/app/ControlPoints/Payments/InlineCharger.php @@ -4,6 +4,7 @@ namespace Workbench\Monitor\ControlPoints\Payments; +use Kirschbaum\Monitor\Control; use Kirschbaum\Monitor\Facades\Monitor; /** @@ -16,6 +17,11 @@ public function charge(): string return Monitor::control('payment.inline', $this)->run(fn (): string => 'ok'); } + public function direct(): string + { + return (new Control('payment.direct', $this))->run(fn (): string => 'ok'); + } + public function dynamic(string $name): string { return Monitor::control($name, $this)->run(fn (): string => 'ok'); From 33de631a2034066098d695217503a95c32bf9fec Mon Sep 17 00:00:00 2001 From: Belisar Hoxholli Date: Mon, 14 Sep 2026 21:51:23 +0200 Subject: [PATCH 08/13] Cover the out-of-namespace branch of the PHPStan rule --- tests/Unit/CriticalNamespaceRuleTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Unit/CriticalNamespaceRuleTest.php b/tests/Unit/CriticalNamespaceRuleTest.php index e9fd32f..1a125e1 100644 --- a/tests/Unit/CriticalNamespaceRuleTest.php +++ b/tests/Unit/CriticalNamespaceRuleTest.php @@ -30,6 +30,7 @@ public function test_it_flags_only_classes_that_are_neither_control_points_nor_c $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.', From f87f059d384464c0ed960c25e61e4867849173db Mon Sep 17 00:00:00 2001 From: Belisar Hoxholli Date: Mon, 14 Sep 2026 23:51:11 +0200 Subject: [PATCH 09/13] Align organisation, API, lifecycle and docs with first-party packages --- .ai/guidelines/core.blade.php | 5 +- CHANGELOG.md | 44 ++- README.md | 20 +- config/monitor.php | 16 +- ..._000000_create_monitor_outcomes_table.php} | 0 docs/README.md | 6 +- docs/agents.md | 19 +- docs/breakers.md | 24 +- docs/configuration.md | 12 +- docs/control-points.md | 17 +- docs/extending.md | 59 +++- docs/getting-started.md | 2 +- docs/inventory.md | 2 +- docs/policies-and-limits.md | 6 +- docs/records.md | 4 +- docs/risks-and-corrections.md | 6 +- docs/store.md | 25 +- docs/testing.md | 79 +++-- docs/tracing.md | 4 +- docs/upgrading.md | 72 ++++- ide.json | 23 ++ rector.php | 4 + .../boost/skills/monitor-development/SKILL.md | 81 +++++ .../PhpStan/CriticalNamespaceRule.php | 8 +- src/Attributes/Point.php | 9 +- src/Breaker/BreakerState.php | 15 +- src/Breaker/CircuitBreaker.php | 2 +- src/Console/{ => Commands}/ExplainCommand.php | 4 +- .../MakeControlPointCommand.php | 8 +- .../{ => Commands}/OutcomesCommand.php | 11 +- src/Console/{ => Commands}/PointsCommand.php | 4 +- src/Console/{ => Commands}/PruneCommand.php | 4 +- src/{Escalations => Contracts}/Escalation.php | 2 +- src/{Limits => Contracts}/Limit.php | 2 +- src/{Policies => Contracts}/Policy.php | 2 +- src/{Inventory/Rules => Contracts}/Rule.php | 2 +- src/{ => Contracts}/Runner.php | 4 +- src/Control.php | 25 +- src/ControlPoint.php | 6 +- src/ControlStack.php | 2 +- src/Events/BreakerClosed.php | 2 +- src/Events/BreakerHalfOpen.php | 2 +- src/Events/BreakerOpened.php | 2 +- src/Events/EscalationFailed.php | 2 +- src/Events/PointEnded.php | 2 +- src/Events/PointEscalated.php | 2 +- src/Events/PointLimitBreached.php | 2 +- src/Events/PointRecovered.php | 2 +- src/Events/PointRefused.php | 2 +- src/Events/PointRetried.php | 2 +- src/Events/PointStarted.php | 2 +- src/Exceptions/InvalidControlPoint.php | 4 +- src/Exceptions/InvalidPointName.php | 4 +- src/Exceptions/InvalidProfile.php | 4 +- src/Exceptions/InvalidTraceId.php | 4 +- src/Exceptions/MonitorException.php | 7 +- src/Facades/Monitor.php | 56 ++-- src/Http/Middleware/CheckBreakers.php | 4 +- src/Http/Middleware/StartTrace.php | 4 +- src/Inventory/AstScanner.php | 2 +- src/Inventory/Discovery.php | 6 +- src/Inventory/Explainer.php | 2 +- src/Inventory/Finding.php | 16 +- src/Inventory/Inventory.php | 2 +- src/Inventory/PointDescription.php | 15 +- src/Inventory/Reports/JsonReport.php | 2 +- src/Inventory/Reports/SarifReport.php | 2 +- src/Inventory/Reports/TableReport.php | 2 +- .../Rules/CatchAllWithoutEscalation.php | 3 +- .../Rules/CriticalNamespaceUncontrolled.php | 7 +- src/Inventory/Rules/DuplicateNames.php | 3 +- src/Inventory/Rules/DynamicName.php | 3 +- src/Inventory/Rules/MissingEscalation.php | 3 +- src/Inventory/Rules/NamePattern.php | 3 +- src/Inventory/Rules/Rules.php | 3 +- src/Inventory/Rules/UnreadableControl.php | 3 +- src/Inventory/ScannedFile.php | 3 + src/Limits/Attempts.php | 2 + src/Limits/Ensure.php | 1 + src/Limits/Within.php | 2 + src/LiveRunner.php | 11 +- src/Logging/JsonTap.php | 2 +- src/Logging/RecordFormatter.php | 2 +- src/Logging/StructuredLogger.php | 4 +- src/Mcp/MonitorServer.php | 2 +- src/Mcp/Prompts/WrapOperation.php | 2 +- src/Mcp/Resources/Guidelines.php | 2 +- src/Mcp/Resources/RecordSchema.php | 2 +- src/Mcp/ResponseRedactor.php | 4 +- src/Mcp/Tools/Escalations.php | 11 +- src/Mcp/Tools/ExplainPoint.php | 15 +- src/Mcp/Tools/ListPoints.php | 7 +- src/Mcp/Tools/RecentOutcomes.php | 11 +- src/Monitor.php | 3 +- src/MonitorServiceProvider.php | 173 ++++++++--- src/Outcome.php | 14 +- src/Policies/Breaker.php | 11 +- src/Policies/Retry.php | 17 +- src/Policies/Transaction.php | 19 +- src/Records/ExceptionSummary.php | 4 +- src/Records/Record.php | 4 +- src/Records/Recorder.php | 2 +- src/Risks/BreakerOpen.php | 2 +- src/Risks/Risk.php | 3 +- src/Run.php | 6 +- src/Store/StoreOutcomes.php | 47 +-- src/Support/ChildEscalations.php | 4 +- src/Support/Domain.php | 4 +- src/Support/Lists.php | 5 +- src/Support/PointName.php | 15 +- src/Support/Profiles.php | 8 +- src/Support/Redaction.php | 20 +- src/Testing/Expectations.php | 5 +- src/Testing/MonitorFake.php | 277 +++++++++++++----- src/Trace/PicksUpCommandTrace.php | 26 ++ src/Trace/PicksUpJobTrace.php | 4 +- src/Trace/PropagatesTrace.php | 12 +- src/Trace/Trace.php | 4 +- src/Trace/TraceParent.php | 2 +- tests/Feature/BreakerTest.php | 2 +- .../{CoverageGapsTest.php => EdgesTest.php} | 31 +- tests/Feature/FakeTest.php | 60 +++- tests/Feature/InventoryTest.php | 4 +- tests/Feature/McpRegistrationTest.php | 27 ++ tests/Feature/McpTest.php | 15 +- tests/Feature/ProviderTest.php | 32 ++ tests/Feature/StoreBootTest.php | 14 +- tests/Feature/StoreTest.php | 45 ++- tests/Feature/TracePropagationTest.php | 23 +- tests/Fixtures/BrokenEscalation.php | 2 +- tests/Fixtures/PagePayments.php | 2 +- tests/Fixtures/PointNames.php | 11 + .../Escalations/PagePayments.php | 2 +- 133 files changed, 1415 insertions(+), 455 deletions(-) rename database/migrations/{create_monitor_outcomes_table.php => 2026_09_14_000000_create_monitor_outcomes_table.php} (100%) create mode 100644 ide.json create mode 100644 resources/boost/skills/monitor-development/SKILL.md rename src/Console/{ => Commands}/ExplainCommand.php (93%) rename src/Console/{ => Commands}/MakeControlPointCommand.php (93%) rename src/Console/{ => Commands}/OutcomesCommand.php (92%) rename src/Console/{ => Commands}/PointsCommand.php (95%) rename src/Console/{ => Commands}/PruneCommand.php (90%) rename src/{Escalations => Contracts}/Escalation.php (91%) rename src/{Limits => Contracts}/Limit.php (87%) rename src/{Policies => Contracts}/Policy.php (95%) rename src/{Inventory/Rules => Contracts}/Rule.php (85%) rename src/{ => Contracts}/Runner.php (73%) create mode 100644 src/Trace/PicksUpCommandTrace.php rename tests/Feature/{CoverageGapsTest.php => EdgesTest.php} (81%) create mode 100644 tests/Feature/McpRegistrationTest.php create mode 100644 tests/Feature/ProviderTest.php create mode 100644 tests/Fixtures/PointNames.php diff --git a/.ai/guidelines/core.blade.php b/.ai/guidelines/core.blade.php index 901290a..cb902cd 100644 --- a/.ai/guidelines/core.blade.php +++ b/.ai/guidelines/core.blade.php @@ -33,6 +33,8 @@ public function handle(StripeClient $stripe): ChargeResult { return $stripe->cha 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. + Rules that `php artisan monitor:points --check` enforces: - Names are dotted lowercase: `domain.operation`, unique across the app. @@ -53,12 +55,13 @@ public function handle(StripeClient $stripe): ChargeResult { return $stripe->cha ```php Monitor::fake(); // records every outcome, still runs the code Monitor::fake()->failing('payment.charge', new CardDeclined('do_not_honor')); -Monitor::fake(['payment.charge' => ChargeResult::declined()]); +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(); ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e63933..30b16a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,12 @@ lists every 0.1 surface and its replacement. 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 @@ -111,12 +117,48 @@ lists every 0.1 surface and its replacement. - **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. + 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. ### 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 diff --git a/README.md b/README.md index a4c6cbb..02a3e9e 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@ ![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) -Every application has a short list of operations where failure is expensive: charging a card, filing with a court system, posting a ledger entry, sending the one email a client is waiting for. What happens when those fail is usually decided by whoever touched the code last, in a try/catch nobody reviews as carefully as the happy path. Nobody can list those operations, each one behaves differently, and the recovery paths are the least tested code in the codebase. +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. -Monitor gives each of them a **control point**: a named declaration of what the operation tolerates, what bounds it, how every outcome is recorded, and who is told when it fails. Because it is a declaration, it can be listed, tested by name, enforced in CI, queried from a log backend, and taught to an agent by the shape of the API alone. +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 -// The ritual, written by hand, slightly differently in every service. +// 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); @@ -20,15 +20,13 @@ try { } catch (CardDeclined $e) { DB::rollBack(); Log::warning('card declined: '.$e->getMessage()); - return null; // the caller has to know null means declined + return null; } catch (\Throwable $e) { DB::rollBack(); - Log::error($e); // no operation name, no attempt, no trace + Log::error($e); throw $e; } -``` -```php // The same operation as a control point. return Monitor::control('payment.charge', $this) ->with(['invoice' => $invoice->id, 'amount' => $amount]) @@ -40,7 +38,7 @@ return Monitor::control('payment.charge', $this) ->run(fn () => $this->stripe->charge($amount)); ``` -Read that once and you know the operation's name, its domain, what it tolerates, what it tries again, when it stops calling Stripe, what counts as success, and who gets paged. The same declaration produces one log record per transition with `point`, `domain`, `status`, `run_id` and `trace_id` as fields. +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. ## Quick Start @@ -128,7 +126,7 @@ An **inventory** reads the codebase without running it: `monitor:points` lists e - **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 guidelines 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. +- **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. ## Documentation @@ -145,8 +143,8 @@ The full documentation lives in [`docs/`](docs/README.md): | [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. | -| [Agents](docs/agents.md) | The Boost guidelines, the MCP server, its tools, resources and prompt. | +| [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. | diff --git a/config/monitor.php b/config/monitor.php index 80baaaf..46226d5 100644 --- a/config/monitor.php +++ b/config/monitor.php @@ -184,6 +184,18 @@ 'redaction' => env('MONITOR_REDACTION_PROFILE', 'observability'), 'exception_trace' => env('MONITOR_EXCEPTION_TRACE', 'never'), // never | debug | always 'exception_trace_lines' => 15, + + /* + |---------------------------------------------------------------------- + | Record Levels + |---------------------------------------------------------------------- + | + | 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. + | + */ + 'levels' => [ 'point.started' => 'debug', 'point.retried' => 'notice', @@ -198,7 +210,7 @@ 'breaker.closed' => 'info', ], 'store' => [ - 'enabled' => env('MONITOR_STORE', false), + 'enabled' => env('MONITOR_STORE_ENABLED', false), 'connection' => env('MONITOR_STORE_CONNECTION'), 'table' => 'monitor_outcomes', 'retention_days' => 30, @@ -217,7 +229,7 @@ */ 'mcp' => [ - 'enabled' => env('MONITOR_MCP', false), + 'enabled' => env('MONITOR_MCP_ENABLED', false), 'handle' => 'monitor', ], ]; diff --git a/database/migrations/create_monitor_outcomes_table.php b/database/migrations/2026_09_14_000000_create_monitor_outcomes_table.php similarity index 100% rename from database/migrations/create_monitor_outcomes_table.php rename to database/migrations/2026_09_14_000000_create_monitor_outcomes_table.php diff --git a/docs/README.md b/docs/README.md index 6e8d2f5..9f80030 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,9 +27,9 @@ Everything else can be read as you need it. | [Tracing](tracing.md) | The trace ID, `traceparent` and the legacy header, the middleware, outgoing propagation with `Http::traced()`, queued jobs and console. | | [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, and `monitor:explain`. | -| [Testing](testing.md) | `Monitor::fake()` and its assertions, the Pest expectations, the PHPStan rule, and the package's own conventions. | -| [Agents](agents.md) | The guidelines shipped for Laravel Boost, the MCP server's tools, resources and prompt, and `make:control-point`. | +| [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. | diff --git a/docs/agents.md b/docs/agents.md index 70a4983..6f38cfa 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -16,7 +16,9 @@ An agent that adds a critical operation should not need the conventions spelled ## The Guidelines -The package ships `.ai/guidelines/core.blade.php`. [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 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: @@ -42,17 +44,26 @@ composer require laravel/mcp ```php 'mcp' => [ - 'enabled' => env('MONITOR_MCP', false), + 'enabled' => env('MONITOR_MCP_ENABLED', false), 'handle' => 'monitor', ], ``` -With `MONITOR_MCP=true`, the service provider registers the server as a local server under the handle, and it starts over stdio with: +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 @@ -97,4 +108,4 @@ A local stdio server, for Claude Code: } ``` -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=true` must be set in the environment the command runs in. +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 index 3a89143..e772990 100644 --- a/docs/breakers.md +++ b/docs/breakers.md @@ -5,6 +5,7 @@ - [Storage](#storage) - [On a Control Point](#on-a-control-point) - [The Standalone API](#the-standalone-api) +- [BreakerState](#breakerstate) - [The Route Middleware](#the-route-middleware) - [Events](#events) @@ -85,7 +86,7 @@ $breaker->isOpen('stripe'); // true while refusing; false when closed or w $breaker->isClosed('stripe'); // the opposite $breaker->retryAfter('stripe'); // seconds until the open period ends, 0 when not open -$breaker->attempt('stripe'); // a Decision: ->allowed, ->probe, ->retryAfterSeconds +$breaker->permit('stripe'); // a Decision: ->allowed, ->probe, ->retryAfterSeconds $breaker->recordFailure('stripe'); $breaker->recordSuccess('stripe'); @@ -93,9 +94,9 @@ $breaker->open('stripe', 300); // open by hand for 300 seconds, e.g. from a h $breaker->close('stripe'); // close by hand and clear the failures ``` -`attempt()` 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()` 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. -`attempt()` and `recordFailure()` accept a `Kirschbaum\Monitor\Breaker\BreakerConfig` as a second argument to use numbers other than the defaults: +`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; @@ -106,7 +107,7 @@ $breaker->recordFailure('efiling', new BreakerConfig(after: 2, within: 30, for: A hand-rolled guard around code that is not a control point: ```php -$decision = Monitor::breaker()->attempt('search'); +$decision = Monitor::breaker()->permit('search'); if (! $decision->allowed) { return $this->cachedResults(); @@ -124,6 +125,21 @@ try { } ``` +## 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: diff --git a/docs/configuration.md b/docs/configuration.md index 4eda915..7c7298c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -82,7 +82,7 @@ The shipped values are listed in [Policies and Limits](policies-and-limits.md#th | --- | --- | --- | --- | --- | | `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 the application boots in the console, so commands and workers have one. | +| `console` | `bool` | `true` | `MONITOR_TRACE_CONSOLE` | Start a trace when an artisan command starts (on `CommandStarting`), so commands and workers have one. | ## breakers @@ -133,7 +133,7 @@ Event names contain dots, so set the whole array rather than one nested key. | Key | Type | Default | Env | Meaning | | --- | --- | --- | --- | --- | -| `enabled` | `bool` | `false` | `MONITOR_STORE` | Keep outcomes in a table. When enabled the package loads its migration. | +| `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. | @@ -142,7 +142,7 @@ Event names contain dots, so set the whole array rather than one nested key. | Key | Type | Default | Env | Meaning | | --- | --- | --- | --- | --- | -| `enabled` | `bool` | `false` | `MONITOR_MCP` | Register the read-only MCP server. Requires `laravel/mcp`. | +| `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 @@ -155,6 +155,8 @@ Event names contain dots, so set the whole array rather than one nested key. | `MONITOR_LOG_CHANNEL` | `records.channel` | | `MONITOR_REDACTION_PROFILE` | `records.redaction` | | `MONITOR_EXCEPTION_TRACE` | `records.exception_trace` | -| `MONITOR_STORE` | `records.store.enabled` | +| `MONITOR_STORE_ENABLED` | `records.store.enabled` | | `MONITOR_STORE_CONNECTION` | `records.store.connection` | -| `MONITOR_MCP` | `mcp.enabled` | +| `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 index 457369d..970dfad 100644 --- a/docs/control-points.md +++ b/docs/control-points.md @@ -26,7 +26,7 @@ payment.charge court.e_filing.submit_v2 ``` -`Monitor::control('Payment')` throws `InvalidPointName` 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. +`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 @@ -72,7 +72,20 @@ $control = Monitor::control('payment.charge', $this); | `attempt(Closure $callback)` | Execute; return the `Outcome`. | | `describe()` | The static description the inventory uses: name, origin, domain, profile, policies, limits, risk classes, whether there is a catch-all, and the escalation as a class name, `'closure'` or `null`. | -`name()`, `origin()`, `resolvedDomain()`, `context()` and `profileName()` read back what was declared. `resolvedPolicies()`, `resolvedWithin()` and `resolvedAttempts()` return the effective policies and limits after the profile is merged. +`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 diff --git a/docs/extending.md b/docs/extending.md index 8c53b13..858c8b8 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -6,6 +6,7 @@ - [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 @@ -13,7 +14,7 @@ Monitor's surface is small on purpose, and each piece of it is a contract you ca ## A Custom Policy -A policy is a class implementing `Kirschbaum\Monitor\Policies\Policy`: +A policy is a class implementing `Kirschbaum\Monitor\Contracts\Policy`: ```php interface Policy @@ -34,7 +35,7 @@ interface Policy `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, `note($event, $detail)` to add an entry to the outcome's timeline, and `info()` for the `RunInfo`. +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: @@ -42,10 +43,10 @@ A policy that logs how long the attempt took and adds it to the timeline: namespace App\Monitor; use Closure; -use Kirschbaum\Monitor\Policies\Policy; +use Kirschbaum\Monitor\Contracts\Policy; use Kirschbaum\Monitor\Run; -final class Timed implements Policy +class Timed implements Policy { public function around(Run $run, Closure $next): mixed { @@ -82,15 +83,15 @@ A policy that retries must consult `Kirschbaum\Monitor\Support\ChildEscalations: ## A Custom Escalation -An escalation is a class implementing `Kirschbaum\Monitor\Escalations\Escalation`: +An escalation is a class implementing `Kirschbaum\Monitor\Contracts\Escalation`: ```php namespace App\Escalations; -use Kirschbaum\Monitor\Escalations\Escalation; +use Kirschbaum\Monitor\Contracts\Escalation; use Kirschbaum\Monitor\Outcome; -final class PagePayments implements Escalation +class PagePayments implements Escalation { public function __construct(private readonly Pager $pager) {} @@ -119,7 +120,7 @@ namespace App\Listeners; use Kirschbaum\Monitor\Events\PointEnded; -final class CountOutcomes +class CountOutcomes { public function handle(PointEnded $event): void { @@ -150,7 +151,7 @@ Listeners run synchronously inside the point's run, so keep them fast or queue t ## A Custom Inventory Rule -A rule implements `Kirschbaum\Monitor\Inventory\Rules\Rule`: +A rule implements `Kirschbaum\Monitor\Contracts\Rule`: ```php interface Rule @@ -170,10 +171,10 @@ A rule that requires every external point to declare a breaker: namespace App\Monitor\Rules; use Kirschbaum\Monitor\Inventory\Finding; +use Kirschbaum\Monitor\Contracts\Rule; use Kirschbaum\Monitor\Inventory\Inventory; -use Kirschbaum\Monitor\Inventory\Rules\Rule; -final class ExternalPointsHaveBreakers implements Rule +class ExternalPointsHaveBreakers implements Rule { public function name(): string { @@ -236,3 +237,39 @@ A domain is derived from a class name through `domains.map`, tried in order. A n ``` 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 index 29bd214..d13ec3f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -97,7 +97,7 @@ Both produce the same `Outcome`, the same events and the same records. | `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, and `toArray()` for a scalar view with the exception summarised. +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 diff --git a/docs/inventory.md b/docs/inventory.md index c213e16..38a3da0 100644 --- a/docs/inventory.md +++ b/docs/inventory.md @@ -14,7 +14,7 @@ ## Introduction -The inventory is the list of every control point in the application, built by reading the code rather than running it. It answers the question no Laravel application could answer before: 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. +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 diff --git a/docs/policies-and-limits.md b/docs/policies-and-limits.md index 3b6a132..ee49b70 100644 --- a/docs/policies-and-limits.md +++ b/docs/policies-and-limits.md @@ -37,7 +37,7 @@ Each retry policy counts its own tries. The `attempts()` limit caps the total fo 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`: `Retry::times(2)->backoff(200, 2.0, true)->on([...])->except([...])`. +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 @@ -47,7 +47,7 @@ The policy object is `Kirschbaum\Monitor\Policies\Retry`: `Retry::times(2)->back 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`: `Transaction::retries(2)->on([...])->except([...])->connection('tenant')->backoff(50)`; `backoff()` is a flat delay with jitter between attempts. +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. @@ -75,7 +75,7 @@ Policies wrap the attempt outermost first, ordered by `Policy::order()` ascendin ### policy() -`policy(Policy $policy)` adds any object implementing `Kirschbaum\Monitor\Policies\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). +`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 diff --git a/docs/records.md b/docs/records.md index a1f6c65..5a49e57 100644 --- a/docs/records.md +++ b/docs/records.md @@ -208,7 +208,7 @@ $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. +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 @@ -228,7 +228,7 @@ Every record is written by a listener, and the events are public. Alerting, metr | `Kirschbaum\Monitor\Events\BreakerHalfOpen` | `breaker`, `state`. | | `Kirschbaum\Monitor\Events\BreakerClosed` | `breaker`, `state`. | -`Outcome` is described in [Control Points](control-points.md). A listener that pages on escalation: +`Outcome` is described in [Getting Started](getting-started.md#the-outcome). `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; diff --git a/docs/risks-and-corrections.md b/docs/risks-and-corrections.md index 5f694cf..51c9cc3 100644 --- a/docs/risks-and-corrections.md +++ b/docs/risks-and-corrections.md @@ -109,13 +109,13 @@ The inventory's `missing_escalation` rule reports a class-form point with no esc ### Escalation Classes -A class implementing `Kirschbaum\Monitor\Escalations\Escalation` is resolved from the container, so it can take constructor dependencies: +A class implementing `Kirschbaum\Monitor\Contracts\Escalation` is resolved from the container, so it can take constructor dependencies: ```php -use Kirschbaum\Monitor\Escalations\Escalation; +use Kirschbaum\Monitor\Contracts\Escalation; use Kirschbaum\Monitor\Outcome; -final class PagePayments implements Escalation +class PagePayments implements Escalation { public function __construct(private readonly Pager $pager) {} diff --git a/docs/store.md b/docs/store.md index 9cb58e3..8f55240 100644 --- a/docs/store.md +++ b/docs/store.md @@ -19,7 +19,7 @@ The store is off by default. Records in the log do not depend on it. Set `records.store.enabled`, then create the table: ``` -MONITOR_STORE=true +MONITOR_STORE_ENABLED=true ``` ```bash @@ -27,12 +27,12 @@ php artisan vendor:publish --tag=monitor-migrations php artisan migrate ``` -Publishing copies the migration into `database/migrations` with a timestamp. Publishing is optional: while the store is enabled, the service provider also loads the migration straight from the package, so `php artisan migrate` alone creates the table. Publish it when you want to change it. +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', false), + 'enabled' => env('MONITOR_STORE_ENABLED', false), 'connection' => env('MONITOR_STORE_CONNECTION'), 'table' => 'monitor_outcomes', 'retention_days' => 30, @@ -73,10 +73,13 @@ Context and exception messages go through the same Redactor profile as records, 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: -- when the application terminates, after the response has been sent, and -- after each queued job, on `JobProcessed` and `JobExceptionOccurred`, so a long-running worker never holds outcomes across jobs. +- 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. +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. @@ -118,7 +121,15 @@ $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. `query()` returns a query builder on the table for anything else. +`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 diff --git a/docs/testing.md b/docs/testing.md index 01f9f95..3f11ca1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,6 +8,7 @@ - [Tips](#tips) - [Pest Expectations](#pest-expectations) - [The PHPStan Rule](#the-phpstan-rule) +- [The Package's Own Tests](#the-packages-own-tests) ## Introduction @@ -23,20 +24,16 @@ $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. -The fake is also the facade root, so assertions are made through `Monitor::` directly. +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()); - -// or several at once -Monitor::fake([ - 'payment.charge' => ChargeResult::settled(), - 'payment.refund' => null, -]); +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: @@ -49,39 +46,47 @@ $result = $this->service->charge($invoice); // goes through recover(CardDeclin 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. +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, in order -Monitor::outcomes('payment.charge'); // for one point +Monitor::outcomes(); // every Outcome +Monitor::outcomes('payment.charge'); // for one point +Monitor::outcomes()->where('status', Status::Escalated)->count(); -$fake->forget(); // discard what was recorded +$fake->forget(); // discard what was recorded ``` ## Assertions -Every assertion returns the fake, so they chain. +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(string $point, ?Closure $callback = null)` | The point ran at least once; with a callback, at least one `Outcome` satisfies it. | -| `assertRanTimes(string $point, int $times)` | The point ran exactly that many times. | -| `assertNeverRan(string $point)` | The point did not run. | -| `assertSucceeded(string $point, ?Closure $callback = null)` | An outcome of the point is `succeeded`, and satisfies the callback if given. | -| `assertRecovered(string $point, ?string $from = null)` | An outcome is `recovered`; with `$from`, recovered from that exception class. | -| `assertEscalated(string $point, ?string $with = null)` | An outcome is `escalated`; with `$with`, the escaped exception is an instance of that class. | -| `assertRefused(string $point)` | An outcome is `refused` by a breaker. | -| `assertRetried(string $point, ?int $times = null)` | An outcome took more than one attempt; with `$times`, exactly `$times + 1` attempts. | -| `assertLimitBreached(string $point, string $limit)` | An outcome breached the named limit, `duration` or `attempts`. | +| `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') - ->assertNeverRan('payment.refund') + ->assertNotRan('payment.refund') ->assertNothingEscalated(); ``` @@ -114,6 +119,8 @@ expect(Monitor::breaker()->isOpen('stripe'))->toBeFalse(); **Records.** `timacdonald/log-fake` captures records: after `LogFake::bind()`, `Log::channel()->logs()` holds every line with its level, message and context. +**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`: @@ -160,7 +167,7 @@ parameters: - App\Services\Payments ``` -A class in one of those namespaces that does not extend `ControlPoint`, does not implement `Escalation` or `Policy`, is not abstract, and contains no `Monitor::control()` or `new Control()` call is reported: +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. @@ -168,3 +175,27 @@ App\Services\Payments\LegacyCharger sits in a critical namespace but is not a co ``` 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 index 33923e1..3fc88ce 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -48,6 +48,8 @@ Monitor::trace()->clear(); // forget the 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 @@ -107,7 +109,7 @@ A job that arrives without a trace, because it was dispatched from a process tha ## Console -In console, the service provider starts a trace when the application boots, so a command's records share one ID. Turn that off with `trace.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' => [ diff --git a/docs/upgrading.md b/docs/upgrading.md index 1100629..15e7c98 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -4,10 +4,14 @@ - [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 @@ -25,10 +29,10 @@ | 0.1 | 1.0 | | --- | --- | -| `Monitor::controlled($name, $origin)` | `Monitor::control($name, $origin)` | -| `Monitor::log($origin)` | Kept; now a PSR-3 logger whose records carry `origin` and `domain` fields. | +| `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 below for the changed methods. | +| `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()`. | @@ -48,12 +52,23 @@ | `->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`, ...). | -| `Monitor::breaker()->isOpen($name, $threshold, $decay)` | `isOpen($name)`; thresholds come from the policy or `breakers`. | -| `Monitor::breaker()->recordFailure($name, $decay)` | `recordFailure($name, ?BreakerConfig $config)`. | -| `Monitor::breaker()->forceOpen($name)`, `->reset($name)` | `open($name, ?int $forSeconds)`, `close($name)`. | -| `Monitor::breaker()->getState($name)` | `state($name)` returning a `BreakerState` with `state`, `failures`, `openedAt`, `openFor`. | -New in 1.0 with no 0.1 equivalent: `->profile()`, `->within()`, `->attempts()`, `->ensure()`, `->policy()`, `->domain()`, `->from()`, 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 and the MCP server. +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 @@ -64,6 +79,7 @@ New in 1.0 with no 0.1 equivalent: `->profile()`, `->within()`, `->attempts()`, | `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 @@ -71,8 +87,8 @@ New in 1.0 with no 0.1 equivalent: `->profile()`, `->within()`, `->attempts()`, | --- | --- | | `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`; reads `traceparent` first, validates what it gets. | -| `Kirschbaum\Monitor\Http\Middleware\CheckCircuitBreakers` aliased `circuit:a,b` | `Kirschbaum\Monitor\Http\Middleware\CheckBreakers`; alias it as you like, e.g. `breakers:a,b`. | +| `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 @@ -82,12 +98,33 @@ New in 1.0 with no 0.1 equivalent: `->profile()`, `->within()`, `->attempts()`, | `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`. | +| `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. @@ -95,3 +132,16 @@ New in 1.0 with no 0.1 equivalent: `->profile()`, `->within()`, `->attempts()`, 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/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/rector.php b/rector.php index c06bca6..eba39d9 100644 --- a/rector.php +++ b/rector.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Rector\Config\RectorConfig; +use Rector\Php82\Rector\Class_\ReadOnlyClassRector; use RectorLaravel\Set\LaravelSetList; return RectorConfig::configure() @@ -18,6 +19,9 @@ 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..cfb82f7 --- /dev/null +++ b/resources/boost/skills/monitor-development/SKILL.md @@ -0,0 +1,81 @@ +--- +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. + +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. 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. + +### 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/src/Analysis/PhpStan/CriticalNamespaceRule.php b/src/Analysis/PhpStan/CriticalNamespaceRule.php index 643ae4d..76a77c4 100644 --- a/src/Analysis/PhpStan/CriticalNamespaceRule.php +++ b/src/Analysis/PhpStan/CriticalNamespaceRule.php @@ -4,12 +4,12 @@ namespace Kirschbaum\Monitor\Analysis\PhpStan; +use Kirschbaum\Monitor\Contracts\Escalation; +use Kirschbaum\Monitor\Contracts\Policy; use Kirschbaum\Monitor\Control; use Kirschbaum\Monitor\ControlPoint; -use Kirschbaum\Monitor\Escalations\Escalation; use Kirschbaum\Monitor\Facades\Monitor as MonitorFacade; use Kirschbaum\Monitor\Monitor; -use Kirschbaum\Monitor\Policies\Policy; use PhpParser\Node; use PhpParser\NodeFinder; use PHPStan\Analyser\Scope; @@ -32,12 +32,12 @@ * * @implements Rule */ -final readonly class CriticalNamespaceRule implements Rule +class CriticalNamespaceRule implements Rule { /** * @param list $namespaces */ - public function __construct(private array $namespaces) {} + public function __construct(private readonly array $namespaces) {} public function getNodeType(): string { diff --git a/src/Attributes/Point.php b/src/Attributes/Point.php index 018d0fd..e363fb9 100644 --- a/src/Attributes/Point.php +++ b/src/Attributes/Point.php @@ -5,6 +5,7 @@ namespace Kirschbaum\Monitor\Attributes; use Attribute; +use BackedEnum; /** * Names a control point class. Everything here is readable without running @@ -13,9 +14,13 @@ #[Attribute(Attribute::TARGET_CLASS)] final readonly class Point { + public string $name; + public function __construct( - public string $name, + string|BackedEnum $name, public ?string $profile = null, public ?string $domain = null, - ) {} + ) { + $this->name = $name instanceof BackedEnum ? (string) $name->value : $name; + } } diff --git a/src/Breaker/BreakerState.php b/src/Breaker/BreakerState.php index f3827c3..e5b05d7 100644 --- a/src/Breaker/BreakerState.php +++ b/src/Breaker/BreakerState.php @@ -4,10 +4,15 @@ namespace Kirschbaum\Monitor\Breaker; +use Illuminate\Contracts\Support\Arrayable; +use JsonSerializable; + /** * What the store knows about one circuit. Immutable; every change is a new state. + * + * @implements Arrayable */ -final readonly class BreakerState +final readonly class BreakerState implements Arrayable, JsonSerializable { /** * @param list $failures unix timestamps of recent failures, oldest first @@ -111,4 +116,12 @@ public static function fromArray(array $data): self 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 index 8a197d4..f943d0f 100644 --- a/src/Breaker/CircuitBreaker.php +++ b/src/Breaker/CircuitBreaker.php @@ -51,7 +51,7 @@ public function isClosed(string $name): bool * Decide whether an attempt may proceed, moving an expired open circuit to * half-open and handing out its single probe. */ - public function attempt(string $name, ?BreakerConfig $config = null): Decision + public function permit(string $name, ?BreakerConfig $config = null): Decision { $config ??= BreakerConfig::defaults(); $state = $this->state($name); diff --git a/src/Console/ExplainCommand.php b/src/Console/Commands/ExplainCommand.php similarity index 93% rename from src/Console/ExplainCommand.php rename to src/Console/Commands/ExplainCommand.php index e84037f..3575393 100644 --- a/src/Console/ExplainCommand.php +++ b/src/Console/Commands/ExplainCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Kirschbaum\Monitor\Console; +namespace Kirschbaum\Monitor\Console\Commands; use Illuminate\Console\Command; use Kirschbaum\Monitor\Inventory\Discovery; @@ -10,7 +10,7 @@ use Kirschbaum\Monitor\Inventory\PointDescription; use Kirschbaum\Monitor\Store\OutcomeStore; -final class ExplainCommand extends Command +class ExplainCommand extends Command { protected $signature = 'monitor:explain {point : The control point name} {--json : Print the description as JSON}'; diff --git a/src/Console/MakeControlPointCommand.php b/src/Console/Commands/MakeControlPointCommand.php similarity index 93% rename from src/Console/MakeControlPointCommand.php rename to src/Console/Commands/MakeControlPointCommand.php index a91a068..2c3da23 100644 --- a/src/Console/MakeControlPointCommand.php +++ b/src/Console/Commands/MakeControlPointCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Kirschbaum\Monitor\Console; +namespace Kirschbaum\Monitor\Console\Commands; use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; @@ -14,7 +14,7 @@ * Generates App\ControlPoints\Payments\RefundCard with a #[Point] attribute, * an empty control() to fill in, and a test that already uses Monitor::fake(). */ -final class MakeControlPointCommand extends GeneratorCommand +class MakeControlPointCommand extends GeneratorCommand { protected $name = 'make:control-point'; @@ -39,7 +39,7 @@ public function handle(): ?bool protected function getStub(): string { - return __DIR__.'/../../stubs/control-point.stub'; + return __DIR__.'/../../../stubs/control-point.stub'; } protected function getDefaultNamespace($rootNamespace): string @@ -106,7 +106,7 @@ private function writeTest(): void return; } - $stub = $this->files->get(__DIR__.'/../../stubs/control-point-test.stub'); + $stub = $this->files->get(__DIR__.'/../../../stubs/control-point-test.stub'); $short = class_basename($name); $content = str_replace( diff --git a/src/Console/OutcomesCommand.php b/src/Console/Commands/OutcomesCommand.php similarity index 92% rename from src/Console/OutcomesCommand.php rename to src/Console/Commands/OutcomesCommand.php index b5d2d1d..3125ed8 100644 --- a/src/Console/OutcomesCommand.php +++ b/src/Console/Commands/OutcomesCommand.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Kirschbaum\Monitor\Console; +namespace Kirschbaum\Monitor\Console\Commands; use Illuminate\Console\Command; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Date; use Kirschbaum\Monitor\Store\OutcomeStore; -final class OutcomesCommand extends Command +class OutcomesCommand extends Command { protected $signature = 'monitor:outcomes {--point= : Only this control point} @@ -25,7 +25,7 @@ final class OutcomesCommand extends Command public function handle(OutcomeStore $store): int { if (! $store->enabled()) { - $this->components->error('The outcome store is disabled. Set MONITOR_STORE=true and run the migration.'); + $this->components->error('The outcome store is disabled. Set MONITOR_STORE_ENABLED=true and run the migration.'); return self::FAILURE; } @@ -75,6 +75,11 @@ public function handle(OutcomeStore $store): int 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) { diff --git a/src/Console/PointsCommand.php b/src/Console/Commands/PointsCommand.php similarity index 95% rename from src/Console/PointsCommand.php rename to src/Console/Commands/PointsCommand.php index f90501b..0150f13 100644 --- a/src/Console/PointsCommand.php +++ b/src/Console/Commands/PointsCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Kirschbaum\Monitor\Console; +namespace Kirschbaum\Monitor\Console\Commands; use Illuminate\Console\Command; use Kirschbaum\Monitor\Inventory\Discovery; @@ -12,7 +12,7 @@ use Kirschbaum\Monitor\Store\OutcomeStore; use Throwable; -final class PointsCommand extends Command +class PointsCommand extends Command { protected $signature = 'monitor:points {--check : Exit 1 when any rule reports an error} diff --git a/src/Console/PruneCommand.php b/src/Console/Commands/PruneCommand.php similarity index 90% rename from src/Console/PruneCommand.php rename to src/Console/Commands/PruneCommand.php index fa4fa0d..64919ed 100644 --- a/src/Console/PruneCommand.php +++ b/src/Console/Commands/PruneCommand.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Kirschbaum\Monitor\Console; +namespace Kirschbaum\Monitor\Console\Commands; use Illuminate\Console\Command; use Kirschbaum\Monitor\Store\OutcomeStore; -final class PruneCommand extends Command +class PruneCommand extends Command { protected $signature = 'monitor:prune {--days= : Delete outcomes older than this many days (default: the configured retention)}'; diff --git a/src/Escalations/Escalation.php b/src/Contracts/Escalation.php similarity index 91% rename from src/Escalations/Escalation.php rename to src/Contracts/Escalation.php index 0de6ff1..083e96a 100644 --- a/src/Escalations/Escalation.php +++ b/src/Contracts/Escalation.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Kirschbaum\Monitor\Escalations; +namespace Kirschbaum\Monitor\Contracts; use Kirschbaum\Monitor\Outcome; diff --git a/src/Limits/Limit.php b/src/Contracts/Limit.php similarity index 87% rename from src/Limits/Limit.php rename to src/Contracts/Limit.php index 4db52cd..4f48cc5 100644 --- a/src/Limits/Limit.php +++ b/src/Contracts/Limit.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Kirschbaum\Monitor\Limits; +namespace Kirschbaum\Monitor\Contracts; /** * A threshold a run must stay within. Whether a breach is recorded or diff --git a/src/Policies/Policy.php b/src/Contracts/Policy.php similarity index 95% rename from src/Policies/Policy.php rename to src/Contracts/Policy.php index b9aa0f8..2fe790e 100644 --- a/src/Policies/Policy.php +++ b/src/Contracts/Policy.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Kirschbaum\Monitor\Policies; +namespace Kirschbaum\Monitor\Contracts; use Closure; use Kirschbaum\Monitor\Run; diff --git a/src/Inventory/Rules/Rule.php b/src/Contracts/Rule.php similarity index 85% rename from src/Inventory/Rules/Rule.php rename to src/Contracts/Rule.php index 0dea54f..c3046f2 100644 --- a/src/Inventory/Rules/Rule.php +++ b/src/Contracts/Rule.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Kirschbaum\Monitor\Inventory\Rules; +namespace Kirschbaum\Monitor\Contracts; use Kirschbaum\Monitor\Inventory\Finding; use Kirschbaum\Monitor\Inventory\Inventory; diff --git a/src/Runner.php b/src/Contracts/Runner.php similarity index 73% rename from src/Runner.php rename to src/Contracts/Runner.php index 524e9f3..3229ead 100644 --- a/src/Runner.php +++ b/src/Contracts/Runner.php @@ -2,9 +2,11 @@ declare(strict_types=1); -namespace Kirschbaum\Monitor; +namespace Kirschbaum\Monitor\Contracts; use Closure; +use Kirschbaum\Monitor\Control; +use Kirschbaum\Monitor\Outcome; /** * Executes a control point. The live runner builds a Run; the fake records. diff --git a/src/Control.php b/src/Control.php index 0e29008..776f424 100644 --- a/src/Control.php +++ b/src/Control.php @@ -4,16 +4,20 @@ namespace Kirschbaum\Monitor; +use BackedEnum; use Closure; use Illuminate\Container\Container; use Illuminate\Database\DeadlockException; -use Kirschbaum\Monitor\Escalations\Escalation; +use Illuminate\Support\Traits\Conditionable; +use Illuminate\Support\Traits\Macroable; +use Kirschbaum\Monitor\Contracts\Escalation; +use Kirschbaum\Monitor\Contracts\Policy; +use Kirschbaum\Monitor\Contracts\Runner; use Kirschbaum\Monitor\Exceptions\InvalidControlPoint; use Kirschbaum\Monitor\Limits\Attempts; use Kirschbaum\Monitor\Limits\Ensure; use Kirschbaum\Monitor\Limits\Within; use Kirschbaum\Monitor\Policies\Breaker; -use Kirschbaum\Monitor\Policies\Policy; use Kirschbaum\Monitor\Policies\Retry; use Kirschbaum\Monitor\Policies\Transaction; use Kirschbaum\Monitor\Support\Domain; @@ -28,8 +32,13 @@ * Reads top to bottom as a contract. Two terminals: run() returns the value or * throws; attempt() returns the Outcome and never throws. */ -final class Control +class Control { + use Conditionable; + use Macroable; + + private readonly string $name; + private string $origin; private ?string $domain = null; @@ -58,10 +67,12 @@ final class Control /** * @param bool $validate false only when describing a point whose name may be invalid */ - public function __construct(private readonly string $name, string|object|null $origin = null, bool $validate = true) + public function __construct(string|BackedEnum $name, string|object|null $origin = null, bool $validate = true) { + $this->name = PointName::of($name); + if ($validate) { - PointName::validate($name); + PointName::validate($this->name); } $this->origin = $this->originOf($origin); @@ -231,7 +242,7 @@ public function ensure(Closure $check, string $reason = 'result did not satisfy public function recover(string $class, Closure $handler): self { if (! is_a($class, Throwable::class, true)) { - throw new InvalidControlPoint(sprintf('recover() expects a Throwable class, got "%s".', $class)); + throw new InvalidControlPoint(sprintf('recover() expects a Throwable class, got [%s].', $class)); } $this->risks[] = ['class' => $class, 'handler' => $handler]; @@ -247,7 +258,7 @@ public function recover(string $class, Closure $handler): self 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)); + throw new InvalidControlPoint(sprintf('escalate() expects a Closure or an Escalation class, got [%s].', $escalation)); } $this->escalation = $escalation; diff --git a/src/ControlPoint.php b/src/ControlPoint.php index c748327..1babd4c 100644 --- a/src/ControlPoint.php +++ b/src/ControlPoint.php @@ -14,7 +14,7 @@ * The class form of a control point. * * #[Point('payment.charge', profile: 'external')] - * final class ChargeCard extends ControlPoint + * class ChargeCard extends ControlPoint * { * public function __construct(private readonly Invoice $invoice) {} * @@ -107,7 +107,7 @@ public static function point(): Point $attributes = (new ReflectionClass(static::class))->getAttributes(Point::class); if ($attributes === []) { - throw new InvalidControlPoint(sprintf('%s must carry a #[Point("name")] attribute.', static::class)); + throw new InvalidControlPoint(sprintf('[%s] must carry a #[Point] attribute.', static::class)); } return $attributes[0]->newInstance(); @@ -156,7 +156,7 @@ 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)); + 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 index 11ccc7f..0e07e65 100644 --- a/src/ControlStack.php +++ b/src/ControlStack.php @@ -13,7 +13,7 @@ * visible as "control_point" so it lands on every log line the application * writes while the point runs. */ -final class ControlStack +class ControlStack { public const string KEY = 'monitor.stack'; diff --git a/src/Events/BreakerClosed.php b/src/Events/BreakerClosed.php index e714a1b..e991179 100644 --- a/src/Events/BreakerClosed.php +++ b/src/Events/BreakerClosed.php @@ -6,7 +6,7 @@ use Kirschbaum\Monitor\Breaker\BreakerState; -final readonly class BreakerClosed +class BreakerClosed { public function __construct(public string $breaker, public BreakerState $state) {} } diff --git a/src/Events/BreakerHalfOpen.php b/src/Events/BreakerHalfOpen.php index 13fe28f..0b1f4a6 100644 --- a/src/Events/BreakerHalfOpen.php +++ b/src/Events/BreakerHalfOpen.php @@ -6,7 +6,7 @@ use Kirschbaum\Monitor\Breaker\BreakerState; -final readonly class BreakerHalfOpen +class BreakerHalfOpen { public function __construct(public string $breaker, public BreakerState $state) {} } diff --git a/src/Events/BreakerOpened.php b/src/Events/BreakerOpened.php index 63d0d54..dc7a7e4 100644 --- a/src/Events/BreakerOpened.php +++ b/src/Events/BreakerOpened.php @@ -6,7 +6,7 @@ use Kirschbaum\Monitor\Breaker\BreakerState; -final readonly class BreakerOpened +class BreakerOpened { public function __construct(public string $breaker, public BreakerState $state) {} } diff --git a/src/Events/EscalationFailed.php b/src/Events/EscalationFailed.php index e193f12..675a970 100644 --- a/src/Events/EscalationFailed.php +++ b/src/Events/EscalationFailed.php @@ -10,7 +10,7 @@ /** * The escalation handler itself threw. The original exception still propagates. */ -final readonly class EscalationFailed +class EscalationFailed { public function __construct(public Outcome $outcome, public Throwable $exception) {} } diff --git a/src/Events/PointEnded.php b/src/Events/PointEnded.php index b3fb6ee..3aed130 100644 --- a/src/Events/PointEnded.php +++ b/src/Events/PointEnded.php @@ -6,7 +6,7 @@ use Kirschbaum\Monitor\Outcome; -final readonly class PointEnded +class PointEnded { public function __construct(public Outcome $outcome) {} } diff --git a/src/Events/PointEscalated.php b/src/Events/PointEscalated.php index 72954b2..6e69aa0 100644 --- a/src/Events/PointEscalated.php +++ b/src/Events/PointEscalated.php @@ -6,7 +6,7 @@ use Kirschbaum\Monitor\Outcome; -final readonly class PointEscalated +class PointEscalated { public function __construct(public Outcome $outcome) {} } diff --git a/src/Events/PointLimitBreached.php b/src/Events/PointLimitBreached.php index 6cab1c0..cba33ad 100644 --- a/src/Events/PointLimitBreached.php +++ b/src/Events/PointLimitBreached.php @@ -6,7 +6,7 @@ use Kirschbaum\Monitor\RunInfo; -final readonly class PointLimitBreached +class PointLimitBreached { public function __construct( public RunInfo $run, diff --git a/src/Events/PointRecovered.php b/src/Events/PointRecovered.php index 2effd54..7f98ce2 100644 --- a/src/Events/PointRecovered.php +++ b/src/Events/PointRecovered.php @@ -6,7 +6,7 @@ use Kirschbaum\Monitor\Outcome; -final readonly class PointRecovered +class PointRecovered { public function __construct(public Outcome $outcome) {} } diff --git a/src/Events/PointRefused.php b/src/Events/PointRefused.php index 206fb19..8034e25 100644 --- a/src/Events/PointRefused.php +++ b/src/Events/PointRefused.php @@ -6,7 +6,7 @@ use Kirschbaum\Monitor\Outcome; -final readonly class PointRefused +class PointRefused { public function __construct(public Outcome $outcome) {} } diff --git a/src/Events/PointRetried.php b/src/Events/PointRetried.php index c8b591a..dcb2689 100644 --- a/src/Events/PointRetried.php +++ b/src/Events/PointRetried.php @@ -7,7 +7,7 @@ use Kirschbaum\Monitor\RunInfo; use Throwable; -final readonly class PointRetried +class PointRetried { /** * @param int $attempt the attempt that just failed diff --git a/src/Events/PointStarted.php b/src/Events/PointStarted.php index f8ba17f..3520a09 100644 --- a/src/Events/PointStarted.php +++ b/src/Events/PointStarted.php @@ -6,7 +6,7 @@ use Kirschbaum\Monitor\RunInfo; -final readonly class PointStarted +class PointStarted { public function __construct(public RunInfo $run) {} } diff --git a/src/Exceptions/InvalidControlPoint.php b/src/Exceptions/InvalidControlPoint.php index a621e68..7352bf0 100644 --- a/src/Exceptions/InvalidControlPoint.php +++ b/src/Exceptions/InvalidControlPoint.php @@ -4,4 +4,6 @@ namespace Kirschbaum\Monitor\Exceptions; -class InvalidControlPoint extends MonitorException {} +use InvalidArgumentException; + +class InvalidControlPoint extends InvalidArgumentException implements MonitorException {} diff --git a/src/Exceptions/InvalidPointName.php b/src/Exceptions/InvalidPointName.php index 629d12b..e7e2161 100644 --- a/src/Exceptions/InvalidPointName.php +++ b/src/Exceptions/InvalidPointName.php @@ -4,4 +4,6 @@ namespace Kirschbaum\Monitor\Exceptions; -class InvalidPointName extends MonitorException {} +use InvalidArgumentException; + +class InvalidPointName extends InvalidArgumentException implements MonitorException {} diff --git a/src/Exceptions/InvalidProfile.php b/src/Exceptions/InvalidProfile.php index bea9d6f..06c3858 100644 --- a/src/Exceptions/InvalidProfile.php +++ b/src/Exceptions/InvalidProfile.php @@ -4,4 +4,6 @@ namespace Kirschbaum\Monitor\Exceptions; -class InvalidProfile extends MonitorException {} +use InvalidArgumentException; + +class InvalidProfile extends InvalidArgumentException implements MonitorException {} diff --git a/src/Exceptions/InvalidTraceId.php b/src/Exceptions/InvalidTraceId.php index 717b95b..992f715 100644 --- a/src/Exceptions/InvalidTraceId.php +++ b/src/Exceptions/InvalidTraceId.php @@ -4,4 +4,6 @@ namespace Kirschbaum\Monitor\Exceptions; -class InvalidTraceId extends MonitorException {} +use InvalidArgumentException; + +class InvalidTraceId extends InvalidArgumentException implements MonitorException {} diff --git a/src/Exceptions/MonitorException.php b/src/Exceptions/MonitorException.php index 4626ddd..dbcbbe3 100644 --- a/src/Exceptions/MonitorException.php +++ b/src/Exceptions/MonitorException.php @@ -4,6 +4,9 @@ namespace Kirschbaum\Monitor\Exceptions; -use RuntimeException; +use Throwable; -class MonitorException extends RuntimeException {} +/** + * Every exception the package throws, so callers can catch them as one. + */ +interface MonitorException extends Throwable {} diff --git a/src/Facades/Monitor.php b/src/Facades/Monitor.php index 8fd85e3..17f7e12 100644 --- a/src/Facades/Monitor.php +++ b/src/Facades/Monitor.php @@ -4,31 +4,36 @@ namespace Kirschbaum\Monitor\Facades; -use Illuminate\Container\Container; use Illuminate\Support\Facades\Facade; -use Kirschbaum\Monitor\Runner; +use Kirschbaum\Monitor\Contracts\Runner; use Kirschbaum\Monitor\Testing\MonitorFake; +use RuntimeException; /** - * @method static \Kirschbaum\Monitor\Control control(string $name, string|object|null $origin = null) + * @method static \Kirschbaum\Monitor\Control control(string|\BackedEnum $name, string|object|null $origin = null) * @method static \Kirschbaum\Monitor\ControlStack stack() * @method static \Kirschbaum\Monitor\Trace\Trace trace() * @method static \Kirschbaum\Monitor\Breaker\CircuitBreaker breaker() * @method static \Kirschbaum\Monitor\Logging\StructuredLogger log(string|object $origin) - * @method static \Kirschbaum\Monitor\Testing\MonitorFake failing(string $point, \Throwable $exception) - * @method static \Kirschbaum\Monitor\Testing\MonitorFake returning(string $point, mixed $value) - * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertRan(string $point, ?\Closure $callback = null) - * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertRanTimes(string $point, int $times) - * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertNeverRan(string $point) - * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertSucceeded(string $point, ?\Closure $callback = null) - * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertRecovered(string $point, ?string $from = null) - * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertEscalated(string $point, ?string $with = null) - * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertRefused(string $point) - * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertRetried(string $point, ?int $times = null) - * @method static \Kirschbaum\Monitor\Testing\MonitorFake assertLimitBreached(string $point, string $limit) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake returning(string|\BackedEnum $point, mixed $value) + * @method static \Kirschbaum\Monitor\Testing\MonitorFake failing(string|\BackedEnum $point, \Throwable $exception) + * @method static \Illuminate\Support\Collection 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() - * @method static list<\Kirschbaum\Monitor\Outcome> outcomes(?string $point = null) * * @see \Kirschbaum\Monitor\Monitor */ @@ -44,19 +49,24 @@ protected static function getFacadeAccessor(): string * * 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. - * - * @param array $returning canned values keyed by point name + * with assertRan() and friends. Calling this twice returns the same fake. */ - public static function fake(array $returning = []): MonitorFake + public static function fake(): MonitorFake { - $app = Container::getInstance(); - $fake = new MonitorFake($app, $app->make(Runner::class)); + $current = static::isFake() ? static::getFacadeRoot() : null; - foreach ($returning as $point => $value) { - $fake->returning($point, $value); + 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); diff --git a/src/Http/Middleware/CheckBreakers.php b/src/Http/Middleware/CheckBreakers.php index a13d249..26f4aae 100644 --- a/src/Http/Middleware/CheckBreakers.php +++ b/src/Http/Middleware/CheckBreakers.php @@ -18,9 +18,9 @@ * 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. */ -final readonly class CheckBreakers +class CheckBreakers { - public function __construct(private CircuitBreaker $breaker) {} + public function __construct(private readonly CircuitBreaker $breaker) {} public function handle(Request $request, Closure $next, string ...$breakers): SymfonyResponse { diff --git a/src/Http/Middleware/StartTrace.php b/src/Http/Middleware/StartTrace.php index a399433..ed9bb66 100644 --- a/src/Http/Middleware/StartTrace.php +++ b/src/Http/Middleware/StartTrace.php @@ -20,9 +20,9 @@ * replacement is logged once at debug, so a client sending junk cannot put * junk on every log line of the request. */ -final readonly class StartTrace +class StartTrace { - public function __construct(private Trace $trace) {} + public function __construct(private readonly Trace $trace) {} public function handle(Request $request, Closure $next): Response { diff --git a/src/Inventory/AstScanner.php b/src/Inventory/AstScanner.php index ecd256e..30d1077 100644 --- a/src/Inventory/AstScanner.php +++ b/src/Inventory/AstScanner.php @@ -18,7 +18,7 @@ * Reads a PHP file without executing it: the classes it declares, and every * Monitor::control('name') or new Control('name') it contains. */ -final class AstScanner +class AstScanner { public function scan(string $path): ScannedFile { diff --git a/src/Inventory/Discovery.php b/src/Inventory/Discovery.php index a815842..58f121d 100644 --- a/src/Inventory/Discovery.php +++ b/src/Inventory/Discovery.php @@ -5,9 +5,9 @@ namespace Kirschbaum\Monitor\Inventory; use Illuminate\Support\Facades\Config; +use Kirschbaum\Monitor\Contracts\Rule; use Kirschbaum\Monitor\Control; use Kirschbaum\Monitor\ControlPoint; -use Kirschbaum\Monitor\Inventory\Rules\Rule; use Kirschbaum\Monitor\Support\Domain; use Kirschbaum\Monitor\Support\Lists; use Symfony\Component\Finder\Finder; @@ -17,9 +17,9 @@ * Builds the inventory: scans the configured paths, describes every class-form * point fully and every inline point by name, then runs the rules. */ -final readonly class Discovery +class Discovery { - public function __construct(private AstScanner $scanner) {} + public function __construct(private readonly AstScanner $scanner) {} /** * @param list|null $paths absolute or relative to base_path(); defaults to config diff --git a/src/Inventory/Explainer.php b/src/Inventory/Explainer.php index 7fd22e0..27f08e3 100644 --- a/src/Inventory/Explainer.php +++ b/src/Inventory/Explainer.php @@ -11,7 +11,7 @@ /** * Turns a point description into prose a person or an agent can read. */ -final class Explainer +class Explainer { public function explain(PointDescription $point, ?OutcomeStore $store = null): string { diff --git a/src/Inventory/Finding.php b/src/Inventory/Finding.php index 4957bde..5fbec5b 100644 --- a/src/Inventory/Finding.php +++ b/src/Inventory/Finding.php @@ -4,7 +4,13 @@ namespace Kirschbaum\Monitor\Inventory; -final readonly class Finding +use Illuminate\Contracts\Support\Arrayable; +use JsonSerializable; + +/** + * @implements Arrayable + */ +final readonly class Finding implements Arrayable, JsonSerializable { public const string ERROR = 'error'; @@ -38,4 +44,12 @@ public function toArray(): array 'line' => $this->line, ]; } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/src/Inventory/Inventory.php b/src/Inventory/Inventory.php index 835b8f8..bc3270f 100644 --- a/src/Inventory/Inventory.php +++ b/src/Inventory/Inventory.php @@ -8,7 +8,7 @@ * Every control point the scan found, every class it saw, and the findings * the rules raised. */ -final class Inventory +class Inventory { /** * @param list $points diff --git a/src/Inventory/PointDescription.php b/src/Inventory/PointDescription.php index bd86fe8..a675192 100644 --- a/src/Inventory/PointDescription.php +++ b/src/Inventory/PointDescription.php @@ -4,11 +4,16 @@ namespace Kirschbaum\Monitor\Inventory; +use Illuminate\Contracts\Support\Arrayable; +use JsonSerializable; + /** * One control point as the inventory sees it. Class-form points carry their * whole contract; inline points carry only what a name scan can know. + * + * @implements Arrayable */ -final readonly class PointDescription +final readonly class PointDescription implements Arrayable, JsonSerializable { /** * @param list> $policies @@ -67,4 +72,12 @@ public function toArray(): array '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 index ac1d1e2..0a752e5 100644 --- a/src/Inventory/Reports/JsonReport.php +++ b/src/Inventory/Reports/JsonReport.php @@ -6,7 +6,7 @@ use Kirschbaum\Monitor\Inventory\Inventory; -final class JsonReport +class JsonReport { public function render(Inventory $inventory): string { diff --git a/src/Inventory/Reports/SarifReport.php b/src/Inventory/Reports/SarifReport.php index 22fd61f..3d4d166 100644 --- a/src/Inventory/Reports/SarifReport.php +++ b/src/Inventory/Reports/SarifReport.php @@ -11,7 +11,7 @@ /** * SARIF 2.1.0, so the findings show up as annotations in code scanning. */ -final class SarifReport +class SarifReport { public function render(Inventory $inventory, string $basePath): string { diff --git a/src/Inventory/Reports/TableReport.php b/src/Inventory/Reports/TableReport.php index e066bd9..9707c69 100644 --- a/src/Inventory/Reports/TableReport.php +++ b/src/Inventory/Reports/TableReport.php @@ -9,7 +9,7 @@ use Kirschbaum\Monitor\Inventory\Inventory; use Kirschbaum\Monitor\Inventory\PointDescription; -final class TableReport +class TableReport { /** * @param array $lastSeen diff --git a/src/Inventory/Rules/CatchAllWithoutEscalation.php b/src/Inventory/Rules/CatchAllWithoutEscalation.php index 2b37278..91837ea 100644 --- a/src/Inventory/Rules/CatchAllWithoutEscalation.php +++ b/src/Inventory/Rules/CatchAllWithoutEscalation.php @@ -4,6 +4,7 @@ namespace Kirschbaum\Monitor\Inventory\Rules; +use Kirschbaum\Monitor\Contracts\Rule; use Kirschbaum\Monitor\Inventory\Finding; use Kirschbaum\Monitor\Inventory\Inventory; @@ -11,7 +12,7 @@ * recover(Throwable::class) with no escalation swallows everything. It is * usually a risk analysis that has not been done. */ -final class CatchAllWithoutEscalation implements Rule +class CatchAllWithoutEscalation implements Rule { public function name(): string { diff --git a/src/Inventory/Rules/CriticalNamespaceUncontrolled.php b/src/Inventory/Rules/CriticalNamespaceUncontrolled.php index 1202440..782e953 100644 --- a/src/Inventory/Rules/CriticalNamespaceUncontrolled.php +++ b/src/Inventory/Rules/CriticalNamespaceUncontrolled.php @@ -5,17 +5,18 @@ namespace Kirschbaum\Monitor\Inventory\Rules; use Illuminate\Support\Facades\Config; +use Kirschbaum\Monitor\Contracts\Escalation; +use Kirschbaum\Monitor\Contracts\Policy; +use Kirschbaum\Monitor\Contracts\Rule; use Kirschbaum\Monitor\ControlPoint; -use Kirschbaum\Monitor\Escalations\Escalation; use Kirschbaum\Monitor\Inventory\Finding; use Kirschbaum\Monitor\Inventory\Inventory; -use Kirschbaum\Monitor\Policies\Policy; /** * A class in a critical namespace that is neither a control point class nor * calls one inline is an operation nobody declared. */ -final class CriticalNamespaceUncontrolled implements Rule +class CriticalNamespaceUncontrolled implements Rule { public function name(): string { diff --git a/src/Inventory/Rules/DuplicateNames.php b/src/Inventory/Rules/DuplicateNames.php index d3dfed0..7750cca 100644 --- a/src/Inventory/Rules/DuplicateNames.php +++ b/src/Inventory/Rules/DuplicateNames.php @@ -4,6 +4,7 @@ namespace Kirschbaum\Monitor\Inventory\Rules; +use Kirschbaum\Monitor\Contracts\Rule; use Kirschbaum\Monitor\Inventory\Finding; use Kirschbaum\Monitor\Inventory\Inventory; use Kirschbaum\Monitor\Inventory\PointDescription; @@ -12,7 +13,7 @@ * A name is the join key across code, records and tests; two points sharing * one cannot be told apart anywhere downstream. */ -final class DuplicateNames implements Rule +class DuplicateNames implements Rule { public function name(): string { diff --git a/src/Inventory/Rules/DynamicName.php b/src/Inventory/Rules/DynamicName.php index d446aed..2d66844 100644 --- a/src/Inventory/Rules/DynamicName.php +++ b/src/Inventory/Rules/DynamicName.php @@ -4,6 +4,7 @@ namespace Kirschbaum\Monitor\Inventory\Rules; +use Kirschbaum\Monitor\Contracts\Rule; use Kirschbaum\Monitor\Inventory\Finding; use Kirschbaum\Monitor\Inventory\Inventory; @@ -11,7 +12,7 @@ * A point whose name is computed cannot be inventoried, tested by name or * found in the records by anyone reading the code. */ -final class DynamicName implements Rule +class DynamicName implements Rule { public function name(): string { diff --git a/src/Inventory/Rules/MissingEscalation.php b/src/Inventory/Rules/MissingEscalation.php index e239ff0..ab8531e 100644 --- a/src/Inventory/Rules/MissingEscalation.php +++ b/src/Inventory/Rules/MissingEscalation.php @@ -4,6 +4,7 @@ namespace Kirschbaum\Monitor\Inventory\Rules; +use Kirschbaum\Monitor\Contracts\Rule; use Kirschbaum\Monitor\Inventory\Finding; use Kirschbaum\Monitor\Inventory\Inventory; @@ -11,7 +12,7 @@ * The one thing a critical operation must never do is fail without anyone * being told. A class-form point needs an escalation or an explicit catch-all. */ -final class MissingEscalation implements Rule +class MissingEscalation implements Rule { public function name(): string { diff --git a/src/Inventory/Rules/NamePattern.php b/src/Inventory/Rules/NamePattern.php index eee053d..e61941a 100644 --- a/src/Inventory/Rules/NamePattern.php +++ b/src/Inventory/Rules/NamePattern.php @@ -4,11 +4,12 @@ namespace Kirschbaum\Monitor\Inventory\Rules; +use Kirschbaum\Monitor\Contracts\Rule; use Kirschbaum\Monitor\Inventory\Finding; use Kirschbaum\Monitor\Inventory\Inventory; use Kirschbaum\Monitor\Support\PointName; -final class NamePattern implements Rule +class NamePattern implements Rule { public function name(): string { diff --git a/src/Inventory/Rules/Rules.php b/src/Inventory/Rules/Rules.php index 1f6add3..6071c68 100644 --- a/src/Inventory/Rules/Rules.php +++ b/src/Inventory/Rules/Rules.php @@ -5,8 +5,9 @@ namespace Kirschbaum\Monitor\Inventory\Rules; use Illuminate\Support\Facades\Config; +use Kirschbaum\Monitor\Contracts\Rule; -final class Rules +class Rules { /** * @return array> diff --git a/src/Inventory/Rules/UnreadableControl.php b/src/Inventory/Rules/UnreadableControl.php index 2e65bbe..725f12e 100644 --- a/src/Inventory/Rules/UnreadableControl.php +++ b/src/Inventory/Rules/UnreadableControl.php @@ -4,6 +4,7 @@ namespace Kirschbaum\Monitor\Inventory\Rules; +use Kirschbaum\Monitor\Contracts\Rule; use Kirschbaum\Monitor\Inventory\Finding; use Kirschbaum\Monitor\Inventory\Inventory; @@ -11,7 +12,7 @@ * control() that depends on constructor arguments cannot be read statically, * so nothing else in the inventory can be trusted for that point. */ -final class UnreadableControl implements Rule +class UnreadableControl implements Rule { public function name(): string { diff --git a/src/Inventory/ScannedFile.php b/src/Inventory/ScannedFile.php index 924e7ef..636b9dd 100644 --- a/src/Inventory/ScannedFile.php +++ b/src/Inventory/ScannedFile.php @@ -8,6 +8,9 @@ * What one PHP file contributes: the classes it declares and the inline * control points it calls, with the class each call sits in. */ +/** + * @internal + */ final readonly class ScannedFile { /** diff --git a/src/Limits/Attempts.php b/src/Limits/Attempts.php index 07c1f47..c0f33a0 100644 --- a/src/Limits/Attempts.php +++ b/src/Limits/Attempts.php @@ -4,6 +4,8 @@ namespace Kirschbaum\Monitor\Limits; +use Kirschbaum\Monitor\Contracts\Limit; + /** * A hard cap on attempts for the run, whatever the retry policies ask for. */ diff --git a/src/Limits/Ensure.php b/src/Limits/Ensure.php index 0104627..da4845f 100644 --- a/src/Limits/Ensure.php +++ b/src/Limits/Ensure.php @@ -5,6 +5,7 @@ namespace Kirschbaum\Monitor\Limits; use Closure; +use Kirschbaum\Monitor\Contracts\Limit; /** * A post-condition on the returned value. The only limit that fails a run, diff --git a/src/Limits/Within.php b/src/Limits/Within.php index 53f94e1..17a0fbb 100644 --- a/src/Limits/Within.php +++ b/src/Limits/Within.php @@ -4,6 +4,8 @@ namespace Kirschbaum\Monitor\Limits; +use Kirschbaum\Monitor\Contracts\Limit; + /** * The whole run, attempts included, should finish inside this many seconds. * diff --git a/src/LiveRunner.php b/src/LiveRunner.php index 5d7e30f..a38ffd7 100644 --- a/src/LiveRunner.php +++ b/src/LiveRunner.php @@ -7,15 +7,16 @@ use Closure; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Events\Dispatcher; +use Kirschbaum\Monitor\Contracts\Runner; use Kirschbaum\Monitor\Trace\Trace; -final readonly class LiveRunner implements Runner +class LiveRunner implements Runner { public function __construct( - private Container $container, - private Dispatcher $events, - private Trace $trace, - private ControlStack $stack, + private readonly Container $container, + private readonly Dispatcher $events, + private readonly Trace $trace, + private readonly ControlStack $stack, ) {} public function execute(Control $control, Closure $callback): Outcome diff --git a/src/Logging/JsonTap.php b/src/Logging/JsonTap.php index 283ad6f..da41355 100644 --- a/src/Logging/JsonTap.php +++ b/src/Logging/JsonTap.php @@ -12,7 +12,7 @@ * Makes a channel write newline-delimited JSON with Monitor's record fields * at the top level. Add it to any channel's "tap" list. */ -final class JsonTap +class JsonTap { public function __invoke(Logger $logger): void { diff --git a/src/Logging/RecordFormatter.php b/src/Logging/RecordFormatter.php index 53792d3..fc3c73e 100644 --- a/src/Logging/RecordFormatter.php +++ b/src/Logging/RecordFormatter.php @@ -13,7 +13,7 @@ * as text inside a context blob. Context keys win over extra keys, and both * win over nothing; the four envelope keys are always present. */ -final class RecordFormatter extends NormalizerFormatter +class RecordFormatter extends NormalizerFormatter { public function __construct() { diff --git a/src/Logging/StructuredLogger.php b/src/Logging/StructuredLogger.php index 59c94f5..6150642 100644 --- a/src/Logging/StructuredLogger.php +++ b/src/Logging/StructuredLogger.php @@ -5,6 +5,7 @@ namespace Kirschbaum\Monitor\Logging; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Traits\Conditionable; use Kirschbaum\Monitor\Support\Domain; use Psr\Log\LoggerInterface; use Psr\Log\LoggerTrait; @@ -15,8 +16,9 @@ * origin class and its domain as context fields and a readable prefix on the * message, so lines from anywhere in a domain group together in a log backend. */ -final class StructuredLogger implements LoggerInterface +class StructuredLogger implements LoggerInterface { + use Conditionable; use LoggerTrait; private readonly string $origin; diff --git a/src/Mcp/MonitorServer.php b/src/Mcp/MonitorServer.php index bcd919b..d0ee390 100644 --- a/src/Mcp/MonitorServer.php +++ b/src/Mcp/MonitorServer.php @@ -21,7 +21,7 @@ * A read-only server that lets an agent inspect the application's control * points and what happened at them. Started with `php artisan mcp:start monitor`. */ -final class MonitorServer extends Server +class MonitorServer extends Server { protected string $name = 'Monitor'; diff --git a/src/Mcp/Prompts/WrapOperation.php b/src/Mcp/Prompts/WrapOperation.php index d6e6433..08219c8 100644 --- a/src/Mcp/Prompts/WrapOperation.php +++ b/src/Mcp/Prompts/WrapOperation.php @@ -10,7 +10,7 @@ use Laravel\Mcp\Server\Prompt; use Laravel\Mcp\Server\Prompts\Argument; -final class WrapOperation extends Prompt +class WrapOperation extends Prompt { protected string $name = 'wrap_operation'; diff --git a/src/Mcp/Resources/Guidelines.php b/src/Mcp/Resources/Guidelines.php index fade7dc..dfe037a 100644 --- a/src/Mcp/Resources/Guidelines.php +++ b/src/Mcp/Resources/Guidelines.php @@ -7,7 +7,7 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Resource; -final class Guidelines extends Resource +class Guidelines extends Resource { protected string $name = 'guidelines'; diff --git a/src/Mcp/Resources/RecordSchema.php b/src/Mcp/Resources/RecordSchema.php index 784c3e2..7996f77 100644 --- a/src/Mcp/Resources/RecordSchema.php +++ b/src/Mcp/Resources/RecordSchema.php @@ -7,7 +7,7 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Resource; -final class RecordSchema extends Resource +class RecordSchema extends Resource { protected string $name = 'record_schema'; diff --git a/src/Mcp/ResponseRedactor.php b/src/Mcp/ResponseRedactor.php index a2b1db4..788bf49 100644 --- a/src/Mcp/ResponseRedactor.php +++ b/src/Mcp/ResponseRedactor.php @@ -16,9 +16,9 @@ * replaced because it was long. Prose is redacted line by line for the same * reason. Resources are package files and pass through untouched. */ -final readonly class ResponseRedactor +class ResponseRedactor { - public function __construct(private ?string $profile) {} + public function __construct(private readonly ?string $profile) {} /** * @param iterable|JsonRpcResponse $response diff --git a/src/Mcp/Tools/Escalations.php b/src/Mcp/Tools/Escalations.php index 6bc758a..1abd59f 100644 --- a/src/Mcp/Tools/Escalations.php +++ b/src/Mcp/Tools/Escalations.php @@ -4,16 +4,15 @@ namespace Kirschbaum\Monitor\Mcp\Tools; -use Illuminate\Container\Container; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\Carbon; -use Kirschbaum\Monitor\Console\OutcomesCommand; +use Kirschbaum\Monitor\Console\Commands\OutcomesCommand; use Kirschbaum\Monitor\Store\OutcomeStore; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; -final class Escalations extends Tool +class Escalations extends Tool { protected string $name = 'escalations'; @@ -27,12 +26,10 @@ public function schema(JsonSchema $schema): array ]; } - public function handle(Request $request): Response + public function handle(Request $request, OutcomeStore $store): Response { - $store = Container::getInstance()->make(OutcomeStore::class); - if (! $store->enabled()) { - return Response::error('The outcome store is disabled. Set MONITOR_STORE=true and run the migration; records are still in the log.'); + 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'); diff --git a/src/Mcp/Tools/ExplainPoint.php b/src/Mcp/Tools/ExplainPoint.php index ac5fd9e..fefa604 100644 --- a/src/Mcp/Tools/ExplainPoint.php +++ b/src/Mcp/Tools/ExplainPoint.php @@ -4,16 +4,16 @@ namespace Kirschbaum\Monitor\Mcp\Tools; -use Illuminate\Container\Container; use Illuminate\Contracts\JsonSchema\JsonSchema; use Kirschbaum\Monitor\Inventory\Discovery; use Kirschbaum\Monitor\Inventory\Explainer; +use Kirschbaum\Monitor\Inventory\PointDescription; use Kirschbaum\Monitor\Store\OutcomeStore; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; -final class ExplainPoint extends Tool +class ExplainPoint extends Tool { protected string $name = 'explain_point'; @@ -26,7 +26,7 @@ public function schema(JsonSchema $schema): array ]; } - public function handle(Request $request): Response + public function handle(Request $request, Discovery $discovery, Explainer $explainer, OutcomeStore $store): Response { $name = $request->get('point'); @@ -34,15 +34,12 @@ public function handle(Request $request): Response return Response::error('Pass the control point name as "point".'); } - $container = Container::getInstance(); - $point = $container->make(Discovery::class)->build()->find($name); + $point = $discovery->build()->find($name); - if ($point === null) { + if (! $point instanceof PointDescription) { return Response::error(sprintf('No control point named "%s". Use list_points to see what exists.', $name)); } - $store = $container->make(OutcomeStore::class); - - return Response::text($container->make(Explainer::class)->explain($point, $store->enabled() ? $store : null)); + return Response::text($explainer->explain($point, $store->enabled() ? $store : null)); } } diff --git a/src/Mcp/Tools/ListPoints.php b/src/Mcp/Tools/ListPoints.php index 96b1eea..32e3d46 100644 --- a/src/Mcp/Tools/ListPoints.php +++ b/src/Mcp/Tools/ListPoints.php @@ -4,7 +4,6 @@ namespace Kirschbaum\Monitor\Mcp\Tools; -use Illuminate\Container\Container; use Illuminate\Contracts\JsonSchema\JsonSchema; use Kirschbaum\Monitor\Inventory\Discovery; use Kirschbaum\Monitor\Inventory\Finding; @@ -13,7 +12,7 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; -final class ListPoints extends Tool +class ListPoints extends Tool { protected string $name = 'list_points'; @@ -26,9 +25,9 @@ public function schema(JsonSchema $schema): array ]; } - public function handle(Request $request): Response + public function handle(Request $request, Discovery $discovery): Response { - $inventory = Container::getInstance()->make(Discovery::class)->build(); + $inventory = $discovery->build(); $domain = $request->get('domain'); $filtered = is_string($domain) && $domain !== ''; diff --git a/src/Mcp/Tools/RecentOutcomes.php b/src/Mcp/Tools/RecentOutcomes.php index 6679039..defef2a 100644 --- a/src/Mcp/Tools/RecentOutcomes.php +++ b/src/Mcp/Tools/RecentOutcomes.php @@ -4,16 +4,15 @@ namespace Kirschbaum\Monitor\Mcp\Tools; -use Illuminate\Container\Container; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\Carbon; -use Kirschbaum\Monitor\Console\OutcomesCommand; +use Kirschbaum\Monitor\Console\Commands\OutcomesCommand; use Kirschbaum\Monitor\Store\OutcomeStore; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; -final class RecentOutcomes extends Tool +class RecentOutcomes extends Tool { protected string $name = 'outcomes'; @@ -31,12 +30,10 @@ public function schema(JsonSchema $schema): array ]; } - public function handle(Request $request): Response + public function handle(Request $request, OutcomeStore $store): Response { - $store = Container::getInstance()->make(OutcomeStore::class); - if (! $store->enabled()) { - return Response::error('The outcome store is disabled. Set MONITOR_STORE=true and run the migration; records are still in the log.'); + 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'); diff --git a/src/Monitor.php b/src/Monitor.php index 10bb809..8c725b6 100644 --- a/src/Monitor.php +++ b/src/Monitor.php @@ -4,6 +4,7 @@ namespace Kirschbaum\Monitor; +use BackedEnum; use Illuminate\Contracts\Container\Container; use Kirschbaum\Monitor\Breaker\CircuitBreaker; use Kirschbaum\Monitor\Logging\StructuredLogger; @@ -16,7 +17,7 @@ public function __construct(protected Container $container) {} /** * Declare a control point. */ - public function control(string $name, string|object|null $origin = null): Control + public function control(string|BackedEnum $name, string|object|null $origin = null): Control { return new Control($name, $origin); } diff --git a/src/MonitorServiceProvider.php b/src/MonitorServiceProvider.php index 88f87ae..0ac11c3 100644 --- a/src/MonitorServiceProvider.php +++ b/src/MonitorServiceProvider.php @@ -4,22 +4,32 @@ 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\Breaker\CircuitBreaker; -use Kirschbaum\Monitor\Console\ExplainCommand; -use Kirschbaum\Monitor\Console\MakeControlPointCommand; -use Kirschbaum\Monitor\Console\OutcomesCommand; -use Kirschbaum\Monitor\Console\PointsCommand; -use Kirschbaum\Monitor\Console\PruneCommand; +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\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; @@ -28,69 +38,148 @@ class MonitorServiceProvider extends ServiceProvider { + /** + * Register the package's services. + */ public function register(): void { $this->mergeConfigFrom(__DIR__.'/../config/monitor.php', 'monitor'); - $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->bind(Runner::class, LiveRunner::class); - $this->app->singleton(OutcomeStore::class); - $this->app->singleton(StoreOutcomes::class); + $this->registerServices(); } /** - * Register the MCP server when laravel/mcp is installed and the server is enabled. + * Bootstrap the package. */ - public static function registerMcpServer(Container $app): bool + public function boot(): void { - $config = $app->make('config'); - - if (! class_exists(Server::class) || ! $config->get('monitor.mcp.enabled', false)) { - return false; + if ($this->app->runningInConsole()) { + $this->registerPublishing(); + $this->registerCommands(); + $this->registerAbout(); } - $handle = $config->get('monitor.mcp.handle', 'monitor'); - - Mcp::local(is_string($handle) && $handle !== '' ? $handle : 'monitor', MonitorServer::class); + $this->registerMiddleware(); + $this->registerListeners(); + $this->registerMacros(); + $this->registerMcpServer(); + } - return true; + /** + * 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); } - public function boot(): void + /** + * 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__.'/../database/migrations/create_monitor_outcomes_table.php' => database_path('migrations/'.date('Y_m_d_His').'_create_monitor_outcomes_table.php'), + $this->publishesMigrations([ + __DIR__.'/../database/migrations' => database_path('migrations'), ], 'monitor-migrations'); + } - if ($this->app->make('config')->get('monitor.records.store.enabled', false)) { - $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); - } - - $this->app->make('events')->subscribe(Recorder::class); - $this->app->make('events')->subscribe(StoreOutcomes::class); + /** + * Register the package's console commands. + */ + protected function registerCommands(): void + { + $this->commands([ + PointsCommand::class, + ExplainCommand::class, + OutcomesCommand::class, + PruneCommand::class, + MakeControlPointCommand::class, + ]); + } - if ($this->app->runningInConsole()) { - $this->commands([OutcomesCommand::class, PruneCommand::class, PointsCommand::class, ExplainCommand::class, MakeControlPointCommand::class]); - } - $this->app->make('events')->listen(JobProcessing::class, PicksUpJobTrace::class); + /** + * 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()), + ]); + } - PropagatesTrace::register($this->app->make(Trace::class)); + /** + * 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); + }); + } - self::registerMcpServer($this->app); + /** + * 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(); + }); + }); + }); + } - $router = $this->app->make(Router::class); - $router->aliasMiddleware('monitor.trace', StartTrace::class); - $router->aliasMiddleware('monitor.breakers', CheckBreakers::class); + /** + * Register Http::traced() and $pendingRequest->traced(). + */ + protected function registerMacros(): void + { + PropagatesTrace::register(fn (): Trace => $this->app->make(Trace::class)); + } - if ($this->app->runningInConsole() && $this->app->make('config')->get('monitor.trace.console', true)) { - $this->app->make(Trace::class)->pickup(); + /** + * 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 index d259fd4..82ab36d 100644 --- a/src/Outcome.php +++ b/src/Outcome.php @@ -4,6 +4,8 @@ namespace Kirschbaum\Monitor; +use Illuminate\Contracts\Support\Arrayable; +use JsonSerializable; use Throwable; /** @@ -11,8 +13,10 @@ * * Whether a caller sees it through attempt(), an event, the fake or the store, * it is the same object with the same fields. + * + * @implements Arrayable */ -final readonly class Outcome +final readonly class Outcome implements Arrayable, JsonSerializable { /** * @param array $limitsBreached @@ -120,4 +124,12 @@ public function toArray(): array 'stack' => $this->stack, ]; } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } } diff --git a/src/Policies/Breaker.php b/src/Policies/Breaker.php index fea6ee5..dbc2fa3 100644 --- a/src/Policies/Breaker.php +++ b/src/Policies/Breaker.php @@ -8,6 +8,7 @@ use Illuminate\Container\Container; use Kirschbaum\Monitor\Breaker\BreakerConfig; use Kirschbaum\Monitor\Breaker\CircuitBreaker; +use Kirschbaum\Monitor\Contracts\Policy; use Kirschbaum\Monitor\Policies\Concerns\FiltersExceptions; use Kirschbaum\Monitor\Risks\BreakerOpen; use Kirschbaum\Monitor\Run; @@ -21,7 +22,7 @@ * closes it, a failure opens it again. Failures are counted per run, after * retries, so a retried-then-succeeded operation is a success. */ -final class Breaker implements Policy +class Breaker implements Policy { use FiltersExceptions; @@ -31,11 +32,11 @@ final class Breaker implements Policy private ?int $for = null; - public function __construct(private readonly string $name) {} + final public function __construct(private readonly string $name) {} - public static function named(string $name): self + public static function named(string $name): static { - return new self($name); + return new static($name); } public function name(): string @@ -72,7 +73,7 @@ public function around(Run $run, Closure $next): mixed $circuit = Container::getInstance()->make(CircuitBreaker::class); $config = $this->config(); - $decision = $circuit->attempt($this->name, $config); + $decision = $circuit->permit($this->name, $config); if (! $decision->allowed) { $run->note('breaker.refused', ['breaker' => $this->name, 'retry_after_s' => $decision->retryAfterSeconds]); diff --git a/src/Policies/Retry.php b/src/Policies/Retry.php index baa470d..733a774 100644 --- a/src/Policies/Retry.php +++ b/src/Policies/Retry.php @@ -6,6 +6,7 @@ use Closure; use Illuminate\Support\Sleep; +use Kirschbaum\Monitor\Contracts\Policy; use Kirschbaum\Monitor\Policies\Concerns\FiltersExceptions; use Kirschbaum\Monitor\Run; use Kirschbaum\Monitor\Support\ChildEscalations; @@ -19,28 +20,26 @@ * happened rather than a wrapper. An attempts() limit on the point caps the * total regardless of what is asked for here. */ -final class Retry implements Policy +class Retry implements Policy { use FiltersExceptions; - private int $times = 1; - private int $backoffMs = 0; private float $multiplier = 2.0; private bool $jitter = true; - public static function times(int $times): self - { - return (new self)->setTimes($times); - } + private readonly int $times; - public function setTimes(int $times): self + final public function __construct(int $times = 1) { $this->times = max(0, $times); + } - return $this; + public static function times(int $times): static + { + return new static($times); } public function backoff(int $ms, float $multiplier = 2.0, bool $jitter = true): self diff --git a/src/Policies/Transaction.php b/src/Policies/Transaction.php index 6086eec..740feca 100644 --- a/src/Policies/Transaction.php +++ b/src/Policies/Transaction.php @@ -7,6 +7,7 @@ use Closure; use Illuminate\Database\DeadlockException; use Illuminate\Support\Facades\DB; +use Kirschbaum\Monitor\Contracts\Policy; use Kirschbaum\Monitor\Policies\Concerns\FiltersExceptions; use Kirschbaum\Monitor\Run; @@ -17,31 +18,25 @@ * Deadlocks are retried by default. Anything else is not, because most failures * inside a transaction are not made better by repeating them. */ -final class Transaction implements Policy +class Transaction implements Policy { use FiltersExceptions; - private int $retries = 0; + private readonly int $retries; private ?string $connection = null; private int $backoffMs = 0; - public function __construct() + final public function __construct(int $retries = 0) { + $this->retries = max(0, $retries); $this->only = [DeadlockException::class]; } - public static function retries(int $retries): self - { - return (new self)->setRetries($retries); - } - - public function setRetries(int $retries): self + public static function retries(int $retries): static { - $this->retries = max(0, $retries); - - return $this; + return new static($retries); } public function connection(?string $name): self diff --git a/src/Records/ExceptionSummary.php b/src/Records/ExceptionSummary.php index 7ffcd84..c9f6793 100644 --- a/src/Records/ExceptionSummary.php +++ b/src/Records/ExceptionSummary.php @@ -10,8 +10,10 @@ /** * The part of an exception worth recording: class, message, location, code, * the previous exception one level down, and a trace only when configured. + * + * @internal */ -final class ExceptionSummary +class ExceptionSummary { /** * @return array diff --git a/src/Records/Record.php b/src/Records/Record.php index 3fad02c..0cfc2e0 100644 --- a/src/Records/Record.php +++ b/src/Records/Record.php @@ -16,8 +16,10 @@ /** * The one shape every record has. Field names here are the schema; see * resources/schema/record-1.json. + * + * @internal */ -final class Record +class Record { public const string SCHEMA = 'monitor/1'; diff --git a/src/Records/Recorder.php b/src/Records/Recorder.php index ad17856..e7ed37e 100644 --- a/src/Records/Recorder.php +++ b/src/Records/Recorder.php @@ -24,7 +24,7 @@ * Writes one log record per transition, on the configured channel, with the * context redacted. Levels come from config('monitor.records.levels'). */ -final class Recorder +class Recorder { /** * @return array diff --git a/src/Risks/BreakerOpen.php b/src/Risks/BreakerOpen.php index bd19e11..05345ad 100644 --- a/src/Risks/BreakerOpen.php +++ b/src/Risks/BreakerOpen.php @@ -11,6 +11,6 @@ final class BreakerOpen extends Risk { public function __construct(public readonly string $breaker, public readonly int $retryAfterSeconds) { - parent::__construct(sprintf('Circuit breaker "%s" is open; retry in %ds.', $breaker, $retryAfterSeconds)); + parent::__construct(sprintf('Circuit breaker [%s] is open; retry in %ds.', $breaker, $retryAfterSeconds)); } } diff --git a/src/Risks/Risk.php b/src/Risks/Risk.php index 8849d68..065a974 100644 --- a/src/Risks/Risk.php +++ b/src/Risks/Risk.php @@ -5,9 +5,10 @@ namespace Kirschbaum\Monitor\Risks; use Kirschbaum\Monitor\Exceptions\MonitorException; +use RuntimeException; /** * A failure Monitor itself raises inside a control point. Every risk can be * recovered from with recover(TheRisk::class, ...) like any other exception. */ -abstract class Risk extends MonitorException {} +abstract class Risk extends RuntimeException implements MonitorException {} diff --git a/src/Run.php b/src/Run.php index 6919807..ce7bfa6 100644 --- a/src/Run.php +++ b/src/Run.php @@ -8,7 +8,8 @@ use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Str; -use Kirschbaum\Monitor\Escalations\Escalation; +use Kirschbaum\Monitor\Contracts\Escalation; +use Kirschbaum\Monitor\Contracts\Policy; use Kirschbaum\Monitor\Events\EscalationFailed; use Kirschbaum\Monitor\Events\PointEnded; use Kirschbaum\Monitor\Events\PointEscalated; @@ -19,7 +20,6 @@ use Kirschbaum\Monitor\Events\PointStarted; use Kirschbaum\Monitor\Limits\Attempts; use Kirschbaum\Monitor\Limits\Within; -use Kirschbaum\Monitor\Policies\Policy; use Kirschbaum\Monitor\Risks\BreakerOpen; use Kirschbaum\Monitor\Risks\EnsureFailed; use Kirschbaum\Monitor\Support\ChildEscalations; @@ -29,7 +29,7 @@ /** * One execution of a control point, from the first event to the outcome. */ -final class Run +class Run { private RunInfo $info; diff --git a/src/Store/StoreOutcomes.php b/src/Store/StoreOutcomes.php index 71812c0..35b1beb 100644 --- a/src/Store/StoreOutcomes.php +++ b/src/Store/StoreOutcomes.php @@ -4,45 +4,53 @@ namespace Kirschbaum\Monitor\Store; -use Illuminate\Contracts\Foundation\Application; +use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Queue\Events\JobExceptionOccurred; use Illuminate\Queue\Events\JobProcessed; +use Illuminate\Queue\Events\Looping; +use Illuminate\Queue\Events\WorkerStopping; use Illuminate\Support\Facades\Log; use Kirschbaum\Monitor\Events\PointEnded; use Kirschbaum\Monitor\Outcome; use Throwable; /** - * Buffers outcomes and writes them after the response is sent or the job has - * finished. A failing write is logged once per process and never rethrown: - * the store is never the reason a control point fails. + * Buffer outcomes and write them after the response is sent, the command has + * finished, or the job has completed. A failing write is logged once per + * process and never rethrown: the store is never the reason a point fails. */ -final class StoreOutcomes +class StoreOutcomes { - /** @var list */ - private array $buffer = []; + /** + * How many outcomes to hold before writing regardless of lifecycle, so a + * long-running process never keeps an unbounded buffer. + */ + public const BUFFER_LIMIT = 100; - private bool $flushRegistered = false; + /** @var list */ + protected array $buffer = []; - private bool $failureReported = false; + protected bool $failureReported = false; - public function __construct( - private readonly OutcomeStore $store, - private readonly Application $app, - ) {} + public function __construct(protected OutcomeStore $store) {} /** * @return array */ - public function subscribe(): 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()) { @@ -51,12 +59,14 @@ public function buffer(PointEnded $event): void $this->buffer[] = $event->outcome; - if (! $this->flushRegistered) { - $this->flushRegistered = true; - $this->app->terminating(fn (): int => $this->flush()); + 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 === []) { @@ -81,6 +91,9 @@ public function flush(): int } } + /** + * How many outcomes are waiting to be written. + */ public function pending(): int { return count($this->buffer); diff --git a/src/Support/ChildEscalations.php b/src/Support/ChildEscalations.php index 28efefb..c89cf77 100644 --- a/src/Support/ChildEscalations.php +++ b/src/Support/ChildEscalations.php @@ -13,8 +13,10 @@ * A parent's retry policy consults this so it never re-runs a child that has * already exhausted its own attempts: retries do not compose across the stack. * The parent's corrections still see the exception like any other. + * + * @internal */ -final class ChildEscalations +class ChildEscalations { /** @var WeakMap|null */ private static ?WeakMap $escalations = null; diff --git a/src/Support/Domain.php b/src/Support/Domain.php index c9c59db..0344762 100644 --- a/src/Support/Domain.php +++ b/src/Support/Domain.php @@ -12,8 +12,10 @@ * The map in config('monitor.domains.map') is tried in order. A null value * takes the namespace segment right after the prefix; a string value is used * as-is. Nothing matching yields the configured fallback. + * + * @internal */ -final class Domain +class Domain { public static function resolve(string $class): string { diff --git a/src/Support/Lists.php b/src/Support/Lists.php index ccd61fd..3c2a606 100644 --- a/src/Support/Lists.php +++ b/src/Support/Lists.php @@ -4,7 +4,10 @@ namespace Kirschbaum\Monitor\Support; -final class Lists +/** + * @internal + */ +class Lists { /** * The array-valued items of a value, as a list of string-keyed arrays. diff --git a/src/Support/PointName.php b/src/Support/PointName.php index 0feb627..8046556 100644 --- a/src/Support/PointName.php +++ b/src/Support/PointName.php @@ -7,13 +7,24 @@ use Illuminate\Support\Facades\Config; use Kirschbaum\Monitor\Exceptions\InvalidPointName; -final class PointName +/** + * @internal + */ +class PointName { + /** + * The string form of a name given as a string or a backed enum. + */ + public static function of(string|\BackedEnum $name): string + { + return $name instanceof \BackedEnum ? (string) $name->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.', + 'Control point name [%s] does not match the pattern [%s].', $name, Config::string('monitor.point_name_pattern'), )); diff --git a/src/Support/Profiles.php b/src/Support/Profiles.php index 66362a5..8e0993f 100644 --- a/src/Support/Profiles.php +++ b/src/Support/Profiles.php @@ -5,18 +5,20 @@ namespace Kirschbaum\Monitor\Support; use Illuminate\Support\Facades\Config; +use Kirschbaum\Monitor\Contracts\Policy; use Kirschbaum\Monitor\Exceptions\InvalidProfile; use Kirschbaum\Monitor\Limits\Attempts; use Kirschbaum\Monitor\Limits\Within; use Kirschbaum\Monitor\Policies\Breaker; -use Kirschbaum\Monitor\Policies\Policy; use Kirschbaum\Monitor\Policies\Retry; use Kirschbaum\Monitor\Policies\Transaction; /** * Turns a configured profile into the policies and limits it bundles. + * + * @internal */ -final class Profiles +class Profiles { /** * @return list @@ -42,7 +44,7 @@ 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)); + throw new InvalidProfile(sprintf('Profile [%s] is not defined in config/monitor.php.', $name)); } /** @var array $definition */ diff --git a/src/Support/Redaction.php b/src/Support/Redaction.php index 1003aca..6788b60 100644 --- a/src/Support/Redaction.php +++ b/src/Support/Redaction.php @@ -6,13 +6,16 @@ use Illuminate\Support\Facades\Config; use Kirschbaum\Redactor\Facades\Redactor; +use Throwable; /** * Redacts the parts of a record that carry application data: the context and * any exception messages. Identifiers, names and numbers are left alone so a * record stays joinable. + * + * @internal */ -final class Redaction +class Redaction { /** * @param array $record @@ -45,7 +48,12 @@ public static function context(array $context): array return $context; } - $redacted = Redactor::profile($profile)->withoutMarkers()->redactSafely($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 : []; } @@ -63,8 +71,14 @@ public static function exception(array $exception): array } foreach (['message', 'previous'] as $key) { - if (isset($exception[$key])) { + if (! isset($exception[$key])) { + continue; + } + + try { $exception[$key] = Redactor::profile($profile)->withoutMarkers()->redactSafely($exception[$key]); + } catch (Throwable) { + $exception[$key] = '[redaction unavailable]'; } } diff --git a/src/Testing/Expectations.php b/src/Testing/Expectations.php index 83b06e4..302f028 100644 --- a/src/Testing/Expectations.php +++ b/src/Testing/Expectations.php @@ -25,7 +25,7 @@ * * The assertions are also plain static methods, for PHPUnit. */ -final class Expectations +class Expectations { /** * The subject an expectation was built on; Pest rebinds the closures below @@ -73,6 +73,9 @@ public static function assertComplete(string $namespace): void Assert::assertEmpty($findings, self::describe($findings, "Incomplete control points in {$namespace}:")); } + /** + * @internal + */ public static function namespaceOf(mixed $value): string { if (! is_string($value) || $value === '') { diff --git a/src/Testing/MonitorFake.php b/src/Testing/MonitorFake.php index 72b893a..ad8fcb2 100644 --- a/src/Testing/MonitorFake.php +++ b/src/Testing/MonitorFake.php @@ -4,18 +4,22 @@ namespace Kirschbaum\Monitor\Testing; +use BackedEnum; use Closure; use Illuminate\Contracts\Container\Container; +use Illuminate\Support\Collection; +use Illuminate\Support\Testing\Fakes\Fake; +use Kirschbaum\Monitor\Contracts\Runner; use Kirschbaum\Monitor\Control; use Kirschbaum\Monitor\Monitor; use Kirschbaum\Monitor\Outcome; -use Kirschbaum\Monitor\Runner; use Kirschbaum\Monitor\Status; +use Kirschbaum\Monitor\Support\PointName; use PHPUnit\Framework\Assert; use Throwable; /** - * Records every outcome and lets a test cann what a point returns or throws. + * Records every outcome and lets a test decide what a point returns or throws. * * Points execute for real through the live runner, so policies, corrections * and escalations run and their outcomes are recorded. A point registered with @@ -23,22 +27,25 @@ * with failing() skips its callback and fails with the exception, which then * goes through the point's own corrections exactly as a real failure would. */ -class MonitorFake extends Monitor implements Runner +class MonitorFake extends Monitor implements Fake, Runner { /** @var list */ - private array $outcomes = []; + protected array $outcomes = []; /** @var array */ - private array $returning = []; + protected array $returning = []; /** @var array */ - private array $failing = []; + protected array $failing = []; - public function __construct(Container $container, private readonly Runner $live) + 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(); @@ -63,10 +70,11 @@ public function execute(Control $control, Closure $callback): Outcome /** * Make the point succeed with this value without running its callback. */ - public function returning(string $point, mixed $value): static + public function returning(string|BackedEnum $point, mixed $value): static { - $this->returning[$point] = $value; - unset($this->failing[$point]); + $name = PointName::of($point); + $this->returning[$name] = $value; + unset($this->failing[$name]); return $this; } @@ -74,164 +82,293 @@ public function returning(string $point, mixed $value): static /** * Make the point's callback throw this, exercising its own corrections. */ - public function failing(string $point, Throwable $exception): static + public function failing(string|BackedEnum $point, Throwable $exception): static { - $this->failing[$point] = $exception; - unset($this->returning[$point]); + $name = PointName::of($point); + $this->failing[$name] = $exception; + unset($this->returning[$name]); return $this; } /** - * @return list + * Get the recorded outcomes, optionally for one point. + * + * @return Collection */ - public function outcomes(?string $point = null): array + public function outcomes(string|BackedEnum|null $point = null): Collection { + $outcomes = new Collection($this->outcomes); + if ($point === null) { - return $this->outcomes; + return $outcomes; } - return array_values(array_filter($this->outcomes, fn (Outcome $o): bool => $o->point === $point)); + $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 = []; } /** - * @param Closure(Outcome): bool|null $callback + * Assert the point ran at least once, optionally with an outcome matching the callback. + * + * @param (Closure(Outcome): bool)|null $callback */ - public function assertRan(string $point, ?Closure $callback = null): static + public function assertRan(string|BackedEnum $point, ?Closure $callback = null): static { - $matching = $this->matching($point, $callback); + $name = PointName::of($point); - Assert::assertNotEmpty($matching, $callback instanceof Closure - ? "Control point [{$point}] ran, but no outcome matched the callback." - : "Control point [{$point}] did not run."); + 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; } - public function assertRanTimes(string $point, int $times): static + /** + * 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 { - $count = count($this->outcomes($point)); + $name = PointName::of($point); - Assert::assertSame($times, $count, "Control point [{$point}] ran {$count} times, expected {$times}."); + 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; } - public function assertNeverRan(string $point): static + /** + * 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 { - Assert::assertEmpty($this->outcomes($point), "Control point [{$point}] ran, but was expected not to."); + $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; } /** - * @param Closure(Outcome): bool|null $callback + * Assert the point succeeded, optionally with an outcome matching the callback. + * + * @param (Closure(Outcome): bool)|null $callback */ - public function assertSucceeded(string $point, ?Closure $callback = null): static + public function assertSucceeded(string|BackedEnum $point, ?Closure $callback = null): static { - $this->assertStatus($point, Status::Succeeded, $callback); + return $this->assertStatus($point, Status::Succeeded, $callback); + } - return $this; + /** + * 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)); } /** - * @param class-string|null $from + * Assert the point did not recover, or not from the given risk class. + * + * @param (Closure(Outcome): bool)|class-string|null $from */ - public function assertRecovered(string $point, ?string $from = null): static + public function assertNotRecovered(string|BackedEnum $point, Closure|string|null $from = null): static { - $this->assertStatus($point, Status::Recovered, $from === null ? null : fn (Outcome $o): bool => $o->recoveredFrom === $from); + return $this->assertNotStatus($point, Status::Recovered, $this->risk($from)); + } - return $this; + /** + * 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)); } /** - * @param class-string|null $with + * Assert the point did not escalate, or not with the given exception class. + * + * @param (Closure(Outcome): bool)|class-string|null $with */ - public function assertEscalated(string $point, ?string $with = null): static + public function assertNotEscalated(string|BackedEnum $point, Closure|string|null $with = null): static { - $this->assertStatus($point, Status::Escalated, $with === null ? null : fn (Outcome $o): bool => $o->exception instanceof $with); + return $this->assertNotStatus($point, Status::Escalated, $this->exception($with)); + } - return $this; + /** + * Assert the point was refused by an open breaker. + */ + public function assertRefused(string|BackedEnum $point): static + { + return $this->assertStatus($point, Status::Refused, null); } - public function assertRefused(string $point): static + /** + * 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 { - $this->assertStatus($point, Status::Refused, null); + $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; } - public function assertRetried(string $point, ?int $times = null): static + /** + * Assert the point was never retried, or never exactly this many times. + */ + public function assertNotRetried(string|BackedEnum $point, ?int $times = null): static { - $this->assertRan($point); - - $matching = $this->matching($point, $times === null - ? fn (Outcome $o): bool => $o->attempts > 1 - : fn (Outcome $o): bool => $o->attempts === $times + 1); + $name = PointName::of($point); - Assert::assertNotEmpty($matching, $times === null - ? "Control point [{$point}] was not retried." - : "Control point [{$point}] was not retried exactly {$times} times."); + 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; } - public function assertLimitBreached(string $point, string $limit): static + /** + * Assert an outcome of the point breached the named limit. + */ + public function assertLimitBreached(string|BackedEnum $point, string $limit): static { - $this->assertRan($point); + $name = PointName::of($point); + $this->assertRan($name); Assert::assertNotEmpty( - $this->matching($point, fn (Outcome $o): bool => $o->breachedLimit($limit)), - "Control point [{$point}] did not breach the [{$limit}] limit.", + $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 = array_map(fn (Outcome $o): string => $o->point, array_filter($this->outcomes, fn (Outcome $o): bool => $o->escalated())); + $escalated = $this->outcomes()->filter(fn (Outcome $o): bool => $o->escalated())->map(fn (Outcome $o): string => $o->point); - Assert::assertEmpty($escalated, 'Control points escalated: '.implode(', ', $escalated).'.'); + Assert::assertEmpty($escalated->all(), 'Control points escalated: '.$escalated->implode(', ').'.'); return $this; } + /** + * Assert no point ran. + */ public function assertNothingRan(): static { - Assert::assertEmpty($this->outcomes, 'Control points ran: '.implode(', ', array_map(fn (Outcome $o): string => $o->point, $this->outcomes)).'.'); + $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 + * @param (Closure(Outcome): bool)|null $callback */ - private function assertStatus(string $point, Status $status, ?Closure $callback): void + protected function assertNotStatus(string|BackedEnum $point, Status $status, ?Closure $callback): static { - $this->assertRan($point); + $name = PointName::of($point); - $matching = $this->matching($point, fn (Outcome $o): bool => $o->status === $status && (! $callback instanceof Closure || $callback($o))); + $matching = $this->matching($name, fn (Outcome $o): bool => $o->status === $status && (! $callback instanceof Closure || $callback($o))); - $seen = implode(', ', array_map(fn (Outcome $o): string => $o->status->value, $this->outcomes($point))); + Assert::assertEmpty($matching, "The control point [{$name}] ended {$status->value}, but was expected not to."); - Assert::assertNotEmpty($matching, "Control point [{$point}] did not end {$status->value}; saw: {$seen}."); + return $this; } /** - * @param Closure(Outcome): bool|null $callback + * @param (Closure(Outcome): bool)|null $callback * @return list */ - private function matching(string $point, ?Closure $callback): array + 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 array_values(array_filter( - $this->outcomes($point), - fn (Outcome $o): bool => ! $callback instanceof Closure || $callback($o), - )); + 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/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 index cab7849..cbb719f 100644 --- a/src/Trace/PicksUpJobTrace.php +++ b/src/Trace/PicksUpJobTrace.php @@ -8,9 +8,9 @@ * A job dispatched from inside a trace carries it through Laravel's Context. * One dispatched from nowhere gets a trace of its own when it starts. */ -final readonly class PicksUpJobTrace +class PicksUpJobTrace { - public function __construct(private Trace $trace) {} + public function __construct(private readonly Trace $trace) {} public function handle(): void { diff --git a/src/Trace/PropagatesTrace.php b/src/Trace/PropagatesTrace.php index cf1162b..953a4d8 100644 --- a/src/Trace/PropagatesTrace.php +++ b/src/Trace/PropagatesTrace.php @@ -4,6 +4,7 @@ namespace Kirschbaum\Monitor\Trace; +use Closure; use Illuminate\Http\Client\Factory; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Config; @@ -12,13 +13,16 @@ * Registers Http::traced() and $pending->traced(), which add the current * trace to an outgoing request as traceparent and the legacy header. */ -final class PropagatesTrace +class PropagatesTrace { - public static function register(Trace $trace): void + /** + * @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(), + 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 { diff --git a/src/Trace/Trace.php b/src/Trace/Trace.php index 2e3910d..cb9dbf0 100644 --- a/src/Trace/Trace.php +++ b/src/Trace/Trace.php @@ -14,7 +14,7 @@ * every log line the application writes. Trace IDs are 32 lowercase hex * characters, the W3C form; a UUID is accepted and normalised to that. */ -final class Trace +class Trace { public const string KEY = 'trace_id'; @@ -74,7 +74,7 @@ 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)); + throw new InvalidTraceId(sprintf('[%s] is not a valid trace ID.', $id)); } Context::add(self::KEY, $normalised); diff --git a/src/Trace/TraceParent.php b/src/Trace/TraceParent.php index c3eea64..e033740 100644 --- a/src/Trace/TraceParent.php +++ b/src/Trace/TraceParent.php @@ -7,7 +7,7 @@ /** * The W3C traceparent header: version-traceid-spanid-flags. */ -final class TraceParent +class TraceParent { private const string PATTERN = '/^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i'; diff --git a/tests/Feature/BreakerTest.php b/tests/Feature/BreakerTest.php index f25ef94..ffadb28 100644 --- a/tests/Feature/BreakerTest.php +++ b/tests/Feature/BreakerTest.php @@ -155,7 +155,7 @@ function stripe(): Control it('starts closed', function (): void { expect(Monitor::breaker()->state('fresh')->isClosed())->toBeTrue() ->and(Monitor::breaker()->isClosed('fresh'))->toBeTrue() - ->and(Monitor::breaker()->attempt('fresh')->allowed)->toBeTrue() + ->and(Monitor::breaker()->permit('fresh')->allowed)->toBeTrue() ->and(Monitor::breaker()->retryAfter('fresh'))->toBe(0); }); diff --git a/tests/Feature/CoverageGapsTest.php b/tests/Feature/EdgesTest.php similarity index 81% rename from tests/Feature/CoverageGapsTest.php rename to tests/Feature/EdgesTest.php index 6cee3ab..7dbe91f 100644 --- a/tests/Feature/CoverageGapsTest.php +++ b/tests/Feature/EdgesTest.php @@ -13,14 +13,16 @@ use Kirschbaum\Monitor\Store\OutcomeStore; use Kirschbaum\Monitor\Support\Lists; use Kirschbaum\Monitor\Support\Profiles; +use Kirschbaum\Monitor\Support\Redaction; use Kirschbaum\Monitor\Testing\Expectations; +use Kirschbaum\Monitor\Testing\MonitorFake; use Kirschbaum\Redactor\Facades\Redactor; use Laravel\Mcp\Transport\JsonRpcResponse; use Tests\Fixtures\Fatal; use Workbench\Monitor\ControlPoints\Filings\Unnamed; /** - * Edges the feature suites do not reach on their own. + * Behaviour at the edges: unusual inputs, missing tables, defensive branches. */ describe('edges', function (): void { it('lets a control change its origin and report escalation presence', function (): void { @@ -143,4 +145,31 @@ 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/FakeTest.php b/tests/Feature/FakeTest.php index a35e7de..8a6a87c 100644 --- a/tests/Feature/FakeTest.php +++ b/tests/Feature/FakeTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\AssertionFailedError; use Tests\Fixtures\CardDeclined; use Tests\Fixtures\Fatal; +use Tests\Fixtures\PointNames; describe('Monitor::fake()', function (): void { it('records outcomes while still running the point', function (): void { @@ -30,12 +31,12 @@ ->assertRanTimes('payment.charge', 1) ->assertSucceeded('payment.charge') ->assertSucceeded('payment.charge', fn (Outcome $o): bool => $o->value === 'charged') - ->assertNeverRan('payment.refund') + ->assertNotRan('payment.refund') ->assertNothingEscalated(); }); it('returns a canned value without running the callback', function (): void { - Monitor::fake(['payment.charge' => 'canned']); + Monitor::fake()->returning('payment.charge', 'canned'); $ran = false; $value = Monitor::control('payment.charge')->run(function () use (&$ran): string { @@ -81,7 +82,7 @@ Monitor::control('payment.charge')->attempt(fn () => throw new Fatal); - expect(fn () => Monitor::assertNeverRan('payment.charge'))->toThrow(AssertionFailedError::class, 'ran, but was expected not to') + 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') @@ -90,7 +91,7 @@ ->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, expected 2'); + ->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 { @@ -105,4 +106,55 @@ $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/InventoryTest.php b/tests/Feature/InventoryTest.php index d176472..be82be0 100644 --- a/tests/Feature/InventoryTest.php +++ b/tests/Feature/InventoryTest.php @@ -146,7 +146,7 @@ function workbenchPath(): string it('adds history from the store when it is enabled', function (): void { config()->set('monitor.records.store.enabled', true); - (require __DIR__.'/../../database/migrations/create_monitor_outcomes_table.php')->up(); + (require glob(__DIR__.'/../../database/migrations/*_create_monitor_outcomes_table.php')[0])->up(); ChargeCard::run(1, 1); resolve(StoreOutcomes::class)->flush(); @@ -175,7 +175,7 @@ function workbenchPath(): string it('shows last seen from the store', function (): void { config()->set('monitor.records.store.enabled', true); - (require __DIR__.'/../../database/migrations/create_monitor_outcomes_table.php')->up(); + (require glob(__DIR__.'/../../database/migrations/*_create_monitor_outcomes_table.php')[0])->up(); ChargeCard::run(1, 1); resolve(StoreOutcomes::class)->flush(); 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 index ac441ad..16aed01 100644 --- a/tests/Feature/McpTest.php +++ b/tests/Feature/McpTest.php @@ -12,7 +12,6 @@ use Kirschbaum\Monitor\Mcp\Tools\ExplainPoint; use Kirschbaum\Monitor\Mcp\Tools\ListPoints; use Kirschbaum\Monitor\Mcp\Tools\RecentOutcomes; -use Kirschbaum\Monitor\MonitorServiceProvider; use Kirschbaum\Monitor\Store\StoreOutcomes; use Laravel\Mcp\Facades\Mcp; use Laravel\Mcp\Transport\JsonRpcResponse; @@ -28,7 +27,7 @@ function enableStoreForMcp(): void { config()->set('monitor.records.store.enabled', true); - (require __DIR__.'/../../database/migrations/create_monitor_outcomes_table.php')->up(); + (require glob(__DIR__.'/../../database/migrations/*_create_monitor_outcomes_table.php')[0])->up(); } describe('server', function (): void { @@ -38,16 +37,10 @@ function enableStoreForMcp(): void MonitorServer::prompts()->assertRegistered([WrapOperation::class]); }); - it('is only registered as a local server when enabled', function (): void { - expect(MonitorServiceProvider::registerMcpServer(app()))->toBeFalse() - ->and(Mcp::getLocalServer('monitor'))->toBeNull(); - - config()->set('monitor.mcp.enabled', true); - config()->set('monitor.mcp.handle', 'monitor'); - - expect(MonitorServiceProvider::registerMcpServer(app()))->toBeTrue() - ->and(Mcp::getLocalServer('monitor'))->not->toBeNull(); + it('is not registered as a local server by default', function (): void { + expect(Mcp::getLocalServer('monitor'))->toBeNull(); }); + }); describe('tools', function (): void { 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/StoreBootTest.php b/tests/Feature/StoreBootTest.php index 038561b..a278a32 100644 --- a/tests/Feature/StoreBootTest.php +++ b/tests/Feature/StoreBootTest.php @@ -6,10 +6,12 @@ use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Migrations\Migrator; +use Illuminate\Support\ServiceProvider; +use Kirschbaum\Monitor\MonitorServiceProvider; use Tests\TestCase; /** - * The provider only loads the migration when the store is enabled at boot. + * The store migration is published, never loaded behind the application's back. */ final class StoreBootTest extends TestCase { @@ -19,10 +21,16 @@ protected function defineEnvironment($app): void $app['config']->set('monitor.records.store.enabled', true); } - public function test_the_migration_path_is_registered_when_the_store_is_enabled(): void + public function test_the_migration_is_publishable_but_not_auto_loaded(): void { $paths = $this->app->make(Migrator::class)->paths(); - $this->assertNotEmpty(array_filter($paths, fn (string $p): bool => str_ends_with($p, 'database/migrations'))); + $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 index 765d23b..6a474df 100644 --- a/tests/Feature/StoreTest.php +++ b/tests/Feature/StoreTest.php @@ -2,15 +2,21 @@ declare(strict_types=1); +use Illuminate\Contracts\Http\Kernel; use Illuminate\Contracts\Queue\Job; +use Illuminate\Http\Response; use Illuminate\Queue\Events\JobProcessed; +use Illuminate\Queue\Events\Looping; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Route; use Illuminate\Support\Sleep; -use Kirschbaum\Monitor\Console\OutcomesCommand; +use Kirschbaum\Monitor\Console\Commands\OutcomesCommand; use Kirschbaum\Monitor\Facades\Monitor; use Kirschbaum\Monitor\Store\OutcomeStore; use Kirschbaum\Monitor\Store\StoreOutcomes; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\NullOutput; use Tests\Fixtures\CardDeclined; use Tests\Fixtures\Fatal; use TiMacDonald\Log\LogFake; @@ -18,7 +24,7 @@ function enableStore(): void { config()->set('monitor.records.store.enabled', true); - (require __DIR__.'/../../database/migrations/create_monitor_outcomes_table.php')->up(); + (require glob(__DIR__.'/../../database/migrations/*_create_monitor_outcomes_table.php')[0])->up(); } describe('outcome store', function (): void { @@ -38,7 +44,7 @@ function enableStore(): void expect(resolve(StoreOutcomes::class)->pending())->toBe(4)->and(DB::table('monitor_outcomes')->count())->toBe(0); - app()->terminate(); + resolve(StoreOutcomes::class)->flush(); expect(resolve(StoreOutcomes::class)->pending())->toBe(0)->and(DB::table('monitor_outcomes')->count())->toBe(4); @@ -60,6 +66,39 @@ function enableStore(): void ->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(); diff --git a/tests/Feature/TracePropagationTest.php b/tests/Feature/TracePropagationTest.php index e8d5571..7e90534 100644 --- a/tests/Feature/TracePropagationTest.php +++ b/tests/Feature/TracePropagationTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Console\Events\CommandStarting; use Illuminate\Http\Client\Request as ClientRequest; use Illuminate\Queue\Events\JobProcessing; use Illuminate\Support\Facades\Http; @@ -9,8 +10,11 @@ use Illuminate\Support\Facades\Route; use Kirschbaum\Monitor\Facades\Monitor; use Kirschbaum\Monitor\Http\Middleware\StartTrace; +use Kirschbaum\Monitor\Trace\PicksUpCommandTrace; use Kirschbaum\Monitor\Trace\PicksUpJobTrace; use Kirschbaum\Monitor\Trace\Trace; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\NullOutput; use TiMacDonald\Log\LogFake; beforeEach(function (): void { @@ -92,6 +96,23 @@ }); }); +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(); @@ -127,6 +148,6 @@ $this->get('/guarded')->assertOk(); - expect(Monitor::breaker()->attempt('stripe')->probe)->toBeTrue(); + expect(Monitor::breaker()->permit('stripe')->probe)->toBeTrue(); }); }); diff --git a/tests/Fixtures/BrokenEscalation.php b/tests/Fixtures/BrokenEscalation.php index b8012fa..7976137 100644 --- a/tests/Fixtures/BrokenEscalation.php +++ b/tests/Fixtures/BrokenEscalation.php @@ -4,7 +4,7 @@ namespace Tests\Fixtures; -use Kirschbaum\Monitor\Escalations\Escalation; +use Kirschbaum\Monitor\Contracts\Escalation; use Kirschbaum\Monitor\Outcome; use LogicException; diff --git a/tests/Fixtures/PagePayments.php b/tests/Fixtures/PagePayments.php index cd14608..d95656a 100644 --- a/tests/Fixtures/PagePayments.php +++ b/tests/Fixtures/PagePayments.php @@ -4,7 +4,7 @@ namespace Tests\Fixtures; -use Kirschbaum\Monitor\Escalations\Escalation; +use Kirschbaum\Monitor\Contracts\Escalation; use Kirschbaum\Monitor\Outcome; final class PagePayments implements Escalation diff --git a/tests/Fixtures/PointNames.php b/tests/Fixtures/PointNames.php new file mode 100644 index 0000000..e5b31af --- /dev/null +++ b/tests/Fixtures/PointNames.php @@ -0,0 +1,11 @@ + Date: Tue, 15 Sep 2026 00:39:48 +0200 Subject: [PATCH 10/13] Say that a breaker is only as shared as its cache store --- docs/breakers.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/breakers.md b/docs/breakers.md index e772990..4e04f5a 100644 --- a/docs/breakers.md +++ b/docs/breakers.md @@ -39,6 +39,8 @@ Failures older than the window are forgotten, so five failures spread over an ho 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'), From b415b76db9ba0f116fa5c04b9ec6aa7161c8c160 Mon Sep 17 00:00:00 2001 From: Belisar Hoxholli Date: Tue, 15 Sep 2026 01:07:01 +0200 Subject: [PATCH 11/13] Add once policy, class corrections, Http breaker, dispatchable points --- config/monitor.php | 15 +++ ...4_000000_create_monitor_outcomes_table.php | 1 + src/Contracts/Correction.php | 21 ++++ src/Contracts/Policy.php | 2 + src/Control.php | 70 ++++++++++- src/ControlPoint.php | 19 +++ src/ControlStack.php | 42 ++++++- src/Events/EscalationThrottled.php | 16 +++ src/Facades/Monitor.php | 2 +- src/Http/HttpBreaker.php | 91 ++++++++++++++ src/Monitor.php | 4 +- src/MonitorServiceProvider.php | 4 +- src/Outcome.php | 5 + src/Policies/Once.php | 75 +++++++++++ src/Queue/Middleware/WaitForBreaker.php | 44 +++++++ src/Queue/RunControlPoint.php | 75 +++++++++++ src/Risks/Duplicate.php | 17 +++ src/Run.php | 39 +++++- src/Store/OutcomeStore.php | 3 +- src/Trace/PicksUpJobTrace.php | 9 +- .../Feature/CorrectionsAndEscalationTest.php | 96 +++++++++++++++ tests/Feature/HttpBreakerTest.php | 68 ++++++++++ tests/Feature/OncePolicyTest.php | 83 +++++++++++++ tests/Feature/QueueTest.php | 116 ++++++++++++++++++ tests/Unit/ControlStackTest.php | 2 +- 25 files changed, 898 insertions(+), 21 deletions(-) create mode 100644 src/Contracts/Correction.php create mode 100644 src/Events/EscalationThrottled.php create mode 100644 src/Http/HttpBreaker.php create mode 100644 src/Policies/Once.php create mode 100644 src/Queue/Middleware/WaitForBreaker.php create mode 100644 src/Queue/RunControlPoint.php create mode 100644 src/Risks/Duplicate.php create mode 100644 tests/Feature/CorrectionsAndEscalationTest.php create mode 100644 tests/Feature/HttpBreakerTest.php create mode 100644 tests/Feature/OncePolicyTest.php create mode 100644 tests/Feature/QueueTest.php diff --git a/config/monitor.php b/config/monitor.php index 46226d5..643756e 100644 --- a/config/monitor.php +++ b/config/monitor.php @@ -163,6 +163,21 @@ 'for' => 120, ], + /* + |-------------------------------------------------------------------------- + | Once + |-------------------------------------------------------------------------- + | + | 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. + | + */ + + 'once' => [ + 'store' => env('MONITOR_ONCE_STORE'), + 'prefix' => 'monitor:once:', + ], + /* |-------------------------------------------------------------------------- | Records 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 index 9aaacc2..b2f8542 100644 --- a/database/migrations/2026_09_14_000000_create_monitor_outcomes_table.php +++ b/database/migrations/2026_09_14_000000_create_monitor_outcomes_table.php @@ -37,6 +37,7 @@ public function up(): void $table->json('limits_breached'); $table->json('policies'); $table->json('timeline'); + $table->timestamp('started_at', 3); $table->timestamp('ended_at', 3)->index(); }); } 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 @@ + */ private array $ensures = []; - /** @var list, handler: Closure}> */ + /** @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; @@ -190,6 +196,17 @@ public function breaker(string $name, ?int $after = null, ?int $within = null, ? 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. @@ -237,14 +254,18 @@ public function ensure(Closure $check, string $reason = 'result did not satisfy * 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 $handler + * @param (Closure(Throwable, Outcome): mixed)|string $handler a closure, or a Correction class name */ - public function recover(string $class, Closure $handler): self + 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; @@ -266,10 +287,45 @@ public function escalate(Closure|string $escalation): self 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. * - * @param Closure(): mixed $callback + * @template T + * + * @param Closure(): T $callback + * @return T */ public function run(Closure $callback): mixed { @@ -329,7 +385,7 @@ public function ensures(): array } /** - * @return list, handler: Closure}> + * @return list, handler: Closure|class-string}> */ public function risks(): array { @@ -394,8 +450,11 @@ public function describe(): array '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, ]; } @@ -414,6 +473,7 @@ private function policyKey(Policy $policy): string $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 index 1babd4c..43b736a 100644 --- a/src/ControlPoint.php +++ b/src/ControlPoint.php @@ -5,8 +5,10 @@ namespace Kirschbaum\Monitor; use Illuminate\Container\Container; +use Illuminate\Foundation\Bus\PendingDispatch; use Kirschbaum\Monitor\Attributes\Point; use Kirschbaum\Monitor\Exceptions\InvalidControlPoint; +use Kirschbaum\Monitor\Queue\RunControlPoint; use ReflectionClass; use Throwable; @@ -69,6 +71,23 @@ public static function attempt(mixed ...$arguments): Outcome 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. */ diff --git a/src/ControlStack.php b/src/ControlStack.php index 0e07e65..2a60aa7 100644 --- a/src/ControlStack.php +++ b/src/ControlStack.php @@ -19,10 +19,12 @@ class ControlStack public const string CURRENT = 'control_point'; - public function push(string $point, string $runId): void + public const string DISPATCHED_FROM = 'dispatched_from_run'; + + public function push(string $point, string $runId, ?string $origin = null): void { $stack = $this->all(); - $stack[] = ['point' => $point, 'run_id' => $runId]; + $stack[] = ['point' => $point, 'run_id' => $runId, 'origin' => $origin]; Context::addHidden(self::KEY, $stack); Context::add(self::CURRENT, $point); @@ -45,7 +47,7 @@ public function pop(): void } /** - * @return array{point: string, run_id: string}|null + * @return array{point: string, run_id: string, origin: string|null}|null */ public function current(): ?array { @@ -78,7 +80,37 @@ public function names(): array } /** - * @return list + * 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 { @@ -92,7 +124,7 @@ public function all(): array 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']]; + $entries[] = ['point' => $entry['point'], 'run_id' => $entry['run_id'], 'origin' => is_string($entry['origin'] ?? null) ? $entry['origin'] : null]; } } diff --git a/src/Events/EscalationThrottled.php b/src/Events/EscalationThrottled.php new file mode 100644 index 0000000..7cd949e --- /dev/null +++ b/src/Events/EscalationThrottled.php @@ -0,0 +1,16 @@ + outcomes(string|\BackedEnum|null $point = null) 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/Monitor.php b/src/Monitor.php index 8c725b6..9af1db9 100644 --- a/src/Monitor.php +++ b/src/Monitor.php @@ -43,8 +43,8 @@ public function breaker(): CircuitBreaker /** * A PSR-3 logger bound to an origin, so its records carry origin and domain. */ - public function log(string|object $origin): StructuredLogger + public function log(string|object|null $origin = null): StructuredLogger { - return new StructuredLogger($origin); + return new StructuredLogger($origin ?? $this->stack()->currentOrigin() ?? Monitor::class); } } diff --git a/src/MonitorServiceProvider.php b/src/MonitorServiceProvider.php index 0ac11c3..758a4bf 100644 --- a/src/MonitorServiceProvider.php +++ b/src/MonitorServiceProvider.php @@ -22,6 +22,7 @@ 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; @@ -162,11 +163,12 @@ protected function registerListeners(): void } /** - * Register Http::traced() and $pendingRequest->traced(). + * 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)); } /** diff --git a/src/Outcome.php b/src/Outcome.php index 82ab36d..6241d50 100644 --- a/src/Outcome.php +++ b/src/Outcome.php @@ -4,6 +4,7 @@ namespace Kirschbaum\Monitor; +use Carbon\CarbonImmutable; use Illuminate\Contracts\Support\Arrayable; use JsonSerializable; use Throwable; @@ -44,6 +45,8 @@ public function __construct( public array $context, public array $timeline, public array $stack, + public CarbonImmutable $startedAt, + public CarbonImmutable $endedAt, ) {} public function succeeded(): bool @@ -122,6 +125,8 @@ public function toArray(): array 'policies' => $this->policies, 'context' => $this->context, 'stack' => $this->stack, + 'started_at' => $this->startedAt->toIso8601String(), + 'ended_at' => $this->endedAt->toIso8601String(), ]; } 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/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/Risks/Duplicate.php b/src/Risks/Duplicate.php new file mode 100644 index 0000000..72de0a5 --- /dev/null +++ b/src/Risks/Duplicate.php @@ -0,0 +1,17 @@ +info; + } + public function attempt(): int { return $this->attempt; @@ -118,10 +132,11 @@ public function note(string $event, array $detail = []): void 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->stack->push($this->control->name(), $id, $this->control->origin()); $this->info = new RunInfo( point: $this->control->name(), @@ -178,7 +193,14 @@ private function handle(Throwable $e): Outcome $this->note('recovering', ['risk' => $risk['class']]); try { - $value = $risk['handler']($e, $this->outcome(Status::Escalated, null, $e, null)); + $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]); @@ -221,7 +243,7 @@ private function dispatchEnd(Outcome $outcome): void Status::Succeeded => null, }; - if ($outcome->escalated()) { + if ($outcome->escalated() || ($this->control->escalatesLimits() && $outcome->limitsBreached !== [])) { $this->notifyEscalation($outcome); } @@ -236,6 +258,15 @@ private function notifyEscalation(Outcome $outcome): void 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); @@ -289,6 +320,8 @@ private function outcome(Status $status, mixed $value, ?Throwable $exception, ?s context: $this->info->context, timeline: $this->timeline, stack: $this->info->stack, + startedAt: $this->startedAtClock, + endedAt: CarbonImmutable::now(), ); } diff --git a/src/Store/OutcomeStore.php b/src/Store/OutcomeStore.php index 6d57a43..f39b735 100644 --- a/src/Store/OutcomeStore.php +++ b/src/Store/OutcomeStore.php @@ -183,7 +183,8 @@ protected function row(Outcome $outcome): array 'limits_breached' => json_encode($outcome->limitsBreached), 'policies' => json_encode($outcome->policies), 'timeline' => json_encode($outcome->timeline), - 'ended_at' => Date::now(), + 'started_at' => $outcome->startedAt, + 'ended_at' => $outcome->endedAt, ]; } diff --git a/src/Trace/PicksUpJobTrace.php b/src/Trace/PicksUpJobTrace.php index cbb719f..b12ce33 100644 --- a/src/Trace/PicksUpJobTrace.php +++ b/src/Trace/PicksUpJobTrace.php @@ -4,16 +4,21 @@ namespace Kirschbaum\Monitor\Trace; +use Kirschbaum\Monitor\ControlStack; + /** * A job dispatched from inside a trace carries it through Laravel's Context. - * One dispatched from nowhere gets a trace of its own when it starts. + * One dispatched from nowhere gets a trace of its own when it starts. The + * control stack it inherited belongs to the dispatching process: it is handed + * off, so the job starts with no parent and a dispatched_from run id instead. */ class PicksUpJobTrace { - public function __construct(private readonly Trace $trace) {} + public function __construct(protected Trace $trace, protected ControlStack $stack) {} public function handle(): void { $this->trace->pickup(); + $this->stack->handOff(); } } diff --git a/tests/Feature/CorrectionsAndEscalationTest.php b/tests/Feature/CorrectionsAndEscalationTest.php new file mode 100644 index 0000000..35d2c45 --- /dev/null +++ b/tests/Feature/CorrectionsAndEscalationTest.php @@ -0,0 +1,96 @@ + $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 { + Event::fake([EscalationThrottled::class]); + $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']); + Event::assertDispatchedTimes(EscalationThrottled::class, 1); + Event::assertDispatched(EscalationThrottled::class, fn (EscalationThrottled $e): bool => $e->seconds === 600 && $e->outcome->timeline[array_key_last($e->outcome->timeline)]['event'] !== 'escalation.throttled'); + + $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/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/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/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/Unit/ControlStackTest.php b/tests/Unit/ControlStackTest.php index acd4a8c..0e7a8d4 100644 --- a/tests/Unit/ControlStackTest.php +++ b/tests/Unit/ControlStackTest.php @@ -32,7 +32,7 @@ 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']]); + expect($stack->all())->toBe([['point' => 'ok.one', 'run_id' => 'r', 'origin' => null]]); $stack->clear(); From 3cefbeb3e0ae2df528c2e4e16e56b3564d769bef Mon Sep 17 00:00:00 2001 From: Belisar Hoxholli Date: Tue, 15 Sep 2026 01:11:20 +0200 Subject: [PATCH 12/13] Document the additions, record throttled escalations, add jobs page --- .ai/guidelines/core.blade.php | 4 +- CHANGELOG.md | 33 +++++++ README.md | 1 + config/monitor.php | 1 + docs/README.md | 1 + docs/breakers.md | 15 ++++ docs/configuration.md | 11 +++ docs/control-points.md | 11 ++- docs/extending.md | 27 ++++++ docs/getting-started.md | 1 + docs/jobs.md | 86 +++++++++++++++++++ docs/policies-and-limits.md | 22 ++++- docs/records.md | 6 +- docs/risks-and-corrections.md | 61 ++++++++++++- docs/store.md | 3 +- docs/testing.md | 4 + docs/tracing.md | 2 +- .../boost/skills/monitor-development/SKILL.md | 4 +- resources/schema/record-1.json | 3 +- src/Records/Record.php | 12 +++ src/Records/Recorder.php | 7 ++ src/Store/OutcomeStore.php | 2 +- .../Feature/CorrectionsAndEscalationTest.php | 9 +- 23 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 docs/jobs.md diff --git a/.ai/guidelines/core.blade.php b/.ai/guidelines/core.blade.php index cb902cd..93181b3 100644 --- a/.ai/guidelines/core.blade.php +++ b/.ai/guidelines/core.blade.php @@ -35,6 +35,8 @@ public function handle(StripeClient $stripe): ChargeResult { return $stripe->cha 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. @@ -44,7 +46,7 @@ public function handle(StripeClient $stripe): ChargeResult { return $stripe->cha ### Risks and corrections -`recover(SomeException::class, fn ($e, Outcome $partial) => $value)` declares an expected failure and what to return instead. 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. +`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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b16a8..2953b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,39 @@ lists every 0.1 surface and its replacement. - **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 diff --git a/README.md b/README.md index 02a3e9e..bc87d8f 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ The full documentation lives in [`docs/`](docs/README.md): | [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`. | diff --git a/config/monitor.php b/config/monitor.php index 643756e..f4a57f0 100644 --- a/config/monitor.php +++ b/config/monitor.php @@ -220,6 +220,7 @@ 'point.escalated' => 'error', 'point.ended' => 'info', 'escalation.failed' => 'critical', + 'escalation.throttled' => 'notice', 'breaker.opened' => 'error', 'breaker.half_open' => 'notice', 'breaker.closed' => 'info', diff --git a/docs/README.md b/docs/README.md index 9f80030..1e1d144 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,7 @@ Everything else can be read as you need it. | [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`. | diff --git a/docs/breakers.md b/docs/breakers.md index 4e04f5a..265b097 100644 --- a/docs/breakers.md +++ b/docs/breakers.md @@ -4,6 +4,7 @@ - [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) @@ -74,6 +75,20 @@ Monitor::control('payment.charge', $this) 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. diff --git a/docs/configuration.md b/docs/configuration.md index 7c7298c..312fdf4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,6 +9,7 @@ - [profiles](#profiles) - [trace](#trace) - [breakers](#breakers) +- [once](#once) - [records](#records) - [records.levels](#recordslevels) - [records.store](#recordsstore) @@ -96,6 +97,15 @@ Defaults for circuits used without explicit numbers, including the standalone `M | `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 | @@ -152,6 +162,7 @@ Event names contain dots, so set the whole array rather than one nested 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` | diff --git a/docs/control-points.md b/docs/control-points.md index 970dfad..7d44c59 100644 --- a/docs/control-points.md +++ b/docs/control-points.md @@ -64,13 +64,14 @@ $control = Monitor::control('payment.charge', $this); | `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(...)`, `policy(Policy $policy)` | Policies; see [Policies and Limits](policies-and-limits.md). | +| `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 $handler)` | Declare a risk and its correction; see [Risks and Corrections](risks-and-corrections.md). | +| `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. | -| `run(Closure $callback)` | Execute; return the value or throw what escaped. | +| `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, whether there is a catch-all, and the escalation as a class name, `'closure'` or `null`. | +| `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. @@ -143,6 +144,8 @@ final class ChargeCard extends ControlPoint | --- | --- | | `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. | diff --git a/docs/extending.md b/docs/extending.md index 858c8b8..32b91e9 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -3,6 +3,7 @@ - [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) @@ -109,6 +110,32 @@ class PagePayments implements Escalation 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). diff --git a/docs/getting-started.md b/docs/getting-started.md index d13ec3f..aba154c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -93,6 +93,7 @@ Both produce the same `Outcome`, the same events and the same records. | `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. | 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 index ee49b70..bbe92bf 100644 --- a/docs/policies-and-limits.md +++ b/docs/policies-and-limits.md @@ -5,6 +5,7 @@ - [Retry](#retry) - [Transaction](#transaction) - [Breaker](#breaker) + - [Once](#once) - [Pipeline Order](#pipeline-order) - [policy()](#policy) - [Limits](#limits) @@ -69,9 +70,28 @@ use Kirschbaum\Monitor\Policies\Breaker; 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: `Breaker` is 100, `Retry` 200, `Transaction` 300. Declaration order does not matter. `Control::resolvedPolicies()` returns the effective list in 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() diff --git a/docs/records.md b/docs/records.md index 5a49e57..f184ed5 100644 --- a/docs/records.md +++ b/docs/records.md @@ -53,6 +53,7 @@ A record is the context array of one log line. The `Recorder` subscribes to Moni | `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. @@ -191,7 +192,7 @@ 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. The line is written as: +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"} @@ -224,11 +225,12 @@ Every record is written by a listener, and the events are public. Alerting, metr | `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). `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: +`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; diff --git a/docs/risks-and-corrections.md b/docs/risks-and-corrections.md index 51c9cc3..a883b26 100644 --- a/docs/risks-and-corrections.md +++ b/docs/risks-and-corrections.md @@ -6,9 +6,12 @@ - [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) @@ -85,6 +88,30 @@ A handler that throws escalates with the exception it threw; the original failur 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. @@ -130,13 +157,36 @@ class PagePayments implements Escalation 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 -Two 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. +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: @@ -155,3 +205,12 @@ Two failures come from the package rather than from the operation. Both extend ` ``` 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 index 8f55240..3c51921 100644 --- a/docs/store.md +++ b/docs/store.md @@ -65,7 +65,8 @@ Publishing copies the migration into `database/migrations` with a timestamp, the | `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. | -| `ended_at` | timestamp(3), indexed | When the row was written. | +| `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). diff --git a/docs/testing.md b/docs/testing.md index 3f11ca1..2325c93 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -119,6 +119,10 @@ expect(Monitor::breaker()->isOpen('stripe'))->toBeFalse(); **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 diff --git a/docs/tracing.md b/docs/tracing.md index 3fc88ce..b0615ee 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -105,7 +105,7 @@ The macro exists on the factory (`Http::traced()`) and on a pending request (`-> 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()`. +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 diff --git a/resources/boost/skills/monitor-development/SKILL.md b/resources/boost/skills/monitor-development/SKILL.md index cfb82f7..f9583ec 100644 --- a/resources/boost/skills/monitor-development/SKILL.md +++ b/resources/boost/skills/monitor-development/SKILL.md @@ -46,6 +46,8 @@ 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. @@ -55,7 +57,7 @@ Rules that `php artisan monitor:points --check` enforces: ### Risks and corrections -`recover(SomeException::class, fn ($e, Outcome $partial) => $value)` declares an expected failure and what to return instead. 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. +`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 diff --git a/resources/schema/record-1.json b/resources/schema/record-1.json index b9f2c66..a34a818 100644 --- a/resources/schema/record-1.json +++ b/resources/schema/record-1.json @@ -10,7 +10,7 @@ "event": { "enum": [ "point.started", "point.retried", "point.limit", "point.recovered", "point.escalated", "point.refused", "point.ended", - "escalation.failed", "breaker.opened", "breaker.half_open", "breaker.closed" + "escalation.failed", "escalation.throttled", "breaker.opened", "breaker.half_open", "breaker.closed" ] }, "point": { "type": "string", "description": "The control point name, e.g. payment.charge." }, @@ -53,6 +53,7 @@ } }, "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": { diff --git a/src/Records/Record.php b/src/Records/Record.php index 0cfc2e0..0c11665 100644 --- a/src/Records/Record.php +++ b/src/Records/Record.php @@ -6,6 +6,7 @@ use Kirschbaum\Monitor\Breaker\BreakerState; use Kirschbaum\Monitor\Events\EscalationFailed; +use Kirschbaum\Monitor\Events\EscalationThrottled; use Kirschbaum\Monitor\Events\PointLimitBreached; use Kirschbaum\Monitor\Events\PointRetried; use Kirschbaum\Monitor\Outcome; @@ -115,6 +116,17 @@ public static function escalationFailed(EscalationFailed $event): array ]; } + /** + * @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 */ diff --git a/src/Records/Recorder.php b/src/Records/Recorder.php index e7ed37e..beb50b8 100644 --- a/src/Records/Recorder.php +++ b/src/Records/Recorder.php @@ -10,6 +10,7 @@ use Kirschbaum\Monitor\Events\BreakerHalfOpen; use Kirschbaum\Monitor\Events\BreakerOpened; use Kirschbaum\Monitor\Events\EscalationFailed; +use Kirschbaum\Monitor\Events\EscalationThrottled; use Kirschbaum\Monitor\Events\PointEnded; use Kirschbaum\Monitor\Events\PointEscalated; use Kirschbaum\Monitor\Events\PointLimitBreached; @@ -40,6 +41,7 @@ public function subscribe(): array PointRefused::class => 'refused', PointEnded::class => 'ended', EscalationFailed::class => 'escalationFailed', + EscalationThrottled::class => 'escalationThrottled', BreakerOpened::class => 'breakerOpened', BreakerHalfOpen::class => 'breakerHalfOpen', BreakerClosed::class => 'breakerClosed', @@ -86,6 +88,11 @@ 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)); diff --git a/src/Store/OutcomeStore.php b/src/Store/OutcomeStore.php index f39b735..961b668 100644 --- a/src/Store/OutcomeStore.php +++ b/src/Store/OutcomeStore.php @@ -55,7 +55,7 @@ public function write(array $outcomes): int $rows = array_map($this->row(...), $outcomes); - $this->query()->upsert($rows, ['run_id'], ['status', 'attempts', 'duration_ms', 'ended_at']); + $this->query()->upsert($rows, ['run_id'], ['status', 'attempts', 'duration_ms', 'started_at', 'ended_at']); return count($rows); } diff --git a/tests/Feature/CorrectionsAndEscalationTest.php b/tests/Feature/CorrectionsAndEscalationTest.php index 35d2c45..1819c99 100644 --- a/tests/Feature/CorrectionsAndEscalationTest.php +++ b/tests/Feature/CorrectionsAndEscalationTest.php @@ -3,13 +3,13 @@ declare(strict_types=1); use Illuminate\Contracts\Config\Repository; -use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Log; use Kirschbaum\Monitor\Contracts\Correction; -use Kirschbaum\Monitor\Events\EscalationThrottled; use Kirschbaum\Monitor\Exceptions\InvalidControlPoint; use Kirschbaum\Monitor\Facades\Monitor; use Kirschbaum\Monitor\Outcome; use Tests\Fixtures\CardDeclined; +use TiMacDonald\Log\LogFake; use Workbench\Monitor\ControlPoints\Escalations\PagePayments; final class DeclineHandler implements Correction @@ -55,7 +55,7 @@ public function __invoke(Throwable $exception, Outcome $outcome): mixed describe('throttled escalation', function (): void { it('escalates at most once per window per point', function (): void { - Event::fake([EscalationThrottled::class]); + LogFake::bind(); $control = fn () => Monitor::control('payment.charge')->escalate(PagePayments::class)->throttleEscalation(600); $control()->attempt(fn () => throw new CardDeclined); @@ -64,8 +64,7 @@ public function __invoke(Throwable $exception, Outcome $outcome): mixed expect(PagePayments::$paged)->toHaveCount(2) ->and(array_map(fn (Outcome $o): string => $o->point, PagePayments::$paged))->toBe(['payment.charge', 'payment.refund']); - Event::assertDispatchedTimes(EscalationThrottled::class, 1); - Event::assertDispatched(EscalationThrottled::class, fn (EscalationThrottled $e): bool => $e->seconds === 600 && $e->outcome->timeline[array_key_last($e->outcome->timeline)]['event'] !== 'escalation.throttled'); + 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); From 9e5ed067c13d62abdf2e4cd92f87caba01d5932a Mon Sep 17 00:00:00 2001 From: Belisar Hoxholli Date: Tue, 15 Sep 2026 01:33:11 +0200 Subject: [PATCH 13/13] Name idempotency, the HTTP breaker and queues in the README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bc87d8f..bb25a69 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,8 @@ An **inventory** reads the codebase without running it: `monitor:points` lists e ## What It Covers - **Critical operations** through `Monitor::control()` inline or `ControlPoint` classes, with typed outcomes. -- **Resilience** through retry with backoff, whole-transaction retry on deadlock, and a closed/open/half-open circuit breaker shared across processes, also usable standalone and as the `CheckBreakers` route middleware. +- **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.