diff --git a/.gitattributes b/.gitattributes index 0243e75..e0e6265 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,4 @@ /phpcs.xml export-ignore /.github export-ignore /Makefile export-ignore +/packages/laravel export-ignore diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 4fb1eff..be882bd 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -14,6 +14,8 @@ on: - "phpcs.xml" - "phpunit.xml" - "api/public-api.json" + - "api/laravel-public-api.json" + - "packages/laravel/composer.json" - "scripts/**" - ".github/workflows/php.yml" @@ -70,6 +72,15 @@ jobs: - name: Check public API snapshot run: composer api:check + - name: Install Laravel dependencies + run: composer install --working-dir=packages/laravel --prefer-dist --no-progress + + - name: Check Laravel public API snapshot + run: composer api:check:laravel + + - name: Run Laravel tests + run: composer --working-dir=packages/laravel test + phpunit: needs: detect-markdown-only runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d22535a..cff303c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ Run the test command used in CI: XDEBUG_MODE=coverage ./vendor/bin/phpunit --bootstrap vendor/autoload.php --configuration phpunit.xml --coverage-text ``` -The PHPUnit transport tests use a loopback HTTP server, not live PostHog endpoints. They require the cURL extension, the `curl` and `gzip` commands, and permission to start PHP subprocesses and listen on loopback ports. +The PHPUnit transport tests use a loopback HTTP server, not live PostHog endpoints. They require the cURL extension, the `curl` and `gzip` commands, and permission to start PHP subprocesses and listen on loopback ports. Distribution archive tests also require the `git` and `tar` commands. Run the adapter fidelity and report checker tests (Python standard library only): @@ -60,6 +60,15 @@ This section is for external contributors. PostHog maintainers (members of the P `composer api:update` regenerates `api/public-api.json`, and CI runs `composer api:check` to catch an outdated snapshot. A diff in that file means your change touches public API. +The Laravel package has a separate snapshot in `api/laravel-public-api.json`. From the repository root: + +```bash +composer install --working-dir=packages/laravel --prefer-dist --no-progress +composer api:check:laravel +``` + +After agreeing on a Laravel API change, run `composer api:update:laravel` and inspect the generated diff. CI checks both snapshots. The Laravel snapshot excludes `@internal` classes and members; framework-required public methods do not automatically become supported customer APIs. Supported client forwarders are declared explicitly in the Laravel manager, so their signatures are tracked without exposing future core methods automatically. + ## Pull requests Please follow the existing project conventions and include tests when you change behavior. diff --git a/RELEASING.md b/RELEASING.md index cd92751..173978a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -43,6 +43,10 @@ The pending change intents determine the next version: When multiple intents are pending, the workflow uses the largest requested bump. +## Composer distribution archives + +The root `.gitattributes` excludes `packages/laravel/` from the core Composer distribution. Laravel source stays in Git and has its own publishing flow. Tools that need the complete repository must use a Git checkout, not the core distribution archive. In particular, a whole-repository `git archive` no longer includes Laravel source or tests. + ## Troubleshooting ### No releasable change intents found diff --git a/api/laravel-public-api.json b/api/laravel-public-api.json new file mode 100644 index 0000000..cab0033 --- /dev/null +++ b/api/laravel-public-api.json @@ -0,0 +1,1344 @@ +{ + "schemaVersion": 1, + "namespace": "PostHog\\Laravel\\", + "symbols": { + "PostHog\\Laravel\\Concerns\\IsolatesCommandContext": { + "type": "trait", + "abstract": false, + "final": false, + "readonly": false, + "extends": null, + "implements": [], + "constants": [], + "properties": [], + "methods": [] + }, + "PostHog\\Laravel\\Context": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": null, + "implements": [], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "app", + "type": "Illuminate\\Contracts\\Container\\Container", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "begin": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "data", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "data": { + "static": false, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [] + }, + "end": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + }, + "snapshot": { + "static": false, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [] + }, + "with": { + "static": false, + "abstract": false, + "final": false, + "returnType": "mixed", + "parameters": [ + { + "name": "data", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "callback", + "type": "callable", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + } + } + }, + "PostHog\\Laravel\\Facades\\PostHog": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": "Illuminate\\Support\\Facades\\Facade", + "implements": [], + "constants": [], + "properties": [], + "methods": { + "fake": { + "static": true, + "abstract": false, + "final": false, + "returnType": "PostHog\\Laravel\\Testing\\PostHogFake", + "parameters": [] + } + } + }, + "PostHog\\Laravel\\FlagDefinitionCacheProvider": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": null, + "implements": [ + "PostHog\\FlagDefinitionCacheProvider" + ], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "cache", + "type": "Illuminate\\Contracts\\Cache\\Repository", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "ttl", + "type": "int", + "byReference": false, + "variadic": false, + "optional": true, + "default": 300, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "refreshInterval", + "type": "int", + "byReference": false, + "variadic": false, + "optional": true, + "default": 30, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "getFlagDefinitions": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?array", + "parameters": [] + }, + "onFlagDefinitionsReceived": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "data", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "shouldFetchFlagDefinitions": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + }, + "shutdown": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + } + } + }, + "PostHog\\Laravel\\PostHog": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": null, + "implements": [], + "constants": { + "VERSION": { + "type": "string", + "value": "" + } + }, + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "app", + "type": "Illuminate\\Contracts\\Container\\Container", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "alias": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [ + { + "name": "message", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "capture": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [ + { + "name": "event", + "type": "array|string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "properties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "captureException": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [ + { + "name": "exception", + "type": "Throwable|string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "properties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "client": { + "static": false, + "abstract": false, + "final": false, + "returnType": "PostHog\\Client", + "parameters": [] + }, + "contextFromHeaders": { + "static": false, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [ + { + "name": "headers", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "enabled": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + }, + "enterContext": { + "static": false, + "abstract": false, + "final": false, + "returnType": "PostHog\\ContextScope", + "parameters": [ + { + "name": "data", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "evaluateFlags": { + "static": false, + "abstract": false, + "final": false, + "returnType": "PostHog\\FeatureFlagEvaluations", + "parameters": [ + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "onlyEvaluateLocally", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "disableGeoip", + "type": "?bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "flagKeys", + "type": "?array", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "flush": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + }, + "getAllFlags": { + "static": false, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [ + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "onlyEvaluateLocally", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "getContext": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?array", + "parameters": [] + }, + "getFeatureFlag": { + "static": false, + "abstract": false, + "final": false, + "returnType": "string|bool|null", + "parameters": [ + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "onlyEvaluateLocally", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "sendFeatureFlagEvents", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": true, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "getFeatureFlagPayload": { + "static": false, + "abstract": false, + "final": false, + "returnType": "mixed", + "parameters": [ + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "getFeatureFlagResult": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?PostHog\\FeatureFlagResult", + "parameters": [ + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "onlyEvaluateLocally", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "sendFeatureFlagEvents", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": true, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "groupIdentify": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [ + { + "name": "message", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "identify": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [ + { + "name": "message", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "isFeatureEnabled": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?bool", + "parameters": [ + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "onlyEvaluateLocally", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "sendFeatureFlagEvents", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": true, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "loadFlags": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + }, + "shutdown": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + }, + "withContext": { + "static": false, + "abstract": false, + "final": false, + "returnType": "mixed", + "parameters": [ + { + "name": "data", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "fn", + "type": "callable", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + } + } + }, + "PostHog\\Laravel\\PostHogServiceProvider": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": "Illuminate\\Support\\ServiceProvider", + "implements": [], + "constants": [], + "properties": [], + "methods": { + "boot": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + }, + "register": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + } + } + }, + "PostHog\\Laravel\\Testing\\PostHogFake": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": "PostHog\\Laravel\\PostHog", + "implements": [], + "constants": [], + "properties": [], + "methods": { + "assertAliased": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "callback", + "type": "?callable", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "assertCaptured": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "event", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "callback", + "type": "?callable", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "assertCapturedCount": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "count", + "type": "int", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "assertExceptionCaptured": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "callback", + "type": "?callable", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "assertGroupIdentified": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "callback", + "type": "?callable", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "assertIdentified": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "callback", + "type": "?callable", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "assertNotCaptured": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "event", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "assertNothingCaptured": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + }, + "captureException": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [ + { + "name": "exception", + "type": "Throwable|string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "properties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "client": { + "static": false, + "abstract": false, + "final": false, + "returnType": "PostHog\\Client", + "parameters": [] + }, + "enabled": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + }, + "evaluateFlags": { + "static": false, + "abstract": false, + "final": false, + "returnType": "PostHog\\FeatureFlagEvaluations", + "parameters": [ + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "onlyEvaluateLocally", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "disableGeoip", + "type": "?bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "flagKeys", + "type": "?array", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "flags": { + "static": false, + "abstract": false, + "final": false, + "returnType": "static", + "parameters": [ + { + "name": "snapshot", + "type": "PostHog\\FeatureFlagEvaluations", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "flush": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + }, + "recorded": { + "static": false, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [ + { + "name": "method", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "shutdown": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + } + } + } + } +} diff --git a/composer.json b/composer.json index 8f37263..af79f0e 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,9 @@ }, "scripts": { "api:check": "@php scripts/check-public-api.php", - "api:update": "@php scripts/check-public-api.php --update" + "api:update": "@php scripts/check-public-api.php --update", + "api:check:laravel": "@php scripts/check-public-api.php --laravel", + "api:update:laravel": "@php scripts/check-public-api.php --laravel --update" }, "bin": [ "bin/posthog" diff --git a/packages/laravel/.gitignore b/packages/laravel/.gitignore new file mode 100644 index 0000000..559dc60 --- /dev/null +++ b/packages/laravel/.gitignore @@ -0,0 +1,3 @@ +/vendor/ +/composer.lock +/.phpunit.cache/ diff --git a/packages/laravel/LICENSE.md b/packages/laravel/LICENSE.md new file mode 120000 index 0000000..f0608a6 --- /dev/null +++ b/packages/laravel/LICENSE.md @@ -0,0 +1 @@ +../../LICENSE.md \ No newline at end of file diff --git a/packages/laravel/README.md b/packages/laravel/README.md new file mode 100644 index 0000000..4a2915c --- /dev/null +++ b/packages/laravel/README.md @@ -0,0 +1,327 @@ +# PostHog for Laravel + +Server-side analytics, feature flags and exception reporting for Laravel 12 and 13. +Requires PHP 8.2+ (Laravel 13 requires PHP 8.3+). + +## Install + +```sh +composer require posthog/posthog-laravel +php artisan posthog:install +``` + +Discovery registers the provider, facade and scoped container bindings automatically. +For applications without discovery, register `PostHog\Laravel\PostHogServiceProvider`. + +Configure your **server environment**: + +```dotenv +POSTHOG_API_KEY=phc_your_project_key +POSTHOG_HOST=https://us.i.posthog.com +POSTHOG_ENABLED=true +``` + +Use your project's ingestion host. `POSTHOG_SECRET_KEY` is optional and enables local +flag evaluation. Never publish secret keys in browser configuration, logs, or event +properties. No browser SDK or browser configuration endpoint is installed. + +A missing/blank project key or `POSTHOG_ENABLED=false` disables all SDK network paths, +including flag requests and the `client()` escape hatch. Setting `sdk.consumer=noop` +only changes event transport: a valid-key client can still evaluate flags remotely. + +## Capture and identity + +```php +use PostHog\Laravel\Facades\PostHog; + +PostHog::capture('checkout completed', ['items' => 2]); +PostHog::capture('import completed', ['rows' => 42], 'account-123', ['team' => 'team-1']); +PostHog::identify(['distinctId' => 'account-123', 'properties' => ['plan' => 'paid']]); +PostHog::alias(['distinctId' => 'account-123', 'alias' => 'old-account']); +PostHog::groupIdentify(['groupType' => 'team', 'groupKey' => 'team-1', 'properties' => ['plan' => 'paid']]); +``` + +Inject `PostHog\Laravel\PostHog` to use the same API without a facade. `posthog` is a +container alias. `PostHog\Client` is also injectable, but raw client calls require +explicit identity or an explicit core context. + +Identity precedence: an explicit API `distinctId` wins, then an explicitly scoped +`Context` identity, then the custom identity resolver or authenticated request user's +auth identifier, then `X-PostHog-Distinct-Id` as an anonymous analytics fallback. `X-PostHog-Session-Id` provides session correlation. Headers are untrusted +analytics input, **never authentication or authorization**. Set `header_context=false` +to ignore both headers. No anonymous ID is generated for requests without identity. + +Captures include the request method and matched route template (for example +`/orders/{order}`), never concrete route parameters. Explicit event properties win. +By default there is no collection of raw URLs, query strings, request bodies, +cookies, email, IP addresses, or user profile fields. GeoIP is disabled by default +(`sdk.disableGeoip=true`). Application-supplied properties and exception messages may +still contain sensitive information; use `sdk.before_send` to redact or drop events. +Exception stacks are serialized by the PHP SDK. + +For a custom identity resolver, set `distinct_id` to an invokable container class. +It receives the application and returns a string identifier or null. A non-null +custom result replaces the request auth identifier. Otherwise auth/header fallback +applies. Resolver errors do not break the application. With `sdk.debug=true`, failed +identity, authenticated-user and group lookups log at most one debug message each +per context scope through Laravel's logger. Diagnostics contain the exception class, +not its message or resolved data; enable debug output on your Laravel log channel to +see them. Set `groups` to an invokable container class receiving the application and +returning group identifiers. Explicit +context groups override matching resolver groups; exception snapshots inherit them. + +## Scoped context and workers + +```php +use PostHog\Laravel\Context; + +app(Context::class)->with([ + 'distinctId' => 'job-owner-123', + 'groups' => ['team' => 'team-1'], + 'properties' => ['component' => 'import'], +], function () { + PostHog::capture('import started'); +}); +``` + +`Context::begin()`, `data()`, `snapshot()` and `end()` support explicit integrations. +`with()` restores the previous data and flag snapshots even when the callback throws. +It accepts `distinct_id`/`session_id` aliases; canonical `distinctId`/`sessionId` keys +win if both forms are supplied. +The manager/facade also exposes `withContext($data, $callback, $options)`, +`enterContext($data, $options)` and `getContext()`. These bridge Laravel context and +raw-client context, accept `distinct_id`/`session_id` aliases, and merge properties +shallowly like the core SDK. Explicit values override active raw-client values, which +override resolved Laravel values. Groups inherit from Laravel unless explicitly +replaced. `['fresh' => true]` excludes ambient identity, request metadata, groups and +resolvers. Close an entered scope in `finally`; closing it twice is harmless. Parent +data and flag snapshots are restored after closing or throwing. +Auth is resolved lazily only while handling a request; no user model is retained by +the analytics context after cleanup. Queue jobs start fresh and receive only bounded +identity, session and scalar group context in `_posthog` payload metadata. Arbitrary +properties, models and request data are not propagated. Set `queue_context=false` to +disable propagation. This metadata is analytics context, never authorization evidence. +Jobs may override it explicitly. Nested synchronous jobs and kernel-level +`Artisan::call()` executions restore the caller's context and flag snapshots when +they finish or fail. + +Request context remains active through HTTP kernel termination, including +`dispatchAfterResponse()` jobs and callbacks registered during application termination. +The kernel's completion hook flushes and clears it afterward, not when middleware +returns the response. Custom HTTP kernels without Laravel's lifecycle callback API +fall back to middleware cleanup and cannot provide after-response correlation. + +Queue events and Octane request/task/tick boundaries flush and clean context +automatically. Console integration decorates the existing console kernel contract: +nested `Artisan::call()` executions restore their caller in `finally`, while the root +command stays active through kernel termination. Nested calls defer flushing to the +outer command or HTTP termination. A standalone `Artisan::call()` still flushes on +exit. Thrown objects retain their own snapshots; nonzero return codes never supply another exception's identity. The +application's commands, custom kernel, and exception-reporting policy are preserved. +Direct child-command `$this->call()`/`callSilent()`/`callSilently()` execution bypasses +those kernel boundaries. Opt into isolation by adding `IsolatesCommandContext` to +**the calling command**, or to your application's base command: + +```php +namespace App\Console; + +use Illuminate\Console\Command as BaseCommand; +use PostHog\Laravel\Concerns\IsolatesCommandContext; + +abstract class Command extends BaseCommand +{ + use IsolatesCommandContext; +} +``` + +Have your application commands extend this base class, or apply the trait directly +to each calling command. All three call helpers then restore the caller's context +and cached flag snapshots when the child returns or throws, including closure +children. Arguments, output, exit codes and throwables pass through unchanged. +Root context still lasts through kernel termination. + +This is opt-in, not global interception: uninstrumented callers, including commands +registered with `Artisan::command()`, still need kernel-level `Artisan::call()` or an +explicit `PostHog::withContext([], fn () => $this->call(...))` scope when a child can +mutate analytics context. The trait uses Laravel's inherited `runCommand()`; +custom call implementations that bypass it also need explicit scoping. + +SDK creation is lazy, +per Laravel scope. Shared flag definitions live in Laravel Cache, not a global SDK. +Do not retain scoped services in application singletons or prewarm them in Octane. + +Supported execution models are ordinary HTTP, Laravel queue workers (including sync +jobs), console commands, and sequential Octane sandbox lifecycles. The package does +not claim isolation for overlapping Swoole coroutines sharing one Laravel container. +Core Fiber context support alone does not make Laravel scopes coroutine-local. +Automatic flush failures do not replace an HTTP response or worker failure. Explicit +`flush()`/`shutdown()` expose the SDK results; flush is not a delivery receipt. + +## Exceptions + +Laravel's reportable exception path respects `dontReport`, exception-specific report +methods, and preceding report callbacks that stop reporting. The package does not +install global PHP exception/error handlers, even if `sdk.error_tracking.enabled` is +set. Disable automatic reporting with `capture_exceptions=false`. Custom exception handlers +must expose Laravel's `reportable()` API for automatic registration; otherwise add an +explicit report callback using the public capture API. + +```php +try { + // Application work... +} catch (Throwable $exception) { + PostHog::captureException($exception, properties: ['component' => 'import']); +} +``` + +The manager/facade uses the named argument `properties:` for exception properties, +not the core client's `additionalProperties:`. Other exception parameters match core. + +Manual captures and ordinary `report()` calls are handled and use mechanism `generic` +unless a known integration boundary supplies a more specific category. Failures +reported from Laravel's routing pipeline or HTTP/console kernel failure paths, escaping request +middleware or queue execution, or reported by Octane's worker error listener are +unhandled. Boundary detection inspects only framework call names, never arguments. +Weakly held exception snapshots preserve the originating identity after cleanup; +automatic reports of the same exception object are deduplicated. A report already +emitted by an earlier framework listener cannot be retroactively relabeled by a +later boundary event. Explicit manual capture is intentionally independent of +automatic-report deduplication. + +Automatic reports include `$exception_source` from the first known SDK boundary: +`laravel.queue_job`, `laravel.console_lifecycle`, or `laravel.octane_worker_error`. +Normal HTTP pipeline failures are reported and rendered by Laravel before the outer +middleware can catch them: their source is `laravel.reportable` and mechanism is +`middleware`. The safety-net middleware catch supplies `laravel.middleware` only +when an exception escapes to it, such as in a custom pipeline. Without a remembered +boundary source, the registered Laravel reportable callback supplies `laravel.reportable`. +Sources are never inferred from stack frames. Ordinary manual `captureException()` +calls omit the source unless an integration explicitly supplies it through the typed +`source` option. + +## Feature flags + +```php +$flags = PostHog::evaluateFlags(); // Current request identity, evaluated on demand. +if ($flags->isEnabled('new-checkout')) { + $variant = $flags->getFlag('checkout-layout'); + $payload = $flags->getFlagPayload('checkout-layout'); +} +``` + +The same complete evaluation inputs reuse one core snapshot within the current +context. New identities, groups, properties, key scopes, or lifecycle scopes do not +reuse another user's result. Capture and boot do not evaluate flags. Value reads +emit the core's deduplicated exposure events with the originating context, even +when read after another request starts or the original scope ends; payload reads +are silent. An explicit +empty `flagKeys: []` returns an empty snapshot without evaluation. + +The full core signature is supported: `distinctId`, `groups`, `personProperties`, +`groupProperties`, `onlyEvaluateLocally`, `disableGeoip`, and `flagKeys`. +The manager also supports the core-compatible `getAllFlags()`, `getFeatureFlag()`, +`getFeatureFlagResult()`, `getFeatureFlagPayload()`, and `isFeatureEnabled()` methods +with current context, plus `loadFlags()` and `contextFromHeaders()`. These methods +are declared explicitly; new core methods are not exposed automatically. Use +`client()` for other lower-level PHP SDK operations: + +```php +$result = PostHog::getFeatureFlagResult('flag-key', 'account-123'); +$client = PostHog::client(); // Advanced PHP SDK access; pass identity explicitly. +$scope = $client->enterContext(['distinctId' => 'account-123'], ['fresh' => true]); +try { + $client->capture(['event' => 'advanced', 'properties' => ['component' => 'worker']]); +} finally { + $scope->close(); +} +``` + +Array-form `capture()` forwards the PHP message unchanged, including its advanced +options. Prefer `evaluateFlags()` over the legacy getters for shared flag decisions. +Forwarded legacy flag getters also lazily load configured local definitions on first +identified use, preserving local-only options. Like `evaluateFlags()`, they inherit +context groups when the groups argument is empty; explicit nonempty groups override +them. With the raw `client()` escape hatch, call `loadFlags()` explicitly before local +evaluation if definitions have not loaded. + +### Shared local definitions + +Enable `flag_cache.enabled` to use `PostHog\Laravel\FlagDefinitionCacheProvider`. +`flag_cache.store` selects a Laravel cache store, `ttl` controls definition retention, +and `refresh_interval` controls refresh eligibility (seconds). The key is isolated +by ingestion host and project-key hash. Complete definition documents, including +`property_matching_version`, round-trip unchanged. Cache backend failures are handled +by the core's defensive provider contract. Use a shared production cache store whose +`add()` operation is atomic for best-effort fetch coordination; the array store is +process-local, not distributed coordination. + +With a secret key, a worker can publish definitions; without it, workers only read +the cache. Definitions load lazily during the first nonempty flag evaluation and +refresh lazily after the interval. The interval only throttles within a single +scoped manager: without a cache provider, each new request/job that evaluates flags +with a secret key performs a blocking definition fetch, even within the interval. +For production local evaluation, enable `flag_cache` with a shared store, or configure +a custom provider. `posthog:doctor` warns about a secret key without a provider. +No cache is enabled implicitly, and there is no background timer. The PHP client's +`loadFlags()` remains available for an explicit refresh. You can instead supply a +custom provider class in `sdk.flag_definition_cache_provider`. + +## Configuration callbacks and full PHP access + +`posthog.sdk` passes PHP SDK options through. `before_send`, `error_handler`, and +`flag_definition_cache_provider` accept container class names, so published config +can be cached without serializing closures. Callback classes must implement the +corresponding SDK callable/interface contract. The Laravel library metadata and +framework handler ownership are enforced by the wrapper. `release_id` sets release +metadata; advanced SDK options remain accessible under `sdk`. + +Client construction remains lazy. Invalid SDK configuration raises an initialization +error on first client use rather than silently disabling analytics. Run +`php artisan posthog:doctor` as a deployment check: it constructs the client and +reports initialization failures without printing credentials, loading flag +definitions or sending a diagnostic event. Custom container callbacks/providers +are resolved, so their constructors should not perform network work. + +The supported PHP surface consists of the manager, facade, documented `Context` +methods, `IsolatesCommandContext` trait, test fake, service provider registration, +and Laravel flag-definition cache provider. Classes and methods marked `@internal` +are framework plumbing, not supported customer APIs or extension points, even when +Laravel or other package classes require their methods to be public. + +## Diagnostics + +- `php artisan posthog:doctor`: local configuration validation, no network or keys in output. +- `php artisan posthog:install`: publish config without overwriting an existing file. +- `php artisan posthog:test --send`: explicitly opt in to a diagnostic event with a random test ID. + +The diagnostic event excludes ambient identity, session, groups and request/user +properties without changing the caller's context. Application-owned `before_send` +callbacks still run and may deliberately enrich or drop it. + +No collectors are enabled by this foundation package. Optional collectors can be +registered by a separate `AutocaptureServiceProvider` when installed. + +## Testing + +```php +$fake = PostHog::fake(); +PostHog::capture('checkout completed', ['items' => 2], 'account-123'); +$fake->assertCaptured('checkout completed', fn ($event) => $event['properties']['items'] === 2); +$fake->assertCapturedCount(1); +$fake->assertNotCaptured('checkout failed'); +``` + +Also available: `assertNothingCaptured()`, `assertIdentified()`, `assertAliased()`, +`assertGroupIdentified()`, `assertExceptionCaptured()`, and `recorded()`. Message API +assertions accept an optional predicate. `flags($coreSnapshot)` configures fake flag +results; the default is an empty snapshot. The fake's raw client is always disabled, +so escape-hatch calls cannot send traffic (they are not recorded). + +The production manifest deliberately has no development repository overrides. For +local development before the required PHP SDK release is published, use the root +`scripts/test-laravel.sh 12` or `scripts/test-laravel.sh 13` helper once the tooling +PR in this stack is merged. It installs local dependencies in a disposable source +copy and runs the matching framework tests without changing package metadata. + +Tests use Testbench, mocked transports and real Laravel lifecycle events; no live +PostHog service or Octane server process is required. diff --git a/packages/laravel/composer.json b/packages/laravel/composer.json new file mode 100644 index 0000000..a5fd4d1 --- /dev/null +++ b/packages/laravel/composer.json @@ -0,0 +1,23 @@ +{ + "name": "posthog/posthog-laravel", + "description": "PostHog for Laravel", + "version": "0.1.0", + "license": "MIT", + "require": { + "php": ">=8.2", + "posthog/posthog-php": "^4.20", + "illuminate/support": "^12.0 || ^13.0", + "illuminate/contracts": "^12.0 || ^13.0" + }, + "require-dev": { + "orchestra/testbench": "^10.0 || ^11.0", + "phpunit/phpunit": "^11.0 || ^12.0", + "laravel/octane": "^2.0" + }, + "autoload": {"psr-4": {"PostHog\\Laravel\\": "src/"}}, + "autoload-dev": {"psr-4": {"PostHog\\Laravel\\Tests\\": "tests/"}}, + "extra": {"laravel": {"providers": ["PostHog\\Laravel\\PostHogServiceProvider"], "aliases": {"PostHog": "PostHog\\Laravel\\Facades\\PostHog"}}}, + "scripts": {"test": "phpunit"}, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/packages/laravel/config/posthog.php b/packages/laravel/config/posthog.php new file mode 100644 index 0000000..133a182 --- /dev/null +++ b/packages/laravel/config/posthog.php @@ -0,0 +1,26 @@ + env('POSTHOG_ENABLED', true), + 'api_key' => env('POSTHOG_API_KEY'), + 'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'), + 'secret_key' => env('POSTHOG_SECRET_KEY'), + 'release_id' => env('POSTHOG_RELEASE_ID'), + 'capture_exceptions' => true, + 'header_context' => true, + 'queue_context' => true, + // Invokable container class; receives the application and returns a distinct ID or null. + 'distinct_id' => null, + // Invokable container class; receives the application and returns group identifiers. + 'groups' => null, + 'sdk' => [ + 'disableGeoip' => true, + // before_send, error_handler and flag_definition_cache_provider accept container class names. + ], + 'flag_cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'refresh_interval' => 30, + ], +]; diff --git a/packages/laravel/phpunit.xml b/packages/laravel/phpunit.xml new file mode 100644 index 0000000..4c162c2 --- /dev/null +++ b/packages/laravel/phpunit.xml @@ -0,0 +1,4 @@ + + + tests + diff --git a/packages/laravel/src/Commands/DoctorCommand.php b/packages/laravel/src/Commands/DoctorCommand.php new file mode 100644 index 0000000..a055be7 --- /dev/null +++ b/packages/laravel/src/Commands/DoctorCommand.php @@ -0,0 +1,46 @@ +enabled()) { + $this->warn('PostHog is disabled or has no project API key. No traffic will be sent.'); + return self::FAILURE; + } + $host = config('posthog.host'); + if ( + !is_string($host) || !filter_var($host, FILTER_VALIDATE_URL) + || !in_array(parse_url($host, PHP_URL_SCHEME), ['http', 'https'], true) + ) { + $this->error('PostHog host must be an HTTP or HTTPS URL.'); + return self::FAILURE; + } + try { + $posthog->client(); + } catch (Throwable $exception) { + $this->error('PostHog client initialization failed (' . $exception::class . '). ' + . 'Check posthog.sdk options, including consumer, callbacks and flag_definition_cache_provider.'); + return self::FAILURE; + } + if ( + config('posthog.secret_key') && !config('posthog.flag_cache.enabled') + && !config('posthog.sdk.flag_definition_cache_provider') + ) { + $this->warn('Local flag definitions will be fetched on first evaluation in each request or job. ' + . 'Enable posthog.flag_cache or configure a flag_definition_cache_provider to share definitions.'); + } + $this->info('PostHog is configured. Credentials are server-side. No network check was performed.'); + return self::SUCCESS; + } +} diff --git a/packages/laravel/src/Commands/InstallCommand.php b/packages/laravel/src/Commands/InstallCommand.php new file mode 100644 index 0000000..9a03ea7 --- /dev/null +++ b/packages/laravel/src/Commands/InstallCommand.php @@ -0,0 +1,23 @@ +call('vendor:publish', ['--tag' => 'posthog-config']); + if ($result !== self::SUCCESS) { + return $result; + } + $this->info('Set POSTHOG_API_KEY and POSTHOG_HOST in your server environment.'); + $this->info('Never expose POSTHOG_SECRET_KEY to a browser.'); + return self::SUCCESS; + } +} diff --git a/packages/laravel/src/Commands/TestCommand.php b/packages/laravel/src/Commands/TestCommand.php new file mode 100644 index 0000000..eaff621 --- /dev/null +++ b/packages/laravel/src/Commands/TestCommand.php @@ -0,0 +1,34 @@ +option('send') || !$posthog->enabled()) { + $this->warn('No event sent. Configure PostHog and pass --send to opt in.'); + return self::FAILURE; + } + $captured = $posthog->withContext( + [], + fn () => $posthog->capture('posthog test event', [], 'posthog-test-' . Str::uuid()), + ['fresh' => true], + ); + $flushed = $posthog->flush(); + if (!$captured || !$flushed) { + $this->error('The diagnostic event was not accepted or the transport did not flush.'); + return self::FAILURE; + } + $this->info('Diagnostic event queued and flushed. Check your PostHog project for delivery.'); + return self::SUCCESS; + } +} diff --git a/packages/laravel/src/Concerns/IsolatesCommandContext.php b/packages/laravel/src/Concerns/IsolatesCommandContext.php new file mode 100644 index 0000000..cabdde6 --- /dev/null +++ b/packages/laravel/src/Concerns/IsolatesCommandContext.php @@ -0,0 +1,18 @@ +make(ConsoleLifecycle::class)->call( + fn () => parent::runCommand($command, $arguments, $output), + ); + } +} diff --git a/packages/laravel/src/ConsoleKernel.php b/packages/laravel/src/ConsoleKernel.php new file mode 100644 index 0000000..7836c58 --- /dev/null +++ b/packages/laravel/src/ConsoleKernel.php @@ -0,0 +1,67 @@ +whenCommandLifecycleIsLongerThan(-INF, static function () { + Container::getInstance()->make(ConsoleLifecycle::class)->finishRoot(); + }); + } + } + + public function bootstrap() + { + return $this->kernel->bootstrap(); + } + + public function handle($input, $output = null) + { + return Container::getInstance()->make(ConsoleLifecycle::class)->handle( + fn () => $this->kernel->handle($input, $output), + ); + } + + public function call($command, array $parameters = [], $outputBuffer = null) + { + return Container::getInstance()->make(ConsoleLifecycle::class)->call( + fn () => $this->kernel->call($command, $parameters, $outputBuffer), + ); + } + + public function queue($command, array $parameters = []) + { + return $this->kernel->queue($command, $parameters); + } + + public function all() + { + return $this->kernel->all(); + } + + public function output() + { + return $this->kernel->output(); + } + + public function terminate($input, $status) + { + try { + return $this->kernel->terminate($input, $status); + } finally { + Container::getInstance()->make(ConsoleLifecycle::class)->finishRoot(); + } + } + + public function __call(string $method, array $arguments): mixed + { + return $this->kernel->$method(...$arguments); + } +} diff --git a/packages/laravel/src/ConsoleLifecycle.php b/packages/laravel/src/ConsoleLifecycle.php new file mode 100644 index 0000000..5905950 --- /dev/null +++ b/packages/laravel/src/ConsoleLifecycle.php @@ -0,0 +1,91 @@ +root || $this->calls > 0) { + return; + } + $this->enter(); + $this->root = true; + } + + public function handle(callable $callback): mixed + { + $this->startRoot(); + try { + return $callback(); + } catch (Throwable $exception) { + $this->remember($exception); + $this->finishRoot(); + throw $exception; + } + } + + public function call(callable $callback): mixed + { + $this->enter(); + $this->calls++; + try { + return $callback(); + } catch (Throwable $exception) { + $this->remember($exception); + throw $exception; + } finally { + $this->calls--; + $this->leave( + !$this->root && $this->calls === 0 && !$this->app->make(RequestLifecycle::class)->active(), + ); + } + } + + public function finishRoot(): void + { + if ($this->root) { + $this->root = false; + $this->leave(); + } + } + + private function enter(): void + { + $context = $this->app->make(Context::class); + $data = $context->snapshot(); + $context->enter(); + $context->begin($data); + } + + private function remember(Throwable $exception): void + { + try { + $this->app->make(ExceptionReporter::class)->remember($exception, 'runtime', 'laravel.console_lifecycle'); + } catch (Throwable) { + } + } + + private function leave(bool $flush = true): void + { + try { + if ($flush) { + $this->app->make(PostHog::class)->flush(); + } + } catch (Throwable) { + } finally { + $this->app->make(Context::class)->leave(); + } + } +} diff --git a/packages/laravel/src/Context.php b/packages/laravel/src/Context.php new file mode 100644 index 0000000..e0ab6d7 --- /dev/null +++ b/packages/laravel/src/Context.php @@ -0,0 +1,220 @@ +context = $data; + $this->flags = []; + $this->request = null; + $this->headerIdentity = false; + $this->resolved = false; + $this->loggedResolverFailures = []; + } + + public function end(): void + { + $this->begin(); + } + + public function data(): array + { + $data = $this->context; + if ($this->resolved) { + return $data; + } + if ($this->request !== null) { + $data['properties']['$request_method'] ??= $this->request->method(); + $route = $this->request->route(); + if ($route instanceof Route) { + $data['properties']['$request_path'] ??= '/' . ltrim($route->uri(), '/'); + } + } + $resolver = $this->app['config']->get('posthog.distinct_id'); + $explicitId = !$this->headerIdentity && isset($data['distinctId']); + $id = $explicitId ? $data['distinctId'] : null; + if (!$explicitId && $resolver) { + try { + $id = ($this->app->make($resolver))($this->app); + } catch (Throwable $exception) { + $this->logResolverFailure('distinct_id', $exception); + } + } + if ($id === null && $this->request !== null) { + try { + $id = $this->request->user()?->getAuthIdentifier(); + } catch (Throwable $exception) { + $this->logResolverFailure('authenticated user', $exception); + } + } + if (is_scalar($id) && (string) $id !== '') { + $data['distinctId'] = (string) $id; + } + $groupsResolver = $this->app['config']->get('posthog.groups'); + if ($groupsResolver !== null) { + try { + $groups = ($this->app->make($groupsResolver))($this->app); + if (is_array($groups)) { + $data['groups'] = array_replace($groups, $data['groups'] ?? []); + } + } catch (Throwable $exception) { + $this->logResolverFailure('groups', $exception); + } + } + return $data; + } + + private function logResolverFailure(string $resolver, Throwable $exception): void + { + if (!$this->app['config']->get('posthog.sdk.debug', false) || isset($this->loggedResolverFailures[$resolver])) { + return; + } + $this->loggedResolverFailures[$resolver] = true; + try { + $this->app->make('log')->debug('[PostHog] Failed to resolve ' . $resolver . ' context.', [ + 'exception_class' => $exception::class, + ]); + } catch (Throwable) { + // A failing application logger must not interrupt analytics context fallback. + } + } + + public function snapshot(): array + { + return $this->data(); + } + + public function with(array $data, callable $callback): mixed + { + $previous = $this->context; + $flags = $this->flags; + $request = $this->request; + $headerIdentity = $this->headerIdentity; + $resolved = $this->resolved; + $loggedResolverFailures = $this->loggedResolverFailures; + foreach (['distinct_id' => 'distinctId', 'session_id' => 'sessionId'] as $alias => $canonical) { + if (array_key_exists($alias, $data) && !array_key_exists($canonical, $data)) { + $data[$canonical] = $data[$alias]; + } + unset($data[$alias]); + } + $sessionId = $data['sessionId'] ?? null; + if ( + is_scalar($sessionId) && (string) $sessionId !== '' + && !array_key_exists('$session_id', $data['properties'] ?? []) + ) { + $data['properties']['$session_id'] = (string) $sessionId; + } + $this->begin(array_replace_recursive($previous, $data)); + $this->resolved = $resolved; + $this->request = $request; + $this->headerIdentity = !array_key_exists('distinctId', $data) && $headerIdentity; + try { + return $callback(); + } catch (Throwable $exception) { + $this->app->make(ExceptionReporter::class)->rememberContext($exception); + throw $exception; + } finally { + $this->context = $previous; + $this->flags = $flags; + $this->request = $request; + $this->headerIdentity = $headerIdentity; + $this->resolved = $resolved; + $this->loggedResolverFailures = $loggedResolverFailures; + } + } + + /** @internal Install resolved manager context without retaining a request or re-running resolvers. */ + public function enterResolvedContext(array $data): ContextScope + { + $id = $this->nextScopeId++; + $this->contextScopes[$id] = $this->state(); + $this->begin($data); + $this->resolved = true; + return new ContextScope(function () use ($id): void { + $ids = array_keys($this->contextScopes); + $index = array_search($id, $ids, true); + if ($index === false) { + return; + } + if (isset($ids[$index + 1])) { + $this->contextScopes[$ids[$index + 1]] = $this->contextScopes[$id]; + } else { + $this->restore($this->contextScopes[$id]); + } + unset($this->contextScopes[$id]); + }); + } + + private function state(): array + { + return [ + $this->context, $this->flags, $this->request, $this->headerIdentity, + $this->resolved, $this->loggedResolverFailures, + ]; + } + + private function restore(array $state): void + { + [ + $this->context, $this->flags, $this->request, $this->headerIdentity, + $this->resolved, $this->loggedResolverFailures, + ] = $state; + } + + /** @internal */ + public function fromRequest(Request $request): void + { + $this->begin($this->app['config']->get('posthog.header_context', true) + ? CorePostHog::contextFromHeaders($request->headers->all()) : []); + $this->request = $request; + $this->headerIdentity = true; + } + + /** @internal Isolate nested queue executions without discarding the caller's context. */ + public function enter(): void + { + $this->scopes[] = $this->state(); + $this->begin(); + } + + /** @internal Restore the scope active before queue execution. */ + public function leave(): void + { + if ($this->scopes === []) { + $this->end(); + return; + } + $this->restore(array_pop($this->scopes)); + } + + /** @internal */ + public function flags(string $key, callable $evaluate): FeatureFlagEvaluations + { + return $this->flags[$key] ??= $evaluate(); + } +} diff --git a/packages/laravel/src/ExceptionReporter.php b/packages/laravel/src/ExceptionReporter.php new file mode 100644 index 0000000..9d3285a --- /dev/null +++ b/packages/laravel/src/ExceptionReporter.php @@ -0,0 +1,81 @@ +snapshots = new WeakMap(); + $this->reported = new WeakMap(); + } + + public function rememberContext(Throwable $exception): void + { + $this->snapshots[$exception] ??= ['data' => $this->app->make(Context::class)->snapshot()]; + } + + public function remember(Throwable $exception, string $mechanism, ?string $source = null): void + { + $this->rememberContext($exception); + $snapshot = $this->snapshots[$exception]; + $snapshot['options'] ??= ['handled' => false, 'mechanism' => $mechanism]; + if ($source !== null) { + $snapshot['options']['source'] ??= $source; + } + $this->snapshots[$exception] = $snapshot; + } + + private function reportOptions(): array + { + foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS) as $frame) { + $class = $frame['class'] ?? ''; + $method = $frame['function'] ?? ''; + // Laravel's routing pipeline consumes failures before outer middleware can catch them. + if ( + ($method === 'handleException' && is_a($class, \Illuminate\Routing\Pipeline::class, true)) + || ($method === 'reportException' && is_a($class, \Illuminate\Foundation\Http\Kernel::class, true)) + ) { + return ['handled' => false, 'mechanism' => 'middleware']; + } + if ( + ($method === 'reportException' && is_a($class, \Illuminate\Foundation\Console\Kernel::class, true)) + || ($method === 'handle' && is_a($class, 'Laravel\\Octane\\Listeners\\ReportException', true)) + ) { + return ['handled' => false, 'mechanism' => 'runtime']; + } + } + return ['handled' => true, 'mechanism' => 'generic']; + } + + public function report(Throwable $exception, ?string $source = null): void + { + if (isset($this->reported[$exception]) || !$this->app['config']->get('posthog.capture_exceptions', true)) { + return; + } + $this->reported[$exception] = true; + $snapshot = $this->snapshots[$exception] ?? ['data' => $this->app->make(Context::class)->snapshot()]; + $options = $snapshot['options'] ?? $this->reportOptions(); + if ($source !== null) { + $options['source'] ??= $source; + } + $manager = $this->app->make(PostHog::class); + try { + $manager->withContext( + $snapshot['data'], + fn () => $manager->captureException($exception, options: $options), + ['fresh' => true], + ); + } catch (Throwable) { + // Reporting must never replace the application's original failure. + } + } +} diff --git a/packages/laravel/src/Facades/PostHog.php b/packages/laravel/src/Facades/PostHog.php new file mode 100644 index 0000000..b8d3817 --- /dev/null +++ b/packages/laravel/src/Facades/PostHog.php @@ -0,0 +1,23 @@ +instance(\PostHog\Client::class, $fake->client()); + return $fake; + } +} diff --git a/packages/laravel/src/FlagDefinitionCacheProvider.php b/packages/laravel/src/FlagDefinitionCacheProvider.php new file mode 100644 index 0000000..f02f07e --- /dev/null +++ b/packages/laravel/src/FlagDefinitionCacheProvider.php @@ -0,0 +1,37 @@ +cache->get($this->key); + return is_array($data) ? $data : null; + } + + public function shouldFetchFlagDefinitions(): bool + { + return $this->cache->add($this->key . ':refresh', true, $this->refreshInterval); + } + + public function onFlagDefinitionsReceived(array $data): void + { + $this->cache->put($this->key, $data, $this->ttl); + } + + public function shutdown(): void + { + } +} diff --git a/packages/laravel/src/LaravelClient.php b/packages/laravel/src/LaravelClient.php new file mode 100644 index 0000000..60d4b04 --- /dev/null +++ b/packages/laravel/src/LaravelClient.php @@ -0,0 +1,36 @@ +configuredReleaseId = $releaseId; + parent::__construct($apiKey, $options, loadFeatureFlags: false, secretKey: $secretKey); + } + + protected function libraryName(): string + { + return 'posthog-laravel'; + } + + protected function libraryVersion(): string + { + return PostHog::VERSION; + } + + protected function releaseId(): ?string + { + return $this->configuredReleaseId ?? parent::releaseId(); + } +} diff --git a/packages/laravel/src/Middleware/RequestContext.php b/packages/laravel/src/Middleware/RequestContext.php new file mode 100644 index 0000000..2de0e92 --- /dev/null +++ b/packages/laravel/src/Middleware/RequestContext.php @@ -0,0 +1,35 @@ +lifecycle->begin($request); + try { + return $next($request); + } catch (Throwable $exception) { + $this->reporter->remember($exception, 'middleware', 'laravel.middleware'); + throw $exception; + } finally { + if (!method_exists($this->kernel, 'whenRequestLifecycleIsLongerThan')) { + $this->lifecycle->finish(); + } + } + } +} diff --git a/packages/laravel/src/PostHog.php b/packages/laravel/src/PostHog.php new file mode 100644 index 0000000..b9b9480 --- /dev/null +++ b/packages/laravel/src/PostHog.php @@ -0,0 +1,342 @@ +app['config']->get('posthog.enabled', true) + && trim((string) $this->app['config']->get('posthog.api_key')) !== ''; + } + + public function client(): Client + { + if ($this->client !== null) { + return $this->client; + } + if (!$this->enabled()) { + return $this->client = new Client(null, ['consumer' => 'noop'], loadFeatureFlags: false); + } + $config = $this->app['config']->get('posthog', []); + $options = $config['sdk'] ?? []; + $options += ['disableGeoip' => true]; + $options['host'] = $config['host'] ?? 'https://us.i.posthog.com'; + // Laravel owns reporting and process-wide PHP handlers. + $options['error_tracking'] = array_replace($options['error_tracking'] ?? [], [ + 'enabled' => false, + ]); + foreach (['before_send', 'error_handler', 'flag_definition_cache_provider'] as $key) { + if (isset($options[$key]) && is_string($options[$key])) { + $options[$key] = $this->app->make($options[$key]); + } + } + if (isset($options['error_handler'])) { + $options['error_handler'] = Closure::fromCallable($options['error_handler']); + } + if (!isset($options['flag_definition_cache_provider']) && ($config['flag_cache']['enabled'] ?? false)) { + $options['flag_definition_cache_provider'] = $this->app->make(FlagDefinitionCacheProvider::class); + } + return $this->client = new LaravelClient( + $config['api_key'], + $options, + $config['release_id'] ?? null, + $config['secret_key'] ?? null, + ); + } + + public function capture( + string|array $event, + array $properties = [], + ?string $distinctId = null, + array $groups = [], + ): bool { + $message = is_array($event) ? $event : ['event' => $event, 'properties' => $properties]; + if ($distinctId !== null) { + $message['distinctId'] = $distinctId; + } + if ($groups !== []) { + $message['groups'] = $groups; + } + return $this->message('capture', $message); + } + + public function identify(array $message): bool + { + return $this->message('identify', $message); + } + + public function alias(array $message): bool + { + return $this->message('alias', $message); + } + + public function groupIdentify(array $message): bool + { + return $this->message('groupIdentify', $message); + } + + protected function message(string $method, array $message): bool + { + if (!$this->enabled()) { + return false; + } + $data = $this->app->make(Context::class)->data(); + if (isset($data['groups']) && !array_key_exists('groups', $message)) { + $message['groups'] = $data['groups']; + } + return $this->client()->withContext( + $data, + fn () => (bool) $this->client()->$method($message), + ['fresh' => true], + ); + } + + public function captureException( + Throwable|string $exception, + ?string $distinctId = null, + array $properties = [], + array $options = [], + ): bool { + if (!$this->enabled()) { + return false; + } + $data = $this->app->make(Context::class)->data(); + $properties += isset($data['groups']) ? ['$groups' => $data['groups']] : []; + return $this->client()->withContext( + $data, + fn () => $this->client()->captureException($exception, $distinctId, $properties, $options), + ['fresh' => true], + ); + } + + public function evaluateFlags( + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + bool $onlyEvaluateLocally = false, + ?bool $disableGeoip = null, + ?array $flagKeys = null, + ): FeatureFlagEvaluations { + $context = $this->app->make(Context::class); + $data = $context->data(); + $distinctId ??= $data['distinctId'] ?? null; + $groups = $groups ?: ($data['groups'] ?? []); + $disableGeoip ??= $this->app['config']->get('posthog.sdk.disableGeoip', true); + $inputs = [ + $distinctId, $groups, $personProperties, $groupProperties, $onlyEvaluateLocally, $disableGeoip, $flagKeys, + ]; + if (!$this->enabled() || $distinctId === null || $distinctId === '') { + return new FeatureFlagEvaluations( + $distinctId ?? '', + [], + $groups, + $this->client(), + disableGeoip: $disableGeoip, + ); + } + return $context->flags(hash('sha256', serialize($inputs)), function () use ($inputs, $flagKeys, $data) { + if ($flagKeys !== []) { + $this->loadDefinitionsIfNeeded(); + } + return $this->client()->withContext( + $data, + fn () => $this->client()->evaluateFlags(...$inputs), + ['fresh' => true], + ); + }); + } + + private function loadDefinitionsIfNeeded(): void + { + $config = $this->app['config']->get('posthog'); + $hasDefinitions = !empty($config['secret_key']) + || !empty($config['sdk']['flag_definition_cache_provider']) + || ($config['flag_cache']['enabled'] ?? false); + $interval = max(1, (int) ($config['flag_cache']['refresh_interval'] ?? 30)); + $refreshDue = $this->definitionsLoadedAt === null || time() - $this->definitionsLoadedAt >= $interval; + if ($hasDefinitions && $refreshDue) { + $this->client()->loadFlags(); + $this->definitionsLoadedAt = time(); + } + } + + public function flush(): bool + { + return $this->client !== null ? (bool) $this->client->flush() : false; + } + + public function shutdown(): bool + { + return $this->client !== null ? $this->client->shutdown() : false; + } + + public function withContext(array $data, callable $fn, array $options = []): mixed + { + $scope = $this->enterContext($data, $options); + try { + return $fn(); + } catch (Throwable $exception) { + $this->app->make(ExceptionReporter::class)->rememberContext($exception); + throw $exception; + } finally { + $scope->close(); + } + } + + public function enterContext(array $data, array $options = []): ContextScope + { + $context = $this->app->make(Context::class); + $client = $this->client(); + $scopes = []; + $groups = []; + if (!($options['fresh'] ?? false)) { + $parent = $context->data(); + $groups = $parent['groups'] ?? []; + $raw = $client->getContext(); + $scopes[] = $client->enterContext($parent, ['fresh' => true]); + if ($raw !== null) { + $scopes[] = $client->enterContext($raw); + } + } + $scopes[] = $client->enterContext($data, $options); + $resolved = $client->getContext(); + if (array_key_exists('groups', $data)) { + $groups = $data['groups']; + } + if ($groups !== []) { + $resolved['groups'] = $groups; + } + $scope = $context->enterResolvedContext($resolved); + return new ContextScope(static function () use ($scope, $scopes): void { + $scope->close(); + foreach (array_reverse($scopes) as $coreScope) { + $coreScope->close(); + } + }); + } + + public function getContext(): ?array + { + return $this->withContext([], fn () => $this->client()->getContext()); + } + + /** @deprecated Use evaluateFlags()->isEnabled() instead. */ + public function isFeatureEnabled( + string $key, + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + bool $onlyEvaluateLocally = false, + bool $sendFeatureFlagEvents = true, + ): ?bool { + return $this->callFlagMethod(__FUNCTION__, [ + $key, $distinctId, $groups, $personProperties, $groupProperties, + $onlyEvaluateLocally, $sendFeatureFlagEvents, + ], $distinctId); + } + + /** @deprecated Use evaluateFlags()->getFlag() instead. */ + public function getFeatureFlag( + string $key, + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + bool $onlyEvaluateLocally = false, + bool $sendFeatureFlagEvents = true, + ): bool|string|null { + return $this->callFlagMethod(__FUNCTION__, [ + $key, $distinctId, $groups, $personProperties, $groupProperties, + $onlyEvaluateLocally, $sendFeatureFlagEvents, + ], $distinctId); + } + + /** @deprecated Use evaluateFlags() to read the value and payload from one snapshot. */ + public function getFeatureFlagResult( + string $key, + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + bool $onlyEvaluateLocally = false, + bool $sendFeatureFlagEvents = true, + ): ?FeatureFlagResult { + return $this->callFlagMethod(__FUNCTION__, [ + $key, $distinctId, $groups, $personProperties, $groupProperties, + $onlyEvaluateLocally, $sendFeatureFlagEvents, + ], $distinctId); + } + + /** @deprecated Use evaluateFlags()->getFlagPayload() instead. */ + public function getFeatureFlagPayload( + string $key, + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + ): mixed { + return $this->callFlagMethod(__FUNCTION__, [ + $key, $distinctId, $groups, $personProperties, $groupProperties, + ], $distinctId); + } + + public function getAllFlags( + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + bool $onlyEvaluateLocally = false, + ): array { + return $this->callFlagMethod(__FUNCTION__, [ + $distinctId, $groups, $personProperties, $groupProperties, $onlyEvaluateLocally, + ], $distinctId); + } + + public function loadFlags(): void + { + $this->client()->loadFlags(); + } + + public function contextFromHeaders(array $headers): array + { + return $this->client()->contextFromHeaders($headers); + } + + private function callFlagMethod(string $method, array $arguments, ?string $distinctId): mixed + { + $data = $this->app->make(Context::class)->data(); + $groupsIndex = $method === 'getAllFlags' ? 1 : 2; + $arguments[$groupsIndex] = $arguments[$groupsIndex] ?: ($data['groups'] ?? []); + if ($distinctId === null || $distinctId === '') { + $distinctId = $data['distinctId'] ?? null; + } + if ($this->enabled() && is_string($distinctId) && $distinctId !== '') { + $this->loadDefinitionsIfNeeded(); + } + return $this->client()->withContext( + $data, + fn () => $this->client()->$method(...$arguments), + ['fresh' => true], + ); + } +} diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php new file mode 100644 index 0000000..397bcfb --- /dev/null +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -0,0 +1,156 @@ +mergeConfigFrom(__DIR__ . '/../config/posthog.php', 'posthog'); + $this->app->scoped(Context::class); + $this->app->scoped(PostHog::class); + $this->app->alias(PostHog::class, 'posthog'); + $this->app->scoped(ExceptionReporter::class); + $this->app->scoped(RequestLifecycle::class); + $this->app->scoped(ConsoleLifecycle::class); + $this->app->extend(\Illuminate\Contracts\Console\Kernel::class, function ($kernel) { + return new ConsoleKernel($kernel); + }); + $this->app->scoped(\PostHog\Client::class, fn ($app) => $app->make(PostHog::class)->client()); + $this->app->scoped(FlagDefinitionCacheProvider::class, function ($app) { + $config = $app['config']->get('posthog'); + $key = 'posthog:definitions:' . hash('sha256', ($config['host'] ?? '') . ':' . ($config['api_key'] ?? '')); + return new FlagDefinitionCacheProvider( + $app['cache']->store($config['flag_cache']['store']), + $key, + $config['flag_cache']['ttl'], + $config['flag_cache']['refresh_interval'], + ); + }); + if (class_exists(AutocaptureServiceProvider::class)) { + $this->app->register(AutocaptureServiceProvider::class); + } + } + + public function boot(): void + { + $this->publishes([__DIR__ . '/../config/posthog.php' => config_path('posthog.php')], 'posthog-config'); + if ($this->app->runningInConsole()) { + $this->commands([ + Commands\DoctorCommand::class, + Commands\InstallCommand::class, + Commands\TestCommand::class, + ]); + } + $this->callAfterResolving(Kernel::class, function ($kernel) { + $kernel->prependMiddleware(RequestContextMiddleware::class); + if (method_exists($kernel, 'whenRequestLifecycleIsLongerThan')) { + $kernel->whenRequestLifecycleIsLongerThan(-INF, static function () { + Container::getInstance()->make(RequestLifecycle::class)->finish(); + }); + } + }); + $this->callAfterResolving(ExceptionHandler::class, function ($handler) { + if (method_exists($handler, 'reportable')) { + $handler->reportable(function (Throwable $exception) { + Container::getInstance()->make(ExceptionReporter::class)->report($exception, 'laravel.reportable'); + }); + } + }); + $events = $this->app['events']; + QueueContext::register(); + $events->listen(JobProcessing::class, function ($event) { + $app = Container::getInstance(); + $context = $app->make(Context::class); + $context->enter(); + $data = $event->job->payload()['_posthog'] ?? null; + if ($app['config']->get('posthog.queue_context', true) && is_array($data)) { + $context->begin(QueueContext::normalize($data)); + } + }); + $events->listen(JobExceptionOccurred::class, function ($event) { + Container::getInstance()->make(ExceptionReporter::class)->remember( + $event->exception, + 'task', + 'laravel.queue_job', + ); + }); + $events->listen(CommandStarting::class, function () { + // The first CLI kernel may already be handling input when package providers boot. + Container::getInstance()->make(ConsoleLifecycle::class)->startRoot(); + }); + foreach ( + [ + 'Laravel\\Octane\\Events\\RequestReceived', + 'Laravel\\Octane\\Events\\TaskReceived', + 'Laravel\\Octane\\Events\\TickReceived', + ] as $event + ) { + $events->listen($event, function ($event) { + $context = ($event->sandbox ?? $event->app ?? Container::getInstance())->make(Context::class); + if (isset($event->request)) { + $context->fromRequest($event->request); + } else { + $context->begin(); + } + }); + } + $events->listen('Laravel\\Octane\\Events\\WorkerErrorOccurred', function ($event) { + $event->sandbox->make(ExceptionReporter::class)->remember( + $event->exception, + 'runtime', + 'laravel.octane_worker_error', + ); + }); + // End-event consumers must observe the completed scope before cleanup restores its caller. + $this->app->booted(function () use ($events) { + foreach ([JobProcessed::class, JobExceptionOccurred::class] as $event) { + $events->listen($event, fn () => $this->finish(Container::getInstance(), true)); + } + foreach ( + [ + Looping::class, + 'Laravel\\Octane\\Events\\RequestTerminated', + 'Laravel\\Octane\\Events\\TaskTerminated', + 'Laravel\\Octane\\Events\\TickTerminated', + ] as $event + ) { + $events->listen($event, function ($event) { + $this->finish($event->sandbox ?? $event->app ?? Container::getInstance()); + }); + } + $events->listen('Laravel\\Octane\\Events\\WorkerErrorOccurred', function ($event) { + $this->finish($event->sandbox); + }); + }); + } + + private function finish(Container $app, bool $restore = false): void + { + $request = $app->make(RequestLifecycle::class); + if (!$restore && $request->active()) { + $request->finish(); + return; + } + try { + $app->make(PostHog::class)->flush(); + } catch (Throwable) { + // Lifecycle cleanup must continue when a transport fails. + } finally { + $context = $app->make(Context::class); + $restore ? $context->leave() : $context->end(); + } + } +} diff --git a/packages/laravel/src/QueueContext.php b/packages/laravel/src/QueueContext.php new file mode 100644 index 0000000..5f6d164 --- /dev/null +++ b/packages/laravel/src/QueueContext.php @@ -0,0 +1,46 @@ +get('posthog.queue_context', true) || !$app->make(PostHog::class)->enabled()) { + return []; + } + $data = self::normalize($app->make(Context::class)->data()); + return $data === [] ? [] : ['_posthog' => $data]; + } + + public static function normalize(array $data): array + { + $headers = []; + foreach (['distinctId' => 'X-PostHog-Distinct-Id', 'sessionId' => 'X-PostHog-Session-Id'] as $key => $header) { + if (isset($data[$key]) && is_scalar($data[$key])) { + $headers[$header] = (string) $data[$key]; + } + } + $context = CorePostHog::contextFromHeaders($headers); + $context = array_filter($context, static fn ($value) => $value !== null && $value !== []); + if (isset($data['groups']) && is_array($data['groups'])) { + foreach (array_slice($data['groups'], 0, 32, true) as $type => $value) { + if (is_string($type) && strlen($type) <= 100 && is_scalar($value) && strlen((string) $value) <= 1000) { + $context['groups'][$type] = (string) $value; + } + } + } + return $context; + } +} diff --git a/packages/laravel/src/RequestLifecycle.php b/packages/laravel/src/RequestLifecycle.php new file mode 100644 index 0000000..39e322c --- /dev/null +++ b/packages/laravel/src/RequestLifecycle.php @@ -0,0 +1,43 @@ +app->make(Context::class)->fromRequest($request); + $this->active = true; + } + + public function active(): bool + { + return $this->active; + } + + public function finish(): void + { + if (!$this->active) { + return; + } + $this->active = false; + try { + $this->app->make(PostHog::class)->flush(); + } catch (Throwable) { + // Failed telemetry delivery must not prevent scope cleanup. + } finally { + $this->app->make(Context::class)->end(); + } + } +} diff --git a/packages/laravel/src/Testing/PostHogFake.php b/packages/laravel/src/Testing/PostHogFake.php new file mode 100644 index 0000000..9f45b62 --- /dev/null +++ b/packages/laravel/src/Testing/PostHogFake.php @@ -0,0 +1,143 @@ +disabledClient ??= new Client(null, ['consumer' => 'noop'], loadFeatureFlags: false); + } + + protected function message(string $method, array $message): bool + { + $data = in_array($method, ['capture', 'captureException'], true) + ? $this->app->make(Context::class)->data() : []; + $message['distinctId'] ??= $data['distinctId'] ?? null; + $message['groups'] ??= $data['groups'] ?? []; + $message['properties'] = array_replace($data['properties'] ?? [], $message['properties'] ?? []); + $this->recorded[] = ['method' => $method, 'message' => $message]; + return true; + } + + public function captureException( + Throwable|string $exception, + ?string $distinctId = null, + array $properties = [], + array $options = [], + ): bool { + $data = $this->app->make(Context::class)->data(); + $properties += isset($data['groups']) ? ['$groups' => $data['groups']] : []; + return $this->message('captureException', [ + 'exception' => $exception, 'distinctId' => $distinctId, 'properties' => $properties, 'options' => $options, + ]); + } + + public function evaluateFlags( + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + bool $onlyEvaluateLocally = false, + ?bool $disableGeoip = null, + ?array $flagKeys = null, + ): FeatureFlagEvaluations { + return $this->flagSnapshot ?? new FeatureFlagEvaluations($distinctId ?? '', [], $groups, $this->client()); + } + + public function flags(FeatureFlagEvaluations $snapshot): static + { + $this->flagSnapshot = $snapshot; + return $this; + } + + public function recorded(?string $method = null): array + { + return array_values(array_filter( + $this->recorded, + fn ($item) => $method === null || $item['method'] === $method, + )); + } + + public function assertCaptured(string $event, ?callable $callback = null): void + { + $matches = array_filter( + $this->recorded('capture'), + fn ($item) => ($item['message']['event'] ?? null) === $event + && ($callback === null || $callback($item['message'])), + ); + Assert::assertNotEmpty($matches, 'Expected PostHog event was not captured: ' . $event); + } + + public function assertNotCaptured(string $event): void + { + Assert::assertEmpty(array_filter( + $this->recorded('capture'), + fn ($item) => ($item['message']['event'] ?? null) === $event, + )); + } + + public function assertCapturedCount(int $count): void + { + Assert::assertCount($count, $this->recorded('capture')); + } + + public function assertNothingCaptured(): void + { + Assert::assertSame([], $this->recorded); + } + + public function assertIdentified(?callable $callback = null): void + { + $this->assertRecorded('identify', $callback); + } + + public function assertAliased(?callable $callback = null): void + { + $this->assertRecorded('alias', $callback); + } + + public function assertGroupIdentified(?callable $callback = null): void + { + $this->assertRecorded('groupIdentify', $callback); + } + + public function assertExceptionCaptured(?callable $callback = null): void + { + $this->assertRecorded('captureException', $callback); + } + + private function assertRecorded(string $method, ?callable $callback): void + { + Assert::assertNotEmpty(array_filter( + $this->recorded($method), + fn ($item) => $callback === null || $callback($item['message']), + )); + } + + public function flush(): bool + { + return true; + } + + public function shutdown(): bool + { + return true; + } +} diff --git a/packages/laravel/tests/ApiSurfaceTest.php b/packages/laravel/tests/ApiSurfaceTest.php new file mode 100644 index 0000000..3974051 --- /dev/null +++ b/packages/laravel/tests/ApiSurfaceTest.php @@ -0,0 +1,194 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + } + + public static function unsupportedMethods(): array + { + return [ + 'transport metadata' => ['getFlagsEtag', []], + 'internal logger' => ['logWarning', ['not a Laravel API']], + 'exposure plumbing' => ['captureFlagCalledIfNeeded', ['user', 'flag', true, [], []]], + ]; + } + + #[DataProvider('unsupportedMethods')] + public function testCoreImplementationMethodsAreNotForwarded(string $method, array $arguments): void + { + $this->expectException(Error::class); + $this->app->make(PostHog::class)->$method(...$arguments); + } + + public function testFacadeDoesNotForwardCoreImplementationMethods(): void + { + $this->expectException(Error::class); + Facade::getFlagsEtag(); + } + + public function testNewClientMethodsAreNotAutomaticallyExposed(): void + { + $client = new class (null, ['consumer' => 'noop'], loadFeatureFlags: false) extends Client { + public function futureClientMethod(): string + { + return 'not a Laravel API'; + } + }; + $manager = $this->getMockBuilder(PostHog::class) + ->setConstructorArgs([$this->app]) + ->onlyMethods(['client']) + ->getMock(); + $manager->expects($this->atLeastOnce())->method('client')->willReturn($client); + $this->assertSame('not a Laravel API', $manager->client()->futureClientMethod()); + $this->expectException(Error::class); + $manager->futureClientMethod(); + } + + public static function flagMethods(): array + { + $single = [ + 'key' => 'checkout', 'distinctId' => 'explicit', 'groups' => ['team' => 'one'], + 'personProperties' => ['plan' => 'pro'], 'groupProperties' => ['team' => ['size' => 2]], + ]; + $options = ['onlyEvaluateLocally' => true, 'sendFeatureFlagEvents' => false]; + $methods = [ + 'boolean' => ['isFeatureEnabled', $single + $options, true], + 'value' => ['getFeatureFlag', $single + $options, 'variant'], + 'result' => ['getFeatureFlagResult', $single + $options, null], + 'payload' => ['getFeatureFlagPayload', $single, ['color' => 'blue']], + 'all' => ['getAllFlags', array_diff_key($single, ['key' => true]) + [ + 'onlyEvaluateLocally' => true, + ], ['checkout' => true]], + ]; + $cases = $methods; + foreach ($methods as $name => [$method, $arguments, $result]) { + $arguments['groups'] = []; + $cases['ambient groups ' . $name] = [$method, $arguments, $result]; + } + return $cases; + } + + #[DataProvider('flagMethods')] + public function testSupportedFlagMethodsDelegateNamedArgumentsAndRestoreContext( + string $method, + array $arguments, + mixed $result, + ): void { + $client = $this->getMockBuilder(Client::class) + ->setConstructorArgs(['project-key', ['consumer' => 'noop'], null, null, false]) + ->onlyMethods([$method]) + ->getMock(); + $expected = $arguments; + $expected['groups'] = $expected['groups'] ?: ['team' => 'ambient']; + $client->expects($this->once())->method($method)->willReturnCallback( + function (...$actual) use ($client, $expected, $result) { + $this->assertSame(array_values($expected), $actual); + $this->assertSame('ambient', $client->getContext()['distinctId']); + return $result; + }, + ); + $manager = $this->getMockBuilder(PostHog::class) + ->setConstructorArgs([$this->app]) + ->onlyMethods(['client']) + ->getMock(); + $manager->expects($this->atLeastOnce())->method('client')->willReturn($client); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient', 'groups' => ['team' => 'ambient']]); + $raw = $client->enterContext(['distinctId' => 'raw-parent']); + $before = $client->getContext(); + try { + $this->assertSame($result, $manager->$method(...$arguments)); + $this->assertSame($before, $client->getContext()); + } finally { + $raw->close(); + } + } + + public function testLegacyFlagEvaluationSendsAmbientGroupsWhenArgumentsAreOmitted(): void + { + $payload = null; + $http = $this->createMock(HttpClient::class); + $http->expects($this->once())->method('sendRequest')->willReturnCallback( + function ($path, $body) use (&$payload) { + $payload = json_decode($body, true); + return new HttpResponse(json_encode([ + 'flags' => ['checkout' => ['enabled' => true, 'variant' => null]], + ]), 200); + }, + ); + $client = new Client('project-key', ['consumer' => 'noop'], $http, loadFeatureFlags: false); + $manager = $this->getMockBuilder(PostHog::class) + ->setConstructorArgs([$this->app]) + ->onlyMethods(['client']) + ->getMock(); + $manager->expects($this->atLeastOnce())->method('client')->willReturn($client); + $this->app->make(Context::class)->begin(['distinctId' => 'user', 'groups' => ['team' => 'ambient']]); + $this->assertSame(['checkout' => true], $manager->getAllFlags()); + $this->assertSame(['team' => 'ambient'], $payload['groups']); + $this->assertSame('user', $payload['distinct_id']); + $this->assertNull($client->getContext()); + } + + public function testSupportedForwardersKeepTheCoreParameterSignatures(): void + { + $methods = [ + 'isFeatureEnabled', 'getFeatureFlag', 'getFeatureFlagResult', 'getFeatureFlagPayload', + 'getAllFlags', 'loadFlags', 'contextFromHeaders', 'captureException', 'evaluateFlags', + ]; + foreach ($methods as $method) { + $signature = static fn ($parameter) => [ + $parameter->getName(), (string) $parameter->getType(), $parameter->isOptional(), + $parameter->isPassedByReference(), $parameter->isVariadic(), + $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null, + ]; + $expected = array_map($signature, (new ReflectionMethod(Client::class, $method))->getParameters()); + if ($method === 'captureException') { + $this->assertSame('additionalProperties', $expected[2][0]); + $expected[2][0] = 'properties'; + } + $this->assertSame( + $expected, + array_map($signature, (new ReflectionMethod(PostHog::class, $method))->getParameters()), + $method, + ); + } + } + + public function testPublicApiMatchesItsSnapshot(): void + { + $root = dirname(__DIR__, 3); + require_once $root . '/scripts/PublicApiSnapshot.php'; + $builder = new ReflectionMethod(PublicApiSnapshot::class, 'buildPublicApi'); + $api = $builder->invoke(null, dirname(__DIR__) . '/src', 'PostHog\\Laravel\\', true); + $this->assertArrayNotHasKey(\PostHog\Laravel\ExceptionReporter::class, $api['symbols']); + $this->assertArrayNotHasKey('fromRequest', $api['symbols'][Context::class]['methods']); + $this->assertArrayHasKey('getFeatureFlagResult', $api['symbols'][PostHog::class]['methods']); + $this->assertSame( + file_get_contents($root . '/api/laravel-public-api.json'), + json_encode($api, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n", + ); + } +} diff --git a/packages/laravel/tests/CommandContextTest.php b/packages/laravel/tests/CommandContextTest.php new file mode 100644 index 0000000..3351933 --- /dev/null +++ b/packages/laravel/tests/CommandContextTest.php @@ -0,0 +1,240 @@ +set('posthog.api_key', 'test-key'); + $app['config']->set('posthog.secret_key', null); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('posthog.sdk.before_send', function ($event) { + $this->events[] = $event; + return null; + }); + } + + public static function childCalls(): iterable + { + foreach (['call', 'callSilent', 'callSilently'] as $method) { + foreach ([false, true] as $throws) { + yield $method . ($throws ? ' throwing' : ' returning') => [$method, $throws]; + } + } + } + + #[DataProvider('childCalls')] + public function testCallHelpersRestoreContextAndFlagsWithoutChangingCommandBehavior( + string $method, + bool $throws, + ): void { + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'caller']); + $parent = [ + 'distinctId' => 'parent', 'sessionId' => 'parent-session', + 'groups' => ['team' => 'parent-team'], 'properties' => ['component' => 'parent'], + ]; + $child = [ + 'distinctId' => 'child', 'sessionId' => 'child-session', + 'groups' => ['team' => 'child-team'], 'properties' => ['component' => 'child'], + ]; + $parentFlags = new FeatureFlagEvaluations('parent', [], [], PostHog::client()); + $childFlags = new FeatureFlagEvaluations('child', [], [], PostHog::client()); + $failure = new RuntimeException('child failed'); + $observed = []; + Artisan::command('posthog:child {--value=}', function () use ( + $context, + $child, + $childFlags, + $throws, + $failure, + &$observed, + ) { + $observed['inherited'] = $context->snapshot(); + $observed['freshFlags'] = $context->flags('snapshot', fn () => $childFlags) === $childFlags; + $context->begin($child); + PostHog::capture('child'); + $this->line($this->option('value')); + if ($throws) { + throw $failure; + } + return 7; + }); + Artisan::registerCommand(new ScopedCommand('posthog:parent', function (Command $command) use ( + $context, + $parent, + $parentFlags, + $childFlags, + $method, + &$observed, + ) { + $context->begin($parent); + $context->flags('snapshot', fn () => $parentFlags); + $observed['result'] = null; + $observed['exception'] = null; + try { + $observed['result'] = $command->{$method}('posthog:child', ['--value' => 'forwarded']); + } catch (RuntimeException $exception) { + $observed['exception'] = $exception; + report($exception); + } + $observed['after'] = $context->snapshot(); + $observed['flags'] = $context->flags('snapshot', fn () => $childFlags); + PostHog::capture('parent continued'); + return 0; + })); + + $this->assertSame(0, Artisan::call('posthog:parent')); + $this->assertSame($parent, $observed['after']); + $this->assertSame($parent, $observed['inherited']); + $this->assertTrue($observed['freshFlags']); + $this->assertSame($parentFlags, $observed['flags']); + $this->assertSame($throws ? null : 7, $observed['result']); + $this->assertSame($throws ? $failure : null, $observed['exception']); + $this->assertSame($method === 'call' ? 'forwarded' . PHP_EOL : '', Artisan::output()); + $this->assertSame(['distinctId' => 'caller'], $context->snapshot()); + $this->assertSame($throws ? ['child', 'child', 'parent'] : ['child', 'parent'], array_column( + $this->events, + 'distinct_id', + )); + $event = $this->events[array_key_last($this->events)]; + $this->assertSame('parent-session', $event['properties']['$session_id']); + $this->assertSame(['team' => 'parent-team'], $event['properties']['$groups']); + $this->assertSame('parent', $event['properties']['component']); + } + + public function testNestedCallsRestoreEachCallerAndKeepTheDeepestThrowableSnapshot(): void + { + $context = $this->app->make(Context::class); + $failure = new RuntimeException('grandchild failed'); + $observed = []; + $grandchild = new ScopedCommand('posthog:grandchild', function () use ($context, $failure) { + $context->begin(['distinctId' => 'grandchild']); + throw $failure; + }); + $this->app->bind(ScopedCommand::class, fn () => $grandchild); + $child = new ScopedCommand('posthog:child', function (Command $command) use ($context, &$observed) { + $context->begin(['distinctId' => 'child']); + try { + $command->call(ScopedCommand::class); + } catch (RuntimeException $exception) { + $observed['child'] = $context->snapshot(); + throw $exception; + } + return 0; + }); + Artisan::registerCommand(new ScopedCommand('posthog:parent', function (Command $command) use ( + $context, + $child, + &$observed, + ) { + $context->begin(['distinctId' => 'parent']); + try { + $command->call($child); + } catch (RuntimeException $exception) { + $observed['exception'] = $exception; + report($exception); + report($exception); + } + $observed['parent'] = $context->snapshot(); + PostHog::capture('parent continued'); + return 0; + })); + + $this->assertSame(0, Artisan::call('posthog:parent')); + $this->assertSame(['distinctId' => 'child'], $observed['child']); + $this->assertSame(['distinctId' => 'parent'], $observed['parent']); + $this->assertSame($failure, $observed['exception']); + $this->assertSame(['grandchild', 'parent'], array_column($this->events, 'distinct_id')); + $this->assertSame('$exception', $this->events[0]['event']); + $mechanism = $this->events[0]['properties']['$exception_list'][0]['mechanism']; + $this->assertFalse($mechanism['handled']); + $this->assertSame('runtime', $mechanism['type']); + $this->assertSame('laravel.console_lifecycle', $this->events[0]['properties']['$exception_source'] ?? null); + } + + public function testChildFailureExitCodeDoesNotReplaceRootIdentityThroughTermination(): void + { + $context = $this->app->make(Context::class); + $this->app->singleton(Kernel::class, fn ($app) => new ReportingConsoleKernel($app, $app['events'])); + Artisan::clearResolvedInstance(Kernel::class); + $kernel = $this->app->make(Kernel::class); + $kernel->rerouteSymfonyCommandEvents(); + Artisan::command('posthog:child', function () use ($context) { + $context->begin(['distinctId' => 'child']); + return 1; + }); + Artisan::registerCommand(new ScopedCommand('posthog:parent', function (Command $command) use ($context) { + $context->begin(['distinctId' => 'parent']); + $command->call('posthog:child'); + throw new RuntimeException('parent failed independently'); + })); + $this->app->terminating(fn () => PostHog::capture('terminating')); + $input = new ArrayInput(['command' => 'posthog:parent']); + + $this->assertSame(1, $kernel->handle($input, new NullOutput())); + $this->assertSame(['distinctId' => 'parent'], $context->snapshot()); + $kernel->terminate($input, 1); + $this->assertSame([], $context->snapshot()); + $this->assertSame(['$exception', 'terminating'], array_column($this->events, 'event')); + $this->assertSame(['parent', 'parent'], array_column($this->events, 'distinct_id')); + } + + public function testFlushFailureCannotMaskAChildThrowableOrPreventRestoration(): void + { + $manager = new class ($this->app) extends Manager { + public function flush(): bool + { + throw new RuntimeException('flush failed'); + } + }; + PostHog::swap($manager); + $context = $this->app->make(Context::class); + $failure = new RuntimeException('child failed'); + $observed = []; + Artisan::command('posthog:child', function () use ($context, $failure) { + $context->begin(['distinctId' => 'child']); + throw $failure; + }); + Artisan::registerCommand(new ScopedCommand('posthog:parent', function (Command $command) use ( + $context, + &$observed, + ) { + $context->begin(['distinctId' => 'parent']); + try { + $command->call('posthog:child'); + } catch (RuntimeException $exception) { + $observed = [$exception, $context->snapshot()]; + } + return 0; + })); + + $this->assertSame(0, Artisan::call('posthog:parent')); + $this->assertSame([$failure, ['distinctId' => 'parent']], $observed); + $this->assertSame([], $context->snapshot()); + } +} diff --git a/packages/laravel/tests/Fixtures/AmbientCaptureJob.php b/packages/laravel/tests/Fixtures/AmbientCaptureJob.php new file mode 100644 index 0000000..ff5dbb7 --- /dev/null +++ b/packages/laravel/tests/Fixtures/AmbientCaptureJob.php @@ -0,0 +1,17 @@ +events[] = $event; + return null; + } +} diff --git a/packages/laravel/tests/Fixtures/CaptureJob.php b/packages/laravel/tests/Fixtures/CaptureJob.php new file mode 100644 index 0000000..610eca4 --- /dev/null +++ b/packages/laravel/tests/Fixtures/CaptureJob.php @@ -0,0 +1,24 @@ +begin(['distinctId' => $this->id]); + $posthog->capture('job'); + } +} diff --git a/packages/laravel/tests/Fixtures/FailingCaptureJob.php b/packages/laravel/tests/Fixtures/FailingCaptureJob.php new file mode 100644 index 0000000..5259aae --- /dev/null +++ b/packages/laravel/tests/Fixtures/FailingCaptureJob.php @@ -0,0 +1,16 @@ + 'noop'], loadFeatureFlags: false); + } + + public function flush(): bool + { + $this->flushes++; + if ($this->fail) { + throw new RuntimeException('transport failure'); + } + return true; + } +} diff --git a/packages/laravel/tests/Fixtures/GroupsResolver.php b/packages/laravel/tests/Fixtures/GroupsResolver.php new file mode 100644 index 0000000..ab863bd --- /dev/null +++ b/packages/laravel/tests/Fixtures/GroupsResolver.php @@ -0,0 +1,11 @@ + 'resolved-team']; + } +} diff --git a/packages/laravel/tests/Fixtures/IdentityResolver.php b/packages/laravel/tests/Fixtures/IdentityResolver.php new file mode 100644 index 0000000..3e32bb9 --- /dev/null +++ b/packages/laravel/tests/Fixtures/IdentityResolver.php @@ -0,0 +1,11 @@ +signature = $signature; + parent::__construct(); + } + + public function handle(): int + { + return ($this->action)($this); + } +} diff --git a/packages/laravel/tests/Fixtures/ScopedFailingJob.php b/packages/laravel/tests/Fixtures/ScopedFailingJob.php new file mode 100644 index 0000000..1b0c682 --- /dev/null +++ b/packages/laravel/tests/Fixtures/ScopedFailingJob.php @@ -0,0 +1,17 @@ +with(['distinctId' => $this->id, 'groups' => ['team' => 'job-team']], function () { + throw new RuntimeException('scoped failure'); + }); + } +} diff --git a/packages/laravel/tests/Fixtures/TransportErrorHandler.php b/packages/laravel/tests/Fixtures/TransportErrorHandler.php new file mode 100644 index 0000000..0d96a13 --- /dev/null +++ b/packages/laravel/tests/Fixtures/TransportErrorHandler.php @@ -0,0 +1,10 @@ +set('posthog.api_key', 'test-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('queue.default', 'sync'); + } + + public function testAfterResponseAndLateTerminatingJobsKeepRequestIdentityAndSession(): void + { + $fake = PostHog::fake(); + Route::get('/deferred', function () { + $this->app['auth']->setUser(new GenericUser(['id' => 'request-owner'])); + Bus::dispatchAfterResponse(new AmbientCaptureJob()); + $this->app->terminating(function () { + Bus::dispatchAfterResponse(new AmbientCaptureJob()); + }); + return 'ok'; + }); + + $this->get('/deferred', ['X-PostHog-Session-Id' => 'request-session'])->assertOk(); + $messages = array_column($fake->recorded('capture'), 'message'); + $this->assertCount(2, $messages); + foreach ($messages as $message) { + $this->assertSame('request-owner', $message['distinctId']); + $this->assertSame('request-session', $message['properties']['$session_id']); + } + $this->assertSame([], $this->app->make(Context::class)->snapshot()); + } + + public function testChildFailureReturnCannotIdentifyAnIndependentlyThrowingParent(): void + { + $fake = PostHog::fake(); + $kernel = $this->reportingKernel(); + Artisan::command('posthog:child', function () { + app(Context::class)->begin(['distinctId' => 'child']); + return 1; + }); + Artisan::command('posthog:parent', function () { + app(Context::class)->begin(['distinctId' => 'parent']); + Artisan::call('posthog:child'); + throw new RuntimeException('parent failed'); + }); + $input = new ArrayInput(['command' => 'posthog:parent']); + $this->assertSame(1, $kernel->handle($input, new NullOutput())); + $kernel->terminate($input, 1); + $fake->assertExceptionCaptured(fn ($event) => $event['distinctId'] === 'parent' + && $event['options']['handled'] === false); + $this->assertSame([], $this->app->make(Context::class)->snapshot()); + } + + public function testThrowingChildKeepsItsOwnExceptionSnapshotAfterCallerCatchesIt(): void + { + $fake = PostHog::fake(); + $kernel = $this->reportingKernel(); + Artisan::command('posthog:throwing-child', function () { + app(Context::class)->begin(['distinctId' => 'child']); + throw new RuntimeException('child failed'); + }); + Artisan::command('posthog:catching-parent', function () { + app(Context::class)->begin(['distinctId' => 'parent']); + try { + Artisan::call('posthog:throwing-child'); + } catch (RuntimeException $exception) { + report($exception); + } + PostHog::capture('parent continued'); + return 0; + }); + $input = new ArrayInput(['command' => 'posthog:catching-parent']); + $this->assertSame(0, $kernel->handle($input, new NullOutput())); + $kernel->terminate($input, 0); + $fake->assertExceptionCaptured(fn ($event) => $event['distinctId'] === 'child'); + $fake->assertCaptured('parent continued', fn ($event) => $event['distinctId'] === 'parent'); + } + + public function testNestedCommandsDeferFlushingUntilHttpOrRootConsoleTermination(): void + { + $client = new \PostHog\Laravel\Tests\Fixtures\FlushClient(); + $manager = $this->app->make(\PostHog\Laravel\PostHog::class); + (new \ReflectionProperty($manager, 'client'))->setValue($manager, $client); + $kernel = $this->reportingKernel(); + Artisan::command('posthog:child', fn () => 0); + Artisan::command('posthog:parent', function () use ($client) { + Artisan::call('posthog:child'); + \PHPUnit\Framework\Assert::assertSame(0, $client->flushes); + return 0; + }); + Route::get('/nested-command', function () use ($client) { + Artisan::call('posthog:parent'); + $this->assertSame(0, $client->flushes); + return 'ok'; + }); + $this->get('/nested-command')->assertOk(); + $this->assertSame(1, $client->flushes); + + $client->flushes = 0; + $input = new ArrayInput(['command' => 'posthog:parent']); + $this->assertSame(0, $kernel->handle($input, new NullOutput())); + $this->assertSame(0, $client->flushes); + $kernel->terminate($input, 0); + $this->assertSame(1, $client->flushes); + + $client->flushes = 0; + $this->assertSame(0, Artisan::call('posthog:parent')); + $this->assertSame(1, $client->flushes); + } + + private function reportingKernel(): ConsoleKernel + { + $this->app->singleton(ConsoleKernel::class, fn ($app) => new ReportingConsoleKernel($app, $app['events'])); + Artisan::clearResolvedInstance(ConsoleKernel::class); + $kernel = $this->app->make(ConsoleKernel::class); + $kernel->rerouteSymfonyCommandEvents(); + return $kernel; + } +} diff --git a/packages/laravel/tests/OctaneTest.php b/packages/laravel/tests/OctaneTest.php new file mode 100644 index 0000000..d34b615 --- /dev/null +++ b/packages/laravel/tests/OctaneTest.php @@ -0,0 +1,88 @@ +app; + $root['config']->set('posthog.api_key', 'project-key'); + $root['config']->set('posthog.sdk.consumer', 'noop'); + $clients = []; + try { + foreach (['first', 'second'] as $id) { + $sandbox = clone $root; + CurrentApplication::set($sandbox); + $request = Request::create('/', server: ['HTTP_X_POSTHOG_DISTINCT_ID' => $id]); + $sandbox['events']->dispatch(new RequestReceived($root, $sandbox, $request)); + $context = $sandbox->make(Context::class); + $this->assertSame($id, $context->data()['distinctId']); + $clients[] = $sandbox->make(PostHog::class)->client(); + $this->assertSame($clients[array_key_last($clients)], $sandbox->make(\PostHog\Client::class)); + $sandbox['events']->dispatch(new RequestTerminated($root, $sandbox, $request, new Response())); + $this->assertSame([], $context->data()); + CurrentApplication::set($root); + } + $this->assertNotSame($clients[0], $clients[1]); + } finally { + CurrentApplication::set($root); + } + } + + public function testWorkerFailureReportsThroughDontReportPolicyAndCleansContext(): void + { + $sandbox = $this->app; + $fake = Facade::fake(); + $sandbox['events']->dispatch(new TaskReceived($sandbox, $sandbox, null)); + $sandbox->make(Context::class)->begin(['distinctId' => 'failing-task']); + $exception = new RuntimeException('failure'); + $sandbox['events']->dispatch(new WorkerErrorOccurred($exception, $sandbox)); + $sandbox->make(ExceptionHandler::class)->report($exception); + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'failing-task' + && !$message['options']['handled'] + && ($message['options']['source'] ?? null) === 'laravel.octane_worker_error' + ); + $this->assertSame([], $sandbox->make(Context::class)->data()); + $sandbox->make(ExceptionHandler::class)->report($exception); + $this->assertCount(1, $fake->recorded('captureException')); + $sandbox->make(ExceptionHandler::class)->dontReport([IgnoredOctaneFailure::class]); + $ignored = new IgnoredOctaneFailure(); + $sandbox['events']->dispatch(new WorkerErrorOccurred($ignored, $sandbox)); + $sandbox->make(ExceptionHandler::class)->report($ignored); + $this->assertCount(1, $fake->recorded('captureException')); + $sandbox->instance('env', 'production'); + $sandbox->make(Context::class)->begin(['distinctId' => 'early-report']); + $earlyEvent = new WorkerErrorOccurred(new RuntimeException('early failure'), $sandbox); + (new \Laravel\Octane\Listeners\ReportException())->handle($earlyEvent); + $sandbox['events']->dispatch($earlyEvent); + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'early-report' && !$message['options']['handled'] + && ($message['options']['source'] ?? null) === 'laravel.reportable', + ); + $this->assertCount(2, $fake->recorded('captureException')); + } +} diff --git a/packages/laravel/tests/QueueContextTest.php b/packages/laravel/tests/QueueContextTest.php new file mode 100644 index 0000000..10f1d83 --- /dev/null +++ b/packages/laravel/tests/QueueContextTest.php @@ -0,0 +1,54 @@ +set('queue.default', 'sync'); + } + + public function testJobPayloadCarriesOnlyAllowlistedContextAndRestoresParent(): void + { + $fake = PostHog::fake(); + $context = $this->app->make(Context::class); + foreach (['first', 'second'] as $id) { + $context->begin([ + 'distinctId' => $id, + 'sessionId' => 'session-' . $id, + 'groups' => ['team' => $id], + 'properties' => ['secret' => 'never-propagate'], + ]); + Bus::dispatchSync(new AmbientCaptureJob()); + $fake->assertCaptured('ambient job', fn ($event) => $event['distinctId'] === $id + && $event['groups'] === ['team' => $id] + && $event['properties'] === ['$session_id' => 'session-' . $id]); + $this->assertSame($id, $context->data()['distinctId']); + $this->assertSame('never-propagate', $context->data()['properties']['secret']); + } + $this->assertCount(2, $fake->recorded('capture')); + } + + public function testJobPropagationCanBeDisabled(): void + { + $this->app['config']->set('posthog.queue_context', false); + $fake = PostHog::fake(); + $this->app->make(Context::class)->begin(['distinctId' => 'parent']); + Bus::dispatchSync(new AmbientCaptureJob()); + $fake->assertCaptured('ambient job', fn ($event) => $event['distinctId'] === null + && $event['groups'] === [] && $event['properties'] === []); + } +} diff --git a/packages/laravel/tests/RuntimeContextTest.php b/packages/laravel/tests/RuntimeContextTest.php new file mode 100644 index 0000000..ff17702 --- /dev/null +++ b/packages/laravel/tests/RuntimeContextTest.php @@ -0,0 +1,540 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + } + + public function testThrowingIdentityResolverStillUsesAuthAndGroups(): void + { + $this->app->bind('throwing-identity', fn () => fn () => throw new RuntimeException('resolver')); + $this->app['config']->set('posthog.distinct_id', 'throwing-identity'); + $this->app['config']->set('posthog.groups', GroupsResolver::class); + $context = $this->app->make(Context::class); + foreach ([[], ['HTTP_X_POSTHOG_DISTINCT_ID' => 'untrusted']] as $headers) { + $request = Request::create('/', server: $headers); + $request->setUserResolver(fn () => new GenericUser(['id' => 'authenticated'])); + $context->fromRequest($request); + $this->assertSame('authenticated', $context->data()['distinctId']); + $this->assertSame(['team' => 'resolved-team'], $context->data()['groups']); + $context->with(['distinctId' => 'explicit'], function () use ($context) { + $this->assertSame('explicit', $context->data()['distinctId']); + }); + $request->setUserResolver(fn () => throw new RuntimeException('auth')); + $this->assertSame(['team' => 'resolved-team'], $context->data()['groups']); + } + } + + public function testScopedSessionOverrideReplacesInheritedHeaderSession(): void + { + $events = []; + $this->app['config']->set('posthog.sdk.before_send', function ($event) use (&$events) { + $events[] = $event; + return null; + }); + $context = $this->app->make(Context::class); + $context->fromRequest(Request::create('/', server: ['HTTP_X_POSTHOG_SESSION_ID' => 'old'])); + $manager = $this->app->make(PostHog::class); + $context->with(['sessionId' => 'new'], fn () => $manager->capture('scoped', [], 'user')); + $context->with( + ['sessionId' => 'new', 'properties' => ['$session_id' => 'explicit']], + fn () => $manager->capture('explicit', [], 'user'), + ); + $manager->capture('outer', [], 'user'); + $this->assertSame( + ['new', 'explicit', 'old'], + array_map(fn ($event) => $event['properties']['$session_id'], $events), + ); + } + + public function testAutomaticReportingDefersFlushToLifecycleCleanup(): void + { + $client = new FlushClient(); + $manager = $this->app->make(PostHog::class); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $client); + $this->app->make(ExceptionReporter::class)->report(new RuntimeException('failure')); + $this->assertSame(0, $client->flushes); + } + + public function testContextAliasesPropagateToCapturesAndQueuePayloads(): void + { + $events = []; + $this->app['config']->set('posthog.sdk.before_send', function ($event) use (&$events) { + $events[] = $event; + return null; + }); + $context = $this->app->make(Context::class); + $context->fromRequest(Request::create('/', server: [ + 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user', 'HTTP_X_POSTHOG_SESSION_ID' => 'header-session', + ])); + $request = $context->snapshot(); + $manager = $this->app->make(PostHog::class); + foreach ( + [ + ['distinct_id' => 'scoped-user', 'session_id' => 'scoped-session'], + [ + 'distinct_id' => 'ignored-user', 'session_id' => 'ignored-session', + 'distinctId' => 'scoped-user', 'sessionId' => 'scoped-session', + ], + ] as $data + ) { + $context->with($data, function () use ($context, $manager) { + $this->assertSame('scoped-user', $context->data()['distinctId']); + $this->assertSame('scoped-session', $context->data()['sessionId']); + $this->assertSame([ + 'distinctId' => 'scoped-user', 'sessionId' => 'scoped-session', + 'properties' => ['$session_id' => 'scoped-session'], + ], \PostHog\Laravel\QueueContext::payload()['_posthog']); + $manager->capture('scoped'); + }); + $this->assertSame($request, $context->snapshot()); + } + $this->assertSame(['scoped-user', 'scoped-user'], array_column($events, 'distinct_id')); + foreach ($events as $event) { + $this->assertSame('scoped-session', $event['properties']['$session_id']); + } + } + + public function testResolverDiagnosticsAreDebugOnlyBoundedAndExcludeSensitiveMessages(): void + { + $messages = []; + $logger = $this->createMock(\Psr\Log\LoggerInterface::class); + $logger->expects($this->exactly(9))->method('debug')->willReturnCallback( + function ($message, $data) use (&$messages) { + $messages[] = [$message, $data]; + }, + ); + $this->app->instance('log', $logger); + $this->app->bind('bad-identity', fn () => fn () => throw new RuntimeException('secret-token')); + $this->app->bind('bad-groups', fn () => fn () => throw new RuntimeException('private-groups')); + $this->app['config']->set('posthog.distinct_id', 'bad-identity'); + $this->app['config']->set('posthog.groups', 'bad-groups'); + $request = Request::create('/'); + $request->setUserResolver(fn () => throw new RuntimeException('private-user')); + $context = $this->app->make(Context::class); + $context->fromRequest($request); + $context->data(); + $this->assertSame([], $messages); + $this->app['config']->set('posthog.sdk.debug', true); + for ($i = 0; $i < 3; $i++) { + $context->data(); + } + $this->assertCount(3, $messages); + foreach ($messages as [$message, $data]) { + $this->assertStringContainsString('[PostHog]', $message); + $this->assertSame(['exception_class' => RuntimeException::class], $data); + } + $this->assertStringNotContainsString('secret-token', json_encode($messages)); + $this->assertStringNotContainsString('private-', json_encode($messages)); + $context->with([], fn () => $context->data()); + $this->assertCount(6, $messages); + $context->data(); + $this->assertCount(6, $messages); + $context->fromRequest($request); + $context->data(); + $this->assertCount(9, $messages); + } + + public function testFailingDiagnosticLoggerDoesNotPreventAuthFallback(): void + { + $logger = $this->createMock(\Psr\Log\LoggerInterface::class); + $logger->expects($this->once())->method('debug')->willThrowException(new RuntimeException('logger')); + $this->app->instance('log', $logger); + $this->app['config']->set('posthog.sdk.debug', true); + $this->app->bind('bad-identity', fn () => fn () => throw new RuntimeException('resolver')); + $this->app['config']->set('posthog.distinct_id', 'bad-identity'); + $request = Request::create('/'); + $request->setUserResolver(fn () => new GenericUser(['id' => 'authenticated'])); + $context = $this->app->make(Context::class); + $context->fromRequest($request); + $this->assertSame('authenticated', $context->data()['distinctId']); + $this->assertSame('authenticated', $context->data()['distinctId']); + } + + public function testManualReportUsesGenericMechanismWithoutInventingAMiddlewareBoundary(): void + { + $events = []; + $this->app['config']->set('posthog.sdk.before_send', function ($event) use (&$events) { + $events[] = $event; + return null; + }); + report(new RuntimeException('manual')); + $this->assertCount(1, $events); + $this->assertSame('generic', $events[0]['properties']['$exception_list'][0]['mechanism']['type']); + $this->assertTrue($events[0]['properties']['$exception_list'][0]['mechanism']['handled']); + $this->assertSame('laravel.reportable', $events[0]['properties']['$exception_source']); + } + + public function testInvokableErrorHandlerConstructsRealLibCurlConsumer(): void + { + $this->app['config']->set('posthog.sdk.consumer', 'lib_curl'); + $this->app['config']->set('posthog.sdk.error_handler', TransportErrorHandler::class); + // Drop the event before dispatch: construction must still exercise LibCurl/HttpClient. + $this->app['config']->set('posthog.sdk.before_send', fn () => null); + $manager = $this->app->make(PostHog::class); + $this->assertInstanceOf(Client::class, $manager->client()); + $this->assertFalse($manager->capture('dropped', [], 'diagnostic')); + $this->assertTrue($manager->flush()); + $this->app['config']->set('posthog.sdk.error_handler', Closure::fromCallable(new TransportErrorHandler())); + $this->assertInstanceOf(Client::class, (new PostHog($this->app))->client()); + } + + public function testDiagnosticSendIsIsolatedAndRestoresBothParents(): void + { + $events = []; + $this->app['config']->set('posthog.sdk.consumer', 'lib_curl'); + $this->app['config']->set('posthog.sdk.before_send', function ($event) use (&$events) { + $events[] = $event; + return null; + }); + $calls = 0; + $this->app->bind('diagnostic-resolver', function () use (&$calls) { + return function () use (&$calls) { + $calls++; + return 'resolver-private'; + }; + }); + $this->app->bind('diagnostic-groups', function () use (&$calls) { + return function () use (&$calls) { + $calls++; + return ['resolver' => 'private-group']; + }; + }); + $this->app['config']->set('posthog.distinct_id', 'diagnostic-resolver'); + $this->app['config']->set('posthog.groups', 'diagnostic-groups'); + $manager = $this->app->make(PostHog::class); + $context = $this->app->make(Context::class); + $context->begin([ + 'distinctId' => 'parent', 'sessionId' => 'private-session', + 'groups' => ['team' => 'private-team'], 'properties' => ['email' => 'private@example.test'], + ]); + $before = $context->snapshot(); + $flags = $context->flags('parent', fn () => $manager->evaluateFlags(flagKeys: [])); + $raw = $manager->client()->enterContext(['distinctId' => 'raw-parent', 'properties' => ['raw' => 'private']]); + $rawBefore = $manager->client()->getContext(); + $calls = 0; + $this->assertSame(1, Artisan::call('posthog:test')); + $this->assertSame([], $events); + // The intentional before_send drop reports failure without dispatching network traffic. + $this->assertSame(1, Artisan::call('posthog:test', ['--send' => true])); + $this->assertCount(1, $events); + // Command boundaries resolve their own snapshot; the diagnostic capture itself must not. + $calls = 0; + $command = new \PostHog\Laravel\Commands\TestCommand(); + $command->setLaravel($this->app); + $command->run( + new \Symfony\Component\Console\Input\ArrayInput(['--send' => true]), + new \Symfony\Component\Console\Output\NullOutput(), + ); + $this->assertSame(0, $calls); + $this->assertCount(2, $events); + foreach ($events as $event) { + $this->assertStringStartsWith('posthog-test-', $event['distinct_id']); + foreach (['$session_id', '$groups', 'email', 'raw'] as $key) { + $this->assertArrayNotHasKey($key, $event['properties']); + } + } + $this->assertSame($rawBefore, $manager->client()->getContext()); + $this->assertSame($before, $context->snapshot()); + $this->assertSame($flags, $context->flags('parent', fn () => throw new RuntimeException('lost snapshot'))); + $raw->close(); + } + + public function testManagerScopesBridgeRawClientAndRestoreSnapshots(): void + { + $events = []; + $this->app['config']->set('posthog.sdk.before_send', function ($event) use (&$events) { + $events[] = $event; + return $event; + }); + $manager = $this->app->make(PostHog::class); + $context = $this->app->make(Context::class); + $context->begin([ + 'distinctId' => 'parent', 'sessionId' => 'parent-session', 'groups' => ['team' => 'parent-team'], + 'properties' => ['parent' => true, 'nested' => ['old' => true]], + ]); + $before = $context->snapshot(); + $flags = $context->flags('parent', fn () => $manager->evaluateFlags(flagKeys: [])); + $raw = $manager->client()->enterContext(['distinctId' => 'raw-parent', 'properties' => ['raw' => true]]); + $rawBefore = $manager->client()->getContext(); + Facade::withContext([ + 'distinct_id' => 'scope', 'session_id' => 'scope-session', + 'properties' => ['nested' => ['new' => true]], 'groups' => ['team' => 'scope-team'], + ], function () use ($manager) { + $this->assertSame('scope', Facade::getContext()['distinctId']); + $this->assertSame(Facade::getContext(), $manager->client()->getContext()); + Facade::capture('manager'); + $manager->client()->capture(['event' => 'raw']); + $this->assertSame(['new' => true], Facade::getContext()['properties']['nested']); + $this->assertTrue(Facade::getContext()['properties']['raw']); + $this->assertTrue(Facade::getContext()['properties']['parent']); + try { + Facade::withContext(['distinctId' => 'inner'], fn () => throw new RuntimeException('restore')); + } catch (RuntimeException) { + } + $this->assertSame('scope', Facade::getContext()['distinctId']); + }); + $this->assertSame(['scope', 'scope'], array_column($events, 'distinct_id')); + $this->assertSame('scope-session', $events[0]['properties']['$session_id']); + $this->assertSame(['team' => 'scope-team'], $events[0]['properties']['$groups']); + $this->assertSame($before, $context->snapshot()); + $this->assertSame($rawBefore, $manager->client()->getContext()); + $this->assertSame($flags, $context->flags('parent', fn () => throw new RuntimeException('lost snapshot'))); + $outer = Facade::enterContext(['distinctId' => 'outer']); + $inner = Facade::enterContext(['distinctId' => 'inner']); + $outer->close(); + $outer->close(); + $this->assertSame('inner', Facade::getContext()['distinctId']); + Facade::capture('entered'); + $this->assertSame('inner', $events[2]['distinct_id']); + $inner->close(); + $inner->close(); + $this->assertSame($before, $context->snapshot()); + $this->assertSame($rawBefore, $manager->client()->getContext()); + $raw->close(); + $this->assertSame('parent', Facade::getContext()['distinctId']); + $this->assertNull($manager->client()->getContext()); + } + + public function testManagerGetContextOverlaysRawContextWithoutChangingEitherParent(): void + { + $manager = $this->app->make(PostHog::class); + $client = $manager->client(); + $context = $this->app->make(Context::class); + $context->begin([ + 'distinctId' => 'laravel', 'sessionId' => 'laravel-session', + 'properties' => ['shared' => 'laravel', 'laravelOnly' => true, 'nested' => ['laravel' => true]], + 'groups' => ['team' => 'laravel-team'], + ]); + $laravelBefore = $context->snapshot(); + $flags = $context->flags('parent', fn () => $manager->evaluateFlags(flagKeys: [])); + $raw = $client->enterContext([ + 'distinctId' => 'raw', 'sessionId' => 'raw-session', + 'properties' => ['shared' => 'raw', 'rawOnly' => true, 'nested' => ['raw' => true]], + ]); + $rawBefore = $client->getContext(); + try { + $expected = [ + 'distinctId' => 'raw', 'sessionId' => 'raw-session', + 'properties' => [ + '$session_id' => 'raw-session', 'shared' => 'raw', 'laravelOnly' => true, + 'nested' => ['raw' => true], 'rawOnly' => true, + ], + ]; + $this->assertSame($expected, $manager->getContext()); + $this->assertSame($expected, Facade::getContext()); + $this->assertSame($laravelBefore, $context->snapshot()); + $this->assertSame($rawBefore, $client->getContext()); + $this->assertSame($flags, $context->flags('parent', fn () => throw new RuntimeException('lost snapshot'))); + $this->assertSame($expected, $manager->withContext([], fn () => $client->getContext())); + } finally { + $raw->close(); + } + $this->assertSame([ + 'distinctId' => 'laravel', 'sessionId' => 'laravel-session', + 'properties' => [ + '$session_id' => 'laravel-session', 'shared' => 'laravel', 'laravelOnly' => true, + 'nested' => ['laravel' => true], + ], + ], $manager->getContext()); + $this->assertSame($laravelBefore, $context->snapshot()); + $this->assertNull($client->getContext()); + $this->assertSame($flags, $context->flags('parent', fn () => throw new RuntimeException('lost snapshot'))); + } + + public function testFreshManagerScopeSuppressesRequestAndResolvers(): void + { + $manager = $this->app->make(PostHog::class); + $context = $this->app->make(Context::class); + $request = Request::create('/private', server: ['HTTP_X_POSTHOG_DISTINCT_ID' => 'header']); + $request->setUserResolver(fn () => new GenericUser(['id' => 'auth'])); + $context->fromRequest($request); + $this->app['config']->set('posthog.groups', GroupsResolver::class); + $calls = 0; + $this->app->bind('fresh-resolver', function () use (&$calls) { + return function () use (&$calls) { + $calls++; + return null; + }; + }); + $this->app['config']->set('posthog.distinct_id', 'fresh-resolver'); + $before = $context->snapshot(); + $calls = 0; + Facade::withContext([], function () use ($manager, $context, &$calls) { + $this->assertSame(['distinctId' => null, 'sessionId' => null, 'properties' => []], Facade::getContext()); + $this->assertSame(Facade::getContext(), $manager->client()->getContext()); + $this->assertArrayNotHasKey('groups', $context->data()); + $this->assertSame([], $manager->evaluateFlags()->getKeys()); + $this->assertSame(0, $calls); + }, ['fresh' => true]); + $this->assertSame($before, $context->snapshot()); + } + + public function testAutomaticReportingUsesCaptureExceptionAndRestoresContextWhenItThrows(): void + { + $manager = $this->getMockBuilder(PostHog::class) + ->setConstructorArgs([$this->app]) + ->onlyMethods(['captureException']) + ->getMock(); + $this->app->instance(PostHog::class, $manager); + $context = $this->app->make(Context::class); + $context->begin([ + 'distinctId' => 'origin', 'sessionId' => 'origin-session', + 'groups' => ['team' => 'origin-team'], 'properties' => ['component' => 'origin'], + ]); + $exception = new RuntimeException('original failure'); + $reporter = $this->app->make(ExceptionReporter::class); + $reporter->remember($exception, 'task'); + $context->begin(['distinctId' => 'later', 'groups' => ['team' => 'later-team']]); + $before = $context->snapshot(); + $flags = $context->flags('later', fn () => $manager->evaluateFlags(flagKeys: [])); + $raw = $manager->client()->enterContext(['distinctId' => 'raw-later']); + $rawBefore = $manager->client()->getContext(); + $observed = null; + $manager->expects($this->once())->method('captureException')->willReturnCallback( + function ($captured, $distinctId, $properties, $options) use ($manager, $context, &$observed) { + $observed = [$captured, $context->data(), $manager->client()->getContext(), $options]; + throw new RuntimeException('capture failed'); + }, + ); + try { + $reporter->report($exception); + $reporter->report($exception); + $this->assertNotNull($observed); + $this->assertSame($exception, $observed[0]); + $this->assertSame('origin', $observed[1]['distinctId']); + $this->assertSame('origin-session', $observed[1]['sessionId']); + $this->assertSame(['team' => 'origin-team'], $observed[1]['groups']); + $this->assertSame('origin', $observed[1]['properties']['component']); + $this->assertSame('origin', $observed[2]['distinctId']); + $this->assertFalse($observed[3]['handled']); + $this->assertSame('task', $observed[3]['mechanism']); + $this->assertSame($before, $context->snapshot()); + $this->assertSame($rawBefore, $manager->client()->getContext()); + $this->assertSame($flags, $context->flags('later', fn () => throw new RuntimeException('lost flags'))); + } finally { + $raw->close(); + } + } + + public function testKnownExceptionSourceSurvivesLaterBoundariesButDoesNotLeakIntoManualCaptures(): void + { + $events = []; + $this->app['config']->set('posthog.sdk.before_send', function ($event) use (&$events) { + $events[] = $event; + return null; + }); + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'origin']); + $exception = new RuntimeException('original failure'); + $reporter = $this->app->make(ExceptionReporter::class); + $reporter->remember($exception, 'task', 'laravel.queue_job'); + $context->begin(['distinctId' => 'later']); + $reporter->remember($exception, 'runtime', 'laravel.console_lifecycle'); + $handler = $this->app->make(\Illuminate\Contracts\Debug\ExceptionHandler::class); + $handler->report($exception); + $handler->report($exception); + $this->assertCount(1, $events); + $this->assertSame('origin', $events[0]['distinct_id']); + $this->assertSame('laravel.queue_job', $events[0]['properties']['$exception_source'] ?? null); + $this->assertSame('task', $events[0]['properties']['$exception_list'][0]['mechanism']['type']); + $this->assertSame(['distinctId' => 'later'], $context->snapshot()); + $manager = $this->app->make(PostHog::class); + $manager->captureException($exception, properties: ['$exception_source' => 'forged']); + $this->assertCount(2, $events); + $this->assertSame('later', $events[1]['distinct_id']); + $this->assertArrayNotHasKey('$exception_source', $events[1]['properties']); + $reporter->report(new RuntimeException('unknown hook')); + $this->assertCount(3, $events); + $this->assertArrayNotHasKey('$exception_source', $events[2]['properties']); + $this->assertSame(['distinctId' => 'later'], $context->snapshot()); + $this->assertNull($manager->client()->getContext()); + } + + public function testDelayedFlagExposuresKeepOriginAfterRequestSwitchAndEnd(): void + { + $events = []; + $http = $this->createMock(HttpClient::class); + $http->expects($this->exactly(2))->method('sendRequest')->willReturn(new HttpResponse(json_encode([ + 'flags' => ['checkout' => ['enabled' => true, 'variant' => 'test']], + ]), 200)); + $sdk = new Client('project-key', [ + 'consumer' => 'noop', + 'before_send' => function ($event) use (&$events) { + $events[] = $event; + return $event; + }, + ], $http, loadFeatureFlags: false); + $manager = new class ($this->app, $sdk) extends PostHog { + public function __construct(Container $app, private Client $testClient) + { + parent::__construct($app); + } + + public function client(): Client + { + return $this->testClient; + } + }; + $context = $this->app->make(Context::class); + foreach (['isEnabled', 'getFlag'] as $accessor) { + $request = Request::create('/origin/123', server: [ + 'HTTP_X_POSTHOG_DISTINCT_ID' => 'origin-' . $accessor, + 'HTTP_X_POSTHOG_SESSION_ID' => 'origin-session', + ]); + $request->setRouteResolver(fn () => new Route('GET', 'origin/{id}', fn () => null)); + $context->fromRequest($request); + $flags = $manager->evaluateFlags(); + $this->assertSame($flags, $manager->evaluateFlags()); + $request->headers->set('X-PostHog-Session-Id', 'mutated'); + $context->begin(['distinctId' => 'next', 'sessionId' => 'next-session']); + $raw = $sdk->enterContext(['distinctId' => 'raw-next', 'sessionId' => 'raw-next-session']); + $before = $sdk->getContext(); + if ($accessor === 'getFlag') { + $context->end(); + } + $flags->$accessor('checkout'); + $flags->only(['checkout'])->$accessor('checkout'); + $flags->$accessor('checkout'); + $this->assertSame($before, $sdk->getContext()); + $raw->close(); + } + $this->assertCount(2, $events); + foreach ($events as $event) { + $this->assertStringStartsWith('origin-', $event['distinct_id']); + $this->assertSame('origin-session', $event['properties']['$session_id']); + $this->assertSame('/origin/{id}', $event['properties']['$request_path']); + $this->assertSame('GET', $event['properties']['$request_method']); + } + } +} diff --git a/packages/laravel/tests/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php new file mode 100644 index 0000000..93991f1 --- /dev/null +++ b/packages/laravel/tests/RuntimeTest.php @@ -0,0 +1,637 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('queue.default', 'sync'); + } + + public function testDiscoveryBindingsAndLazyDisabledClient(): void + { + $manager = $this->app->make(PostHog::class); + $this->assertSame($manager, $this->app->make('posthog')); + $sdk = new \ReflectionProperty(PostHog::class, 'client'); + $this->assertNull($sdk->getValue($manager)); + $this->app['config']->set('posthog.api_key', ' '); + $this->assertFalse($manager->capture('no traffic')); + $this->assertNull($sdk->getValue($manager)); + $this->assertSame([], $manager->evaluateFlags('user')->getKeys()); + $this->assertFalse($manager->captureException(new RuntimeException('disabled'))); + $this->assertSame([], $manager->client()->evaluateFlags('user')->getKeys()); + $this->assertFalse($manager->client()->capture(['event' => 'disabled', 'distinctId' => 'user'])); + } + + public function testDisabledWithKeyAndSecretDoesNotFetch(): void + { + $this->app['config']->set('posthog.enabled', false); + $this->app['config']->set('posthog.secret_key', 'server-secret'); + $manager = $this->app->make(PostHog::class); + $this->assertSame([], $manager->evaluateFlags('user')->getKeys()); + $this->assertFalse($manager->client()->captureException('disabled')); + $this->assertFalse($manager->enabled()); + } + + public function testActualHttpRequestsResolveAuthAfterMiddlewareAndNeverLeak(): void + { + $fake = Facade::fake(); + Route::get('/runtime/{id}', function ($id) { + $this->app['auth']->setUser(new GenericUser(['id' => $id, 'email' => 'private@example.test'])); + Facade::capture('request'); + Facade::capture('explicit', [], 'override'); + return 'ok'; + }); + foreach (['first', 'second'] as $id) { + $this->get('/runtime/' . $id . '?secret=private', [ + 'X-PostHog-Distinct-Id' => 'untrusted', + 'X-PostHog-Session-Id' => 'session-' . $id, + ])->assertOk(); + $this->app['auth']->forgetGuards(); + } + $messages = array_column($fake->recorded('capture'), 'message'); + $this->assertSame(['first', 'override', 'second', 'override'], array_column($messages, 'distinctId')); + $this->assertSame([ + '$session_id' => 'session-first', '$request_method' => 'GET', '$request_path' => '/runtime/{id}', + ], $messages[0]['properties']); + $this->assertSame([ + '$session_id' => 'session-second', '$request_method' => 'GET', '$request_path' => '/runtime/{id}', + ], $messages[2]['properties']); + $this->assertSame([], $this->app->make(Context::class)->snapshot()); + $this->assertStringNotContainsString('private', json_encode($messages)); + } + + public function testExplicitScopedIdentityWinsOverAuthenticationAndCustomResolvers(): void + { + $fake = Facade::fake(); + $context = $this->app->make(Context::class); + $request = Request::create('/', server: ['HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user']); + $request->setUserResolver(fn () => new GenericUser(['id' => 'authenticated-user'])); + $context->fromRequest($request); + $context->with(['distinctId' => 'scoped-user'], fn () => Facade::capture('scoped')); + $fake->assertCaptured('scoped', fn ($event) => $event['distinctId'] === 'scoped-user'); + $this->assertSame('authenticated-user', $context->data()['distinctId']); + $this->app['config']->set('posthog.distinct_id', IdentityResolver::class); + $context->begin(['distinctId' => 'explicit-job-user']); + $this->assertSame('explicit-job-user', $context->data()['distinctId']); + } + + public function testNestedArtisanCommandRestoresCallerContextAndFlagSnapshot(): void + { + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'caller', 'sessionId' => 'session', 'groups' => ['team' => 'one']]); + $before = $context->snapshot(); + $flags = new \PostHog\FeatureFlagEvaluations('caller', [], [], Facade::fake()->client()); + $context->flags('caller-snapshot', fn () => $flags); + $this->app->make(\Illuminate\Contracts\Console\Kernel::class)->rerouteSymfonyCommandEvents(); + \Illuminate\Support\Facades\Artisan::call('list', ['--raw' => true]); + $this->assertSame($before, $context->snapshot()); + $this->assertSame($flags, $context->flags( + 'caller-snapshot', + fn () => throw new RuntimeException('lost flags'), + )); + } + + public function testFailedCommandSnapshotSurvivesTerminationBeforeKernelReporting(): void + { + $fake = Facade::fake(); + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'caller']); + $this->app->singleton(\Illuminate\Contracts\Console\Kernel::class, fn ($app) => + new \PostHog\Laravel\Tests\Fixtures\ReportingConsoleKernel($app, $app['events'])); + \Illuminate\Support\Facades\Artisan::clearResolvedInstance(\Illuminate\Contracts\Console\Kernel::class); + $kernel = $this->app->make(\Illuminate\Contracts\Console\Kernel::class); + $kernel->rerouteSymfonyCommandEvents(); + \Illuminate\Support\Facades\Artisan::command('posthog:failing', function () { + app(Context::class)->begin(['distinctId' => 'failed-command']); + throw new RuntimeException('command failure'); + }); + $input = new ArrayInput(['command' => 'posthog:failing']); + $this->assertSame(1, $kernel->handle($input, new NullOutput())); + $this->assertSame('failed-command', $context->data()['distinctId']); + $kernel->terminate($input, 1); + $this->assertSame('caller', $context->data()['distinctId']); + $this->assertCount(1, $fake->recorded('captureException')); + $this->assertSame('failed-command', $fake->recorded('captureException')[0]['message']['distinctId']); + $fake->assertExceptionCaptured(fn ($event) => $event['distinctId'] === 'failed-command' + && $event['options']['handled'] === false); + } + + public function testHeaderFallbackAndNestedContextRestoreOnFailure(): void + { + $context = $this->app->make(Context::class); + $context->fromRequest(Request::create('/', server: ['HTTP_X_POSTHOG_DISTINCT_ID' => 'anonymous'])); + $this->assertSame('anonymous', $context->data()['distinctId']); + try { + $context->with(['groups' => ['team' => 'one']], function () use ($context) { + $this->assertSame(['team' => 'one'], $context->data()['groups']); + throw new RuntimeException('restore'); + }); + } catch (RuntimeException) { + } + $this->assertArrayNotHasKey('groups', $context->data()); + $context->end(); + $this->assertSame([], $context->data()); + } + + public function testRequestFailureSnapshotsSurviveCleanupAndDontReportIsRespected(): void + { + $fake = Facade::fake(); + $middleware = $this->app->make(RequestContext::class); + $exception = new RuntimeException('failure'); + try { + $middleware->handle( + Request::create('/', server: ['HTTP_X_POSTHOG_DISTINCT_ID' => 'origin']), + fn () => throw $exception, + ); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + $this->app->make(Context::class)->begin(['distinctId' => 'next-request']); + $handler = $this->app->make(ExceptionHandler::class); + $handler->report($exception); + $handler->report($exception); + $this->assertCount(1, $fake->recorded('captureException')); + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'origin' + && $message['options'] === [ + 'handled' => false, 'mechanism' => 'middleware', 'source' => 'laravel.middleware', + ] + ); + $handler->dontReport([IgnoredFailure::class]); + $handler->report(new IgnoredFailure('ignored')); + $this->assertCount(1, $fake->recorded('captureException')); + $handler->report(new RuntimeException('manual report')); + $fake->assertExceptionCaptured(fn ($message) => $message['options']['handled'] === true); + } + + public function testRealRouteFailuresAreUnhandledButManualReportRemainsHandled(): void + { + $fake = Facade::fake(); + Route::get('/failure', fn () => throw new RuntimeException('route failure')); + Route::get('/manual', function () { + report(new RuntimeException('manual report')); + return 'ok'; + }); + $this->get('/failure', ['X-PostHog-Distinct-Id' => 'failed-user'])->assertStatus(500); + $this->get('/manual', ['X-PostHog-Distinct-Id' => 'manual-user'])->assertOk(); + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'failed-user' + && $message['options']['handled'] === false + && $message['options']['mechanism'] === 'middleware' + && ($message['options']['source'] ?? null) === 'laravel.reportable' + ); + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'manual-user' + && $message['options']['handled'] === true + && $message['options']['mechanism'] === 'generic' + && ($message['options']['source'] ?? null) === 'laravel.reportable' + ); + } + + public function testQueueBoundariesCleanTwoJobsAndPreserveFailedJobIdentity(): void + { + $fake = Facade::fake(); + Bus::dispatchSync(new CaptureJob('first')); + Bus::dispatchSync(new CaptureJob('second')); + $fake->assertCaptured('job', fn ($message) => $message['distinctId'] === 'first'); + $fake->assertCaptured('job', fn ($message) => $message['distinctId'] === 'second'); + $this->assertSame([], $this->app->make(Context::class)->data()); + $job = $this->createStub(\Illuminate\Contracts\Queue\Job::class); + $this->app['events']->dispatch(new JobProcessing('sync', $job)); + $this->app->make(Context::class)->begin(['distinctId' => 'failed-job']); + $exception = new RuntimeException('job failure'); + $this->app['events']->dispatch(new JobExceptionOccurred('sync', $job, $exception)); + $this->app->make(Context::class)->begin(['distinctId' => 'later-job']); + $this->app->make(ExceptionHandler::class)->report($exception); + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'failed-job' + && $message['options'] === [ + 'handled' => false, 'mechanism' => 'task', 'source' => 'laravel.queue_job', + ] + ); + $this->app['events']->dispatch(new JobProcessed('sync', $job)); + $this->assertSame([], $this->app->make(Context::class)->data()); + } + + public function testNestedSyncJobRestoresOuterContextAndActualFailureCleansUp(): void + { + $fake = Facade::fake(); + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'outer', 'groups' => ['team' => 'one']]); + Bus::dispatchSync(new CaptureJob('inner')); + $this->assertSame('outer', $context->data()['distinctId']); + $this->assertSame(['team' => 'one'], $context->data()['groups']); + try { + Bus::dispatchSync(new FailingCaptureJob('failed-inner')); + $this->fail('The job must fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('outer', $context->data()['distinctId']); + $this->app->make(ExceptionHandler::class)->report($exception); + } + $fake->assertExceptionCaptured(fn ($message) => $message['distinctId'] === 'failed-inner'); + } + + public function testScopedExceptionDataSurvivesUnwindWithoutInventingAnUnhandledBoundary(): void + { + $fake = Facade::fake(); + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'outer']); + try { + $context->with(['distinctId' => 'caught-origin'], fn () => throw new RuntimeException('caught')); + } catch (RuntimeException $exception) { + $this->assertSame('outer', $context->data()['distinctId']); + $this->app->make(ExceptionHandler::class)->report($exception); + } + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'caught-origin' && $message['options']['handled'], + ); + try { + Bus::dispatchSync(new ScopedFailingJob('job-origin')); + } catch (RuntimeException $exception) { + $this->assertSame('outer', $context->data()['distinctId']); + $this->app->make(ExceptionHandler::class)->report($exception); + } + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'job-origin' && !$message['options']['handled'] + && $message['properties']['$groups'] === ['team' => 'job-team'], + ); + } + + public function testConsoleAndOctaneBoundariesFlushAndReset(): void + { + $client = new FlushClient(); + $manager = $this->app->make(PostHog::class); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $client); + $context = $this->app->make(Context::class); + $this->app->make(\Illuminate\Contracts\Console\Kernel::class)->rerouteSymfonyCommandEvents(); + \Illuminate\Support\Facades\Artisan::command('posthog:boundary', function () { + app(Context::class)->begin(['distinctId' => 'console']); + return 1; + }); + $this->assertSame(1, \Illuminate\Support\Facades\Artisan::call('posthog:boundary')); + $this->assertSame([], $context->data()); + foreach (['first', 'second'] as $id) { + $this->app['events']->dispatch('Laravel\\Octane\\Events\\RequestReceived', [(object) [ + 'request' => Request::create('/', server: ['HTTP_X_POSTHOG_DISTINCT_ID' => $id]), + ]]); + $this->assertSame($id, $context->data()['distinctId']); + $this->app['events']->dispatch('Laravel\\Octane\\Events\\RequestTerminated', [(object) []]); + $this->assertSame([], $context->data()); + } + $this->assertSame(3, $client->flushes); + } + + public function testLifecycleCleanupSurvivesFlushFailure(): void + { + $manager = $this->app->make(PostHog::class); + $client = new FlushClient(); + $client->fail = true; + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $client); + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'stale']); + $this->app['events']->dispatch('Laravel\\Octane\\Events\\TaskTerminated', [(object) []]); + $this->assertSame([], $context->data()); + Route::get('/flush-failure', fn () => 'ok'); + $this->get('/flush-failure')->assertOk(); + $this->assertSame([], $context->data()); + $client->fail = false; + } + + public function testFlagsAreLazyScopedAndValidNoopStillEvaluates(): void + { + $http = $this->createMock(HttpClient::class); + $http->expects($this->exactly(2))->method('sendRequest')->willReturnCallback(function ($path, $payload) { + $body = json_decode($payload, true); + $this->assertContains($body['distinct_id'], ['first', 'second']); + $this->assertTrue($body['geoip_disable']); + return new HttpResponse( + json_encode(['flags' => ['checkout' => ['enabled' => true, 'variant' => null]]]), + 200, + ); + }); + $sdk = new Client( + 'project-key', + ['consumer' => 'noop', 'disableGeoip' => true], + $http, + loadFeatureFlags: false, + ); + $manager = $this->app->make(PostHog::class); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $sdk); + $manager->capture('does not fetch', [], 'first'); + $context = $this->app->make(Context::class); + foreach (['first', 'second'] as $id) { + $context->begin(['distinctId' => $id]); + $this->assertSame([], $manager->evaluateFlags(flagKeys: [])->getKeys()); + $flags = $manager->evaluateFlags(); + $this->assertSame($flags, $manager->evaluateFlags()); + $this->assertTrue($flags->isEnabled('checkout')); + $this->assertTrue($flags->isEnabled('checkout')); + $context->end(); + } + } + + public function testNoIdentityNeverLoadsPrivilegedDefinitionsAndResolversUseContainer(): void + { + $this->app['config']->set('posthog.secret_key', 'server-secret'); + $http = $this->createMock(HttpClient::class); + $http->expects($this->never())->method('sendRequest'); + $sdk = new Client( + 'project-key', + ['consumer' => 'noop'], + $http, + loadFeatureFlags: false, + secretKey: 'server-secret', + ); + $manager = $this->app->make(PostHog::class); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $sdk); + $this->assertSame([], $manager->evaluateFlags()->getKeys()); + $this->app['config']->set('posthog.distinct_id', IdentityResolver::class); + $this->app['config']->set('posthog.groups', GroupsResolver::class); + $context = $this->app->make(Context::class); + $this->assertSame('resolved-user', $context->data()['distinctId']); + $this->assertSame(['team' => 'resolved-team'], $context->data()['groups']); + $context->begin(['groups' => ['team' => 'explicit-team']]); + $this->assertSame(['team' => 'explicit-team'], $context->data()['groups']); + $fake = Facade::fake(); + $this->app->make(ExceptionHandler::class)->report(new RuntimeException('grouped error')); + $fake->assertExceptionCaptured( + fn ($message) => $message['properties']['$groups'] === ['team' => 'explicit-team'] + ); + } + + public function testLegacyLocalOnlyFlagsLoadDefinitionsLazilyOnFirstIdentifiedUse(): void + { + $provider = $this->createMock(\PostHog\FlagDefinitionCacheProvider::class); + $provider->expects($this->once())->method('getFlagDefinitions')->willReturn([ + 'flags' => [[ + 'id' => 1, + 'key' => 'local-flag', + 'active' => true, + 'filters' => ['groups' => [['properties' => [], 'rollout_percentage' => 100]]], + ]], + 'group_type_mapping' => [], + 'cohorts' => [], + ]); + $provider->expects($this->never())->method('shouldFetchFlagDefinitions'); + $http = $this->createMock(HttpClient::class); + $http->expects($this->never())->method('sendRequest'); + $this->app['config']->set('posthog.sdk.flag_definition_cache_provider', $provider); + $sdk = new Client('project-key', [ + 'consumer' => 'noop', + 'flag_definition_cache_provider' => $provider, + ], $http, loadFeatureFlags: false); + $manager = $this->app->make(PostHog::class); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $sdk); + $this->assertSame([], $manager->getAllFlags(onlyEvaluateLocally: true)); + $this->app->make(Context::class)->begin(['distinctId' => '0']); + $this->assertSame(['local-flag' => true], $manager->getAllFlags(onlyEvaluateLocally: true)); + $this->assertSame(['local-flag' => true], $manager->getAllFlags(onlyEvaluateLocally: false)); + } + + public function testCacheProviderRoundTripsCompleteSnapshotAndCoordinatesFetch(): void + { + $provider = new FlagDefinitionCacheProvider($this->app['cache']->store('array'), 'isolated', 300, 30); + $data = [ + 'flags' => [], + 'group_type_mapping' => ['0' => 'team'], + 'cohorts' => ['1' => []], + 'property_matching_version' => 2, + 'minimal_flag_called_events' => true, + ]; + $this->assertNull($provider->getFlagDefinitions()); + $this->assertTrue($provider->shouldFetchFlagDefinitions()); + $this->assertFalse($provider->shouldFetchFlagDefinitions()); + $provider->onFlagDefinitionsReceived($data); + $this->assertSame($data, $provider->getFlagDefinitions()); + $provider->shutdown(); + } + + public function testContainerCallbackAndFrameworkHandlerOwnership(): void + { + $this->app->singleton(CaptureCallback::class); + $this->app['config']->set('posthog.sdk.before_send', CaptureCallback::class); + $this->app['config']->set('posthog.sdk.error_tracking.enabled', true); + $before = set_error_handler(static fn () => false); + restore_error_handler(); + $manager = $this->app->make(PostHog::class); + $this->app->make(Context::class)->begin(['distinctId' => 'user']); + $manager->capture('safe'); + $events = $this->app->make(CaptureCallback::class)->events; + $this->assertSame('safe', $events[0]['event']); + $this->assertTrue($events[0]['properties']['$geoip_disable']); + $after = set_error_handler(static fn () => false); + restore_error_handler(); + $this->assertSame($before, $after); + } + + public function testRealClientMetadataGroupsAndCanonicalExceptionAreForwarded(): void + { + $this->app->singleton(CaptureCallback::class); + $this->app['config']->set('posthog.sdk.before_send', CaptureCallback::class); + $this->app['config']->set('posthog.release_id', 'release-123'); + $this->app['config']->set('posthog.sdk.library_name', 'ignored'); + $this->app['config']->set('posthog.sdk.release_id', 'ignored'); + $manager = $this->app->make(PostHog::class); + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'user', 'groups' => ['team' => 'one']]); + $manager->capture(['event' => 'advanced', 'distinctId' => 'override', 'properties' => ['custom' => true]]); + $manager->captureException(new RuntimeException('canonical'), properties: ['$exception_list' => ['forged']]); + $events = $this->app->make(CaptureCallback::class)->events; + $this->assertSame('override', $events[0]['distinct_id']); + $this->assertSame('posthog-laravel', $events[0]['properties']['$lib']); + $this->assertSame(PostHog::VERSION, $events[0]['properties']['$lib_version']); + $this->assertSame('release-123', $events[0]['properties']['$release_id']); + $this->assertSame(['team' => 'one'], $events[0]['properties']['$groups']); + $this->assertSame(['team' => 'one'], $events[1]['properties']['$groups']); + $this->assertSame('RuntimeException', $events[1]['properties']['$exception_list'][0]['type']); + $this->assertTrue($events[1]['properties']['$exception_list'][0]['mechanism']['handled']); + $this->assertArrayNotHasKey('$exception_source', $events[1]['properties']); + $this->assertNull($manager->client()->getContext()); + $exception = new RuntimeException('originating boundary'); + $this->app->make(ExceptionReporter::class)->remember($exception, 'task'); + $context->begin(['distinctId' => 'next-user', 'groups' => ['team' => 'two']]); + $this->app->make(ExceptionHandler::class)->report($exception); + $events = $this->app->make(CaptureCallback::class)->events; + $this->assertSame('user', $events[2]['distinct_id']); + $this->assertSame(['team' => 'one'], $events[2]['properties']['$groups']); + $this->assertFalse($events[2]['properties']['$exception_list'][0]['mechanism']['handled']); + $this->assertSame('laravel.reportable', $events[2]['properties']['$exception_source'] ?? null); + } + + public function testInstallPublishesWithoutOverwritingApplicationConfig(): void + { + $path = $this->app->configPath('posthog.php'); + $original = is_file($path) ? file_get_contents($path) : null; + @unlink($path); + try { + $this->artisan('posthog:install')->assertSuccessful(); + $this->assertFileExists($path); + file_put_contents($path, ' true];'); + $this->artisan('posthog:install')->assertSuccessful(); + $this->assertSame(' true];', file_get_contents($path)); + } finally { + if ($original === null) { + @unlink($path); + } else { + file_put_contents($path, $original); + } + } + } + + public function testFakeDoesNotApplyCaptureContextToIdentityMutations(): void + { + $fake = Facade::fake(); + $this->app->make(Context::class)->begin([ + 'distinctId' => 'ambient-user', 'properties' => ['private' => 'capture-only'], + ]); + Facade::identify(['distinctId' => 'target', 'properties' => ['plan' => 'pro']]); + Facade::alias(['distinctId' => 'target', 'alias' => 'old']); + Facade::groupIdentify(['groupType' => 'team', 'groupKey' => 'one']); + $messages = array_column($fake->recorded(), 'message'); + $this->assertSame('target', $messages[0]['distinctId']); + $this->assertSame(['plan' => 'pro'], $messages[0]['properties']); + $this->assertSame([], $messages[1]['properties']); + $this->assertNull($messages[2]['distinctId']); + $this->assertSame([], $messages[2]['properties']); + } + + public function testFakeForwardsAllMessageApisAndAssertions(): void + { + $previousClient = $this->app->make(Client::class); + $fake = Facade::fake(); + $this->assertNotSame($previousClient, $this->app->make(Client::class)); + $this->assertSame($fake->client(), $this->app->make(Client::class)); + $fake->assertNothingCaptured(); + Facade::capture('event', ['count' => 1], 'user'); + Facade::identify(['distinctId' => 'user', 'properties' => ['plan' => 'paid']]); + Facade::alias(['distinctId' => 'user', 'alias' => 'old']); + Facade::groupIdentify(['groupType' => 'team', 'groupKey' => 'one']); + Facade::captureException('manual'); + $fake->assertCaptured('event', fn ($message) => $message['properties']['count'] === 1); + $fake->assertCapturedCount(1); + $fake->assertNotCaptured('missing'); + $fake->assertIdentified(); + $fake->assertAliased(); + $fake->assertGroupIdentified(); + $fake->assertExceptionCaptured(); + $this->assertFalse($fake->client()->capture(['event' => 'escape', 'distinctId' => 'user'])); + } + + public static function invalidClientOptions(): array + { + return [ + 'consumer' => [['consumer' => 'invalid-consumer']], + 'provider' => [['consumer' => 'noop', 'flag_definition_cache_provider' => \stdClass::class]], + ]; + } + + #[DataProvider('invalidClientOptions')] + public function testDoctorRejectsInvalidClientOptionsWithoutLeakingSecrets(array $options): void + { + $this->app['config']->set('posthog.sdk', $options); + $this->app->instance(PostHog::class, new PostHog($this->app)); + $this->assertSame(1, \Illuminate\Support\Facades\Artisan::call('posthog:doctor')); + $output = \Illuminate\Support\Facades\Artisan::output(); + $this->assertStringContainsString('PostHog client initialization failed', $output); + $this->assertStringContainsString('posthog.sdk', $output); + $this->assertStringNotContainsString('project-key', $output); + } + + public function testUncachedDefinitionsLoadOncePerScopedManagerNotOncePerWorker(): void + { + $this->app['config']->set('posthog.secret_key', 'private-secret'); + $this->app['config']->set('posthog.flag_cache.refresh_interval', 3600); + $http = $this->createMock(HttpClient::class); + $http->expects($this->exactly(3))->method('sendRequest')->willReturnCallback(function ($path) { + $this->assertStringStartsWith('/flags/definitions?', $path); + return new HttpResponse(json_encode(['flags' => []]), 200); + }); + for ($i = 0; $i < 3; $i++) { + $this->app->forgetScopedInstances(); + $manager = $this->app->make(PostHog::class); + $client = new Client( + 'project-key', + ['consumer' => 'noop'], + $http, + loadFeatureFlags: false, + secretKey: 'private-secret', + ); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $client); + for ($read = 0; $read < 2; $read++) { + $this->assertSame([], $manager->evaluateFlags('user', onlyEvaluateLocally: true)->getKeys()); + } + } + } + + public function testDoctorWarnsAboutUncachedDefinitionsWithoutFetching(): void + { + $this->app['config']->set('posthog.secret_key', 'private-secret'); + $manager = $this->getMockBuilder(PostHog::class) + ->setConstructorArgs([$this->app]) + ->onlyMethods(['loadFlags']) + ->getMock(); + $manager->expects($this->never())->method('loadFlags'); + $this->app->instance(PostHog::class, $manager); + $this->assertSame(0, \Illuminate\Support\Facades\Artisan::call('posthog:doctor')); + $output = \Illuminate\Support\Facades\Artisan::output(); + $this->assertStringContainsString('each request or job', $output); + $this->assertStringNotContainsString('private-secret', $output); + $this->app['config']->set('posthog.flag_cache.enabled', true); + $this->assertSame(0, \Illuminate\Support\Facades\Artisan::call('posthog:doctor')); + $this->assertStringNotContainsString('each request or job', \Illuminate\Support\Facades\Artisan::output()); + } + + public function testCommandsNeverPrintKeysAndRequireExplicitSending(): void + { + $this->artisan('posthog:doctor')->expectsOutput( + 'PostHog is configured. Credentials are server-side. No network check was performed.', + )->assertSuccessful(); + $this->artisan('posthog:test')->assertFailed(); + Facade::fake(); + $this->artisan('posthog:test', ['--send' => true])->assertSuccessful(); + $this->app['config']->set('posthog.api_key', null); + $this->app->instance(PostHog::class, new PostHog($this->app)); + $this->artisan('posthog:doctor')->assertFailed(); + } +} diff --git a/scripts/PublicApiSnapshot.php b/scripts/PublicApiSnapshot.php index 74a7ff8..916e977 100644 --- a/scripts/PublicApiSnapshot.php +++ b/scripts/PublicApiSnapshot.php @@ -23,12 +23,19 @@ final class PublicApiSnapshot public static function run(array $argv): int { $root = dirname(__DIR__); - $snapshotFile = $root . '/api/public-api.json'; + $laravel = in_array('--laravel', $argv, true); + $packageRoot = $laravel ? $root . '/packages/laravel' : $root; + $snapshotFile = $root . '/api/' . ($laravel ? 'laravel-public-api.json' : 'public-api.json'); $update = in_array('--update', $argv, true); + $updateCommand = $laravel ? 'composer api:update:laravel' : 'composer api:update'; - require_once $root . '/vendor/autoload.php'; + require_once $packageRoot . '/vendor/autoload.php'; - $api = self::buildPublicApi($root . '/lib', 'PostHog\\'); + $api = self::buildPublicApi( + $packageRoot . ($laravel ? '/src' : '/lib'), + $laravel ? 'PostHog\\Laravel\\' : 'PostHog\\', + $laravel, + ); $json = json_encode($api, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; if ($update) { @@ -43,7 +50,7 @@ public static function run(array $argv): int if (!file_exists($snapshotFile)) { fwrite(STDERR, 'Missing public API snapshot: ' . self::relativePath($snapshotFile, $root) . PHP_EOL); - fwrite(STDERR, "Run `composer api:update` to create it.\n"); + fwrite(STDERR, "Run `$updateCommand` to create it.\n"); return 1; } @@ -57,7 +64,7 @@ public static function run(array $argv): int file_put_contents($tmpFile, $json); fwrite(STDERR, "Public API snapshot is out of date.\n"); - fwrite(STDERR, "Run `composer api:update` if this public API change is intentional.\n\n"); + fwrite(STDERR, "Run `$updateCommand` if this public API change is intentional.\n\n"); fwrite(STDERR, self::unifiedDiff($snapshotFile, $tmpFile, $root)); @unlink($tmpFile); return 1; @@ -66,8 +73,11 @@ public static function run(array $argv): int /** * @return array */ - private static function buildPublicApi(string $sourceDir, string $namespacePrefix): array - { + private static function buildPublicApi( + string $sourceDir, + string $namespacePrefix, + bool $excludeInternal = false, + ): array { foreach (self::phpFiles($sourceDir) as $file) { require_once $file; } @@ -83,11 +93,14 @@ private static function buildPublicApi(string $sourceDir, string $namespacePrefi $reflection = new ReflectionClass($name); $fileName = $reflection->getFileName(); - if ($fileName === false || !self::pathIsInside($fileName, $sourceDir)) { + if ( + $fileName === false || !self::pathIsInside($fileName, $sourceDir) + || ($excludeInternal && self::isInternal($reflection)) + ) { continue; } - $symbols[$name] = self::reflectClass($reflection); + $symbols[$name] = self::reflectClass($reflection, $excludeInternal); } ksort($symbols); @@ -123,11 +136,14 @@ private static function phpFiles(string $directory): array /** * @return array */ - private static function reflectClass(ReflectionClass $class): array + private static function reflectClass(ReflectionClass $class, bool $excludeInternal = false): array { $constants = []; foreach ($class->getReflectionConstants(ReflectionClassConstant::IS_PUBLIC) as $constant) { - if ($constant->getDeclaringClass()->getName() !== $class->getName()) { + if ( + $constant->getDeclaringClass()->getName() !== $class->getName() + || ($excludeInternal && self::isInternal($constant)) + ) { continue; } @@ -140,7 +156,10 @@ private static function reflectClass(ReflectionClass $class): array $properties = []; foreach ($class->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { - if ($property->getDeclaringClass()->getName() !== $class->getName()) { + if ( + $property->getDeclaringClass()->getName() !== $class->getName() + || ($excludeInternal && self::isInternal($property)) + ) { continue; } @@ -156,7 +175,10 @@ private static function reflectClass(ReflectionClass $class): array $methods = []; foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { - if ($method->getDeclaringClass()->getName() !== $class->getName()) { + if ( + $method->getDeclaringClass()->getName() !== $class->getName() + || ($excludeInternal && self::isInternal($method)) + ) { continue; } @@ -177,6 +199,12 @@ private static function reflectClass(ReflectionClass $class): array ]; } + private static function isInternal( + ReflectionClass|ReflectionClassConstant|ReflectionMethod|ReflectionProperty $symbol, + ): bool { + return preg_match('/@internal\b/', $symbol->getDocComment() ?: '') === 1; + } + /** * @return array */ @@ -295,7 +323,14 @@ private static function normalizeTypeName(string $name, ReflectionClass $scope): private static function normalizeConstantValue(ReflectionClassConstant $constant): mixed { - if ($constant->getDeclaringClass()->getName() === 'PostHog\\PostHog' && $constant->getName() === 'VERSION') { + if ( + $constant->getName() === 'VERSION' + && in_array( + $constant->getDeclaringClass()->getName(), + ['PostHog\\PostHog', 'PostHog\\Laravel\\PostHog'], + true, + ) + ) { return ''; } diff --git a/test/PackageArchiveTest.php b/test/PackageArchiveTest.php new file mode 100644 index 0000000..12497a1 --- /dev/null +++ b/test/PackageArchiveTest.php @@ -0,0 +1,26 @@ + $path === 'packages/laravel' || str_starts_with($path, 'packages/laravel/'), + ))); + } +} diff --git a/test/PublicApiSnapshotTest.php b/test/PublicApiSnapshotTest.php index 7a1fc47..0888d07 100644 --- a/test/PublicApiSnapshotTest.php +++ b/test/PublicApiSnapshotTest.php @@ -11,6 +11,47 @@ class PublicApiSnapshotTest extends TestCase { + public function testInternalAnnotationsCanBeExcludedWithoutChangingTheCoreDefault(): void + { + require_once __DIR__ . '/../scripts/PublicApiSnapshot.php'; + $directory = sys_get_temp_dir() . '/posthog-api-' . bin2hex(random_bytes(8)); + mkdir($directory); + $namespace = 'PostHog\\Test\\ApiFixture' . bin2hex(random_bytes(8)); + $file = $directory . '/Fixture.php'; + file_put_contents($file, sprintf(<<<'PHP' +invoke(null, $directory, $namespace . '\\', true); + self::assertArrayNotHasKey($namespace . '\\Hidden', $api['symbols']); + $visible = $api['symbols'][$namespace . '\\Visible']; + self::assertSame(['SUPPORTED'], array_keys($visible['constants'])); + self::assertSame(['supported'], array_keys($visible['properties'])); + self::assertSame(['supported'], array_keys($visible['methods'])); + $unfiltered = $builder->invoke(null, $directory, $namespace . '\\'); + self::assertArrayHasKey($namespace . '\\Hidden', $unfiltered['symbols']); + self::assertArrayHasKey('internal', $unfiltered['symbols'][$namespace . '\\Visible']['methods']); + } finally { + unlink($file); + rmdir($directory); + } + } + public function testInternalNamespaceIsExcludedWithoutDroppingExistingSymbols(): void { require_once __DIR__ . '/../scripts/PublicApiSnapshot.php';