From 34e07100d9ce06f2c56312a9d1072a59574ffed8 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 09:25:09 +0100 Subject: [PATCH 01/32] feat: add PHP framework integration primitives --- .changeset/young-onions-hide.md | 5 + api/public-api.json | 176 ++++++++++++++++++++++++++++ lib/Client.php | 90 +++++++++++++-- lib/ContextScope.php | 27 +++++ lib/PostHog.php | 57 ++++----- lib/RequestContext.php | 27 +++-- test/FrameworkIntegrationTest.php | 184 ++++++++++++++++++++++++++++++ 7 files changed, 510 insertions(+), 56 deletions(-) create mode 100644 .changeset/young-onions-hide.md create mode 100644 lib/ContextScope.php create mode 100644 test/FrameworkIntegrationTest.php diff --git a/.changeset/young-onions-hide.md b/.changeset/young-onions-hide.md new file mode 100644 index 00000000..9a218d4e --- /dev/null +++ b/.changeset/young-onions-hide.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Add instance group identify, lifecycle context scopes, framework exception metadata, and explicit library and release configuration. diff --git a/api/public-api.json b/api/public-api.json index 81b148bb..2196769f 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -203,6 +203,16 @@ "default": [], "defaultConstant": null, "hasDefault": true + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true } ] }, @@ -292,6 +302,34 @@ } ] }, + "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, @@ -779,6 +817,24 @@ "returnType": "?string", "parameters": [] }, + "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, @@ -1570,6 +1626,50 @@ } } }, + "PostHog\\ContextScope": { + "type": "class", + "abstract": false, + "final": true, + "readonly": false, + "extends": null, + "implements": [], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "onClose", + "type": "?Closure", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "__destruct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [] + }, + "close": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + } + } + }, "PostHog\\EvaluatedFlagRecord": { "type": "class", "abstract": false, @@ -3750,6 +3850,16 @@ "default": [], "defaultConstant": null, "hasDefault": true + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true } ] }, @@ -3771,6 +3881,34 @@ } ] }, + "enterContext": { + "static": true, + "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": true, "abstract": false, @@ -4589,6 +4727,44 @@ } ] }, + "enterContext": { + "static": true, + "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 + }, + { + "name": "contextKey", + "type": "?int", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, "getContext": { "static": true, "abstract": false, diff --git a/lib/Client.php b/lib/Client.php index a857ca36..88ba9a57 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -273,6 +273,9 @@ class Client implements FeatureFlagEvaluationsHost * * @param array{ * host?: string, + * library_name?: string, + * library_version?: string, + * release_id?: string|null, * ssl?: bool, * timeout?: int|float, * verify_batch_events_request?: bool, @@ -330,7 +333,14 @@ public function __construct( $this->flagDefinitionCacheProviderShutdown = false; $this->shutdownComplete = false; $envReleaseId = getenv(self::ENV_RELEASE_ID); - $this->releaseId = $envReleaseId === false ? null : StringNormalizer::normalizeOptional($envReleaseId); + $releaseId = array_key_exists('release_id', $options) + ? $options['release_id'] + : ($envReleaseId === false ? null : $envReleaseId); + $this->releaseId = StringNormalizer::normalizeOptional($releaseId); + $this->options['library_name'] = StringNormalizer::normalizeOptional($options['library_name'] ?? null) + ?? PostHog::LIBRARY; + $this->options['library_version'] = StringNormalizer::normalizeOptional($options['library_version'] ?? null) + ?? PostHog::VERSION; $this->options['host'] = StringNormalizer::normalizeHost($options['host'] ?? null); if (!$this->enabled) { if (($this->options['consumer'] ?? null) !== 'noop') { @@ -629,12 +639,15 @@ private function shouldSendMinimalFlagCalledEvent(array $message): bool * @param \Throwable|string $exception The exception to capture or a plain string message * @param string|null $distinctId User ID; a random UUID is used when omitted (no person profile created) * @param array $additionalProperties Extra properties merged into the event + * @param array{handled?: bool, mechanism?: string} $options Framework boundary metadata. + * Manual capture defaults to handled; integrations may explicitly mark a failed boundary. * @return bool whether the capture call succeeded */ public function captureException( \Throwable|string $exception, ?string $distinctId = null, - array $additionalProperties = [] + array $additionalProperties = [], + array $options = [] ): bool { $errorTrackingConfig = $this->options['error_tracking'] ?? []; $maxFrames = max(0, (int) ($errorTrackingConfig['max_frames'] ?? 20)); @@ -644,6 +657,11 @@ public function captureException( return false; } + $exceptionList = ExceptionPayloadBuilder::overridePrimaryMechanism($exceptionList, [ + 'type' => $options['mechanism'] ?? 'generic', + 'handled' => $options['handled'] ?? true, + ]); + $traceProperties = array_diff_key( ExceptionPayloadBuilder::getTraceProperties(), $this->getContext()['properties'] ?? [] @@ -669,6 +687,41 @@ public function captureException( return $this->capture($message); } + /** + * Update a group using explicit group identity, without inheriting request context. + * + * @param array{groupType: string, groupKey: string, properties?: array, + * distinctId?: string, distinct_id?: string, disableGeoip?: bool} $message + */ + public function groupIdentify(array $message): bool + { + if (empty($message['groupType'])) { + throw new Exception('PostHog::groupIdentify() expects a groupType'); + } + if (empty($message['groupKey'])) { + throw new Exception('PostHog::groupIdentify() expects a groupKey'); + } + + $distinctId = "\${$message['groupType']}_{$message['groupKey']}"; + foreach (['distinctId', 'distinct_id'] as $key) { + if (isset($message[$key]) && is_scalar($message[$key]) && (string) $message[$key] !== '') { + $distinctId = (string) $message[$key]; + break; + } + } + + return $this->capture([ + 'event' => '$groupidentify', + 'distinctId' => $distinctId, + 'disableGeoip' => $message['disableGeoip'] ?? null, + 'properties' => [ + '$group_type' => $message['groupType'], + '$group_key' => $message['groupKey'], + '$group_set' => $message['properties'] ?? [], + ], + ]); + } + /** * Tags properties about the user. * @@ -1977,7 +2030,7 @@ public function localFlags(): HttpResponse $headers = [ // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. - "User-Agent: " . PostHog::LIBRARY . "/" . PostHog::VERSION, + "User-Agent: " . $this->options['library_name'] . "/" . $this->options['library_version'], "Authorization: Bearer " . $this->secretKey ]; @@ -2184,7 +2237,7 @@ private function sendFeatureFlagsRequest(array $payload): HttpResponse $requestPayload, [ // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. - "User-Agent: " . PostHog::LIBRARY . "/" . PostHog::VERSION, + "User-Agent: " . $this->options['library_name'] . "/" . $this->options['library_version'], ], [ "shouldRetry" => false, @@ -2383,6 +2436,22 @@ public function withContext(array $data, callable $fn, array $options = []): mix }, $options, $this->contextKey()); } + /** + * Enter context across framework lifecycle callbacks. Close the returned scope in a finally block. + * + * @param array $data + * @param array{fresh?: bool} $options + */ + public function enterContext(array $data, array $options = []): ContextScope + { + $scope = RequestContext::enterContext($data, $options, $this->contextKey()); + Internal\SpanContext::enrichCurrent($this); + // The bound closure keeps this client alive so its context key cannot be reused before close. + return new ContextScope(function () use ($scope): void { + $scope->close(); + }); + } + /** * Get the currently active request context for this client, if any. * @@ -2543,19 +2612,22 @@ private function message($msg) unset($msg["type"], $msg["library"], $msg["library_version"], $msg["library_consumer"]); if (!array_key_exists('$lib', $msg["properties"])) { - $msg["properties"]['$lib'] = is_scalar($legacyLibrary) && (string) $legacyLibrary !== '' + $msg["properties"]['$lib'] = is_scalar($legacyLibrary) + && (string) $legacyLibrary !== '' ? (string) $legacyLibrary - : PostHog::LIBRARY; + : $this->options['library_name']; } if (!array_key_exists('$lib_version', $msg["properties"])) { - $msg["properties"]['$lib_version'] = is_scalar($legacyLibraryVersion) && (string) $legacyLibraryVersion !== '' + $msg["properties"]['$lib_version'] = is_scalar($legacyLibraryVersion) + && (string) $legacyLibraryVersion !== '' ? (string) $legacyLibraryVersion - : PostHog::VERSION; + : $this->options['library_version']; } if (!array_key_exists('$lib_consumer', $msg["properties"])) { - $msg["properties"]['$lib_consumer'] = is_scalar($legacyLibraryConsumer) && (string) $legacyLibraryConsumer !== '' + $msg["properties"]['$lib_consumer'] = is_scalar($legacyLibraryConsumer) + && (string) $legacyLibraryConsumer !== '' ? (string) $legacyLibraryConsumer : $this->consumer->getConsumer(); } diff --git a/lib/ContextScope.php b/lib/ContextScope.php new file mode 100644 index 00000000..c6e41901 --- /dev/null +++ b/lib/ContextScope.php @@ -0,0 +1,27 @@ +onClose; + $this->onClose = null; + if ($close !== null) { + $close(); + } + } + + public function __destruct() + { + $this->close(); + } +} diff --git a/lib/PostHog.php b/lib/PostHog.php index c4d4058b..843706fc 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -41,6 +41,9 @@ class PostHog * * @param array{ * host?: string, + * library_name?: string, + * library_version?: string, + * release_id?: string|null, * ssl?: bool, * timeout?: int|float, * verify_batch_events_request?: bool, @@ -124,16 +127,18 @@ public static function init( * @param \Throwable|string $exception The exception to capture or a plain string message. * @param string|null $distinctId User ID; a random UUID is used when omitted (no person profile created). * @param array $additionalProperties Extra properties merged into the event. + * @param array{handled?: bool, mechanism?: string} $options Framework boundary metadata. * @return bool Whether the capture call succeeded. * @throws Exception */ public static function captureException( \Throwable|string $exception, ?string $distinctId = null, - array $additionalProperties = [] + array $additionalProperties = [], + array $options = [] ): bool { self::checkClient(); - return self::$client->captureException($exception, $distinctId, $additionalProperties); + return self::$client->captureException($exception, $distinctId, $additionalProperties, $options); } /** @@ -214,40 +219,8 @@ public static function identify(array $message) */ public static function groupIdentify(array $message) { - self::assert(!empty($message["groupType"]), "PostHog::groupIdentify() expects a groupType"); - self::assert(!empty($message["groupKey"]), "PostHog::groupIdentify() expects a groupKey"); - - if (!isset($message["properties"])) { - $message["properties"] = array(); - } - - $distinctId = "\${$message['groupType']}_{$message['groupKey']}"; - if ( - array_key_exists("distinctId", $message) - && is_scalar($message["distinctId"]) - && (string) $message["distinctId"] !== "" - ) { - $distinctId = (string) $message["distinctId"]; - } elseif ( - array_key_exists("distinct_id", $message) - && is_scalar($message["distinct_id"]) - && (string) $message["distinct_id"] !== "" - ) { - $distinctId = (string) $message["distinct_id"]; - } - - $msg = array( - "event" => "\$groupidentify", - "distinctId" => $distinctId, - "disableGeoip" => $message["disableGeoip"] ?? null, - "properties" => array( - "\$group_type" => $message["groupType"], - "\$group_key" => $message["groupKey"], - "\$group_set" => $message["properties"], - ) - ); - - return self::capture($msg); + self::checkClient(); + return self::$client->groupIdentify($message); } /** @@ -519,6 +492,18 @@ public static function withContext(array $data, callable $fn, array $options = [ return self::$client->withContext($data, $fn, $options); } + /** + * Enter framework context until the returned scope is closed. + * + * @param array $data + * @param array{fresh?: bool} $options + */ + public static function enterContext(array $data, array $options = []): ContextScope + { + self::checkClient(); + return self::$client->enterContext($data, $options); + } + /** * Get the currently active request context for this client, if any. * diff --git a/lib/RequestContext.php b/lib/RequestContext.php index edd9e3cf..62ae3aa9 100644 --- a/lib/RequestContext.php +++ b/lib/RequestContext.php @@ -40,16 +40,26 @@ public static function withContext( array $options = [], ?int $contextKey = null ): mixed { - $contextKey = self::contextKey($contextKey); - $scopeId = self::push($data, (bool) ($options['fresh'] ?? false), $contextKey); - + $scope = self::enterContext($data, $options, $contextKey); try { return $fn(); } finally { - self::pop($scopeId, $contextKey); + $scope->close(); } } + /** @internal Use Client::enterContext() to keep the owning client alive. */ + public static function enterContext(array $data, array $options = [], ?int $contextKey = null): ContextScope + { + $contextKey = self::contextKey($contextKey); + $fiber = \Fiber::getCurrent(); + $scopeId = self::push($data, (bool) ($options['fresh'] ?? false), $contextKey); + + return new ContextScope(static function () use ($scopeId, $contextKey, $fiber): void { + self::pop($scopeId, $contextKey, $fiber === null ? 0 : spl_object_id($fiber)); + }); + } + /** * Get the current context for a context scope. * @@ -169,9 +179,8 @@ private static function push(array $data, bool $fresh, int $contextKey): int return $scopeId; } - private static function pop(int $scopeId, int $contextKey): void + private static function pop(int $scopeId, int $contextKey, int $fiberKey): void { - $fiberKey = self::fiberKey(); if (empty(self::$stacks[$contextKey][$fiberKey])) { return; } @@ -185,11 +194,7 @@ private static function pop(int $scopeId, int $contextKey): void foreach (self::$stacks[$contextKey][$fiberKey] as $index => $frame) { if ($frame['scopeId'] === $scopeId) { - self::$stacks[$contextKey][$fiberKey] = array_slice( - self::$stacks[$contextKey][$fiberKey], - 0, - $index - ); + array_splice(self::$stacks[$contextKey][$fiberKey], $index, 1); self::unsetStackIfEmpty($contextKey, $fiberKey); return; } diff --git a/test/FrameworkIntegrationTest.php b/test/FrameworkIntegrationTest.php new file mode 100644 index 00000000..2dfd323e --- /dev/null +++ b/test/FrameworkIntegrationTest.php @@ -0,0 +1,184 @@ +http = new MockedHttpClient('localhost'); + return new Client('test-key', $options, $this->http, loadFeatureFlags: false); + } + + private function events(Client $client): array + { + $client->flush(); + $events = []; + foreach ($this->http->calls ?? [] as $call) { + if ($call['path'] === '/batch/') { + $events = array_merge($events, json_decode($call['payload'], true)['batch']); + } + } + return $events; + } + + public function testInstanceGroupIdentifyDoesNotInheritRequestIdentity(): void + { + $client = $this->client(); + $client->withContext( + ['distinctId' => 'request-user', 'properties' => ['secret' => 'private']], + function () use ($client) { + $client->groupIdentify([ + 'groupType' => 'company', 'groupKey' => 'acme', 'properties' => ['plan' => 'pro'], + ]); + } + ); + $event = $this->events($client)[0]; + self::assertSame('$groupidentify', $event['event']); + self::assertSame('$company_acme', $event['distinct_id']); + self::assertSame('acme', $event['properties']['$group_key']); + self::assertSame(['plan' => 'pro'], $event['properties']['$group_set']); + self::assertArrayNotHasKey('secret', $event['properties']); + } + + public function testDetachedScopesRestoreOuterContextAndCloseOnlyOnce(): void + { + $client = $this->client(); + $outer = $client->enterContext(['distinctId' => 'outer']); + $inner = $client->enterContext(['distinctId' => 'inner'], ['fresh' => true]); + $client->capture(['event' => 'inside']); + $inner->close(); + $inner->close(); + $client->capture(['event' => 'outside']); + $outer->close(); + self::assertNull($client->getContext()); + self::assertSame(['inner', 'outer'], array_column($this->events($client), 'distinct_id')); + } + + public function testClosingOuterScopeDoesNotRemoveAYoungerScope(): void + { + $client = $this->client(); + $outer = $client->enterContext(['distinctId' => 'outer']); + $inner = $client->enterContext(['distinctId' => 'inner'], ['fresh' => true]); + $outer->close(); + self::assertSame('inner', $client->getContext()['distinctId']); + $inner->close(); + self::assertNull($client->getContext()); + } + + public function testClosingScopeFromAnotherFiberDoesNotClearItsContext(): void + { + $client = $this->client(); + $fiber = new Fiber(function () use ($client) { + $scope = $client->enterContext(['distinctId' => 'fiber-user']); + Fiber::suspend($scope); + self::assertNull($client->getContext()); + }); + $fiberScope = $fiber->start(); + $mainScope = $client->enterContext(['distinctId' => 'main-user']); + $fiberScope->close(); + self::assertSame('main-user', $client->getContext()['distinctId']); + $fiber->resume(); + $mainScope->close(); + self::assertNull($client->getContext()); + } + + public function testScopeDestructionClosesItsContext(): void + { + $client = $this->client(); + $scope = $client->enterContext(['distinctId' => 'temporary']); + unset($scope); + self::assertNull($client->getContext()); + } + + public function testFrameworkExceptionOptionsPreserveChainAndStack(): void + { + $client = $this->client(); + $exception = new RuntimeException('outer', previous: new RuntimeException('root')); + $client->captureException($exception, 'user', ['$exception_list' => [], '$exception_handled' => true], [ + 'handled' => false, + 'mechanism' => 'auto.framework', + ]); + $event = $this->events($client)[0]; + self::assertFalse($event['properties']['$exception_handled']); + $list = $event['properties']['$exception_list']; + self::assertCount(2, $list); + self::assertSame('outer', $list[0]['value']); + self::assertSame('root', $list[1]['value']); + self::assertSame(['type' => 'auto.framework', 'handled' => false], $list[0]['mechanism']); + self::assertTrue($list[1]['mechanism']['handled']); + self::assertNotEmpty($list[0]['stacktrace']['frames']); + } + + public function testManualExceptionsStayHandledByDefault(): void + { + $client = $this->client(); + $client->captureException(new RuntimeException('handled')); + $event = $this->events($client)[0]; + self::assertTrue($event['properties']['$exception_handled']); + self::assertSame('generic', $event['properties']['$exception_list'][0]['mechanism']['type']); + } + + public function testConfiguredLibraryAndReleaseApplyAcrossEventTypes(): void + { + $client = $this->client([ + 'library_name' => 'posthog-laravel', 'library_version' => '0.1.0', 'release_id' => 'release', + ]); + $client->capture(['event' => 'purchase']); + $client->identify(['distinctId' => 'user', 'properties' => ['plan' => 'pro']]); + $client->alias(['distinctId' => 'user', 'alias' => 'old-user']); + $client->groupIdentify(['groupType' => 'company', 'groupKey' => 'acme']); + $client->captureException('error'); + $events = $this->events($client); + self::assertCount(5, $events); + foreach ($events as $event) { + self::assertSame('posthog-laravel', $event['properties']['$lib']); + self::assertSame('0.1.0', $event['properties']['$lib_version']); + self::assertSame('release', $event['properties']['$release_id']); + } + } + + public function testConfiguredLibraryIsUsedForFlagHttpRequests(): void + { + $client = $this->client(['library_name' => 'posthog-laravel', 'library_version' => '0.1.0']); + $client->evaluateFlags('user'); + self::assertContains('User-Agent: posthog-laravel/0.1.0', $this->http->calls[0]['extraHeaders']); + } + + public function testReleaseOptionOverridesEnvironmentAndNullDisablesIt(): void + { + $previous = getenv('POSTHOG_RELEASE_ID'); + putenv('POSTHOG_RELEASE_ID=environment-release'); + try { + $client = $this->client(['release_id' => 'configured-release']); + $client->capture(['event' => 'configured']); + self::assertSame('configured-release', $this->events($client)[0]['properties']['$release_id']); + $client = $this->client(['release_id' => null]); + $client->capture(['event' => 'disabled']); + self::assertArrayNotHasKey('$release_id', $this->events($client)[0]['properties']); + } finally { + putenv($previous === false ? 'POSTHOG_RELEASE_ID' : 'POSTHOG_RELEASE_ID=' . $previous); + } + } + + public function testExplicitEventMetadataWinsOverClientOptions(): void + { + $client = $this->client([ + 'library_name' => 'integration', 'library_version' => '0.1.0', 'release_id' => 'default', + ]); + $client->capture(['event' => 'custom', 'properties' => [ + '$lib' => 'custom', '$lib_version' => '2.0.0', '$release_id' => 'explicit', + ]]); + $properties = $this->events($client)[0]['properties']; + self::assertSame('custom', $properties['$lib']); + self::assertSame('2.0.0', $properties['$lib_version']); + self::assertSame('explicit', $properties['$release_id']); + } +} From bec01c1c8eba95f59db4753cbc9211e361815e33 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 09:39:22 +0100 Subject: [PATCH 02/32] feat: add Laravel runtime foundation --- packages/laravel/.gitignore | 3 + packages/laravel/LICENSE.md | 23 + packages/laravel/README.md | 222 ++++++++ packages/laravel/composer.json | 24 + packages/laravel/config/posthog.php | 25 + packages/laravel/phpunit.xml | 4 + .../laravel/src/Commands/DoctorCommand.php | 30 ++ .../laravel/src/Commands/InstallCommand.php | 22 + packages/laravel/src/Commands/TestCommand.php | 29 ++ packages/laravel/src/Context.php | 114 +++++ packages/laravel/src/ExceptionReporter.php | 71 +++ packages/laravel/src/Facades/PostHog.php | 23 + .../src/FlagDefinitionCacheProvider.php | 37 ++ .../laravel/src/Middleware/RequestContext.php | 35 ++ packages/laravel/src/PostHog.php | 226 ++++++++ .../laravel/src/PostHogServiceProvider.php | 122 +++++ packages/laravel/src/Testing/PostHogFake.php | 156 ++++++ .../tests/Fixtures/CaptureCallback.php | 14 + .../laravel/tests/Fixtures/CaptureJob.php | 24 + .../tests/Fixtures/FailingCaptureJob.php | 16 + .../laravel/tests/Fixtures/FlushClient.php | 26 + .../laravel/tests/Fixtures/GroupsResolver.php | 11 + .../tests/Fixtures/IdentityResolver.php | 11 + .../laravel/tests/Fixtures/IgnoredFailure.php | 9 + .../tests/Fixtures/IgnoredOctaneFailure.php | 9 + .../tests/Fixtures/ScopedFailingJob.php | 17 + packages/laravel/tests/OctaneTest.php | 86 ++++ packages/laravel/tests/RuntimeTest.php | 482 ++++++++++++++++++ 28 files changed, 1871 insertions(+) create mode 100644 packages/laravel/.gitignore create mode 100644 packages/laravel/LICENSE.md create mode 100644 packages/laravel/README.md create mode 100644 packages/laravel/composer.json create mode 100644 packages/laravel/config/posthog.php create mode 100644 packages/laravel/phpunit.xml create mode 100644 packages/laravel/src/Commands/DoctorCommand.php create mode 100644 packages/laravel/src/Commands/InstallCommand.php create mode 100644 packages/laravel/src/Commands/TestCommand.php create mode 100644 packages/laravel/src/Context.php create mode 100644 packages/laravel/src/ExceptionReporter.php create mode 100644 packages/laravel/src/Facades/PostHog.php create mode 100644 packages/laravel/src/FlagDefinitionCacheProvider.php create mode 100644 packages/laravel/src/Middleware/RequestContext.php create mode 100644 packages/laravel/src/PostHog.php create mode 100644 packages/laravel/src/PostHogServiceProvider.php create mode 100644 packages/laravel/src/Testing/PostHogFake.php create mode 100644 packages/laravel/tests/Fixtures/CaptureCallback.php create mode 100644 packages/laravel/tests/Fixtures/CaptureJob.php create mode 100644 packages/laravel/tests/Fixtures/FailingCaptureJob.php create mode 100644 packages/laravel/tests/Fixtures/FlushClient.php create mode 100644 packages/laravel/tests/Fixtures/GroupsResolver.php create mode 100644 packages/laravel/tests/Fixtures/IdentityResolver.php create mode 100644 packages/laravel/tests/Fixtures/IgnoredFailure.php create mode 100644 packages/laravel/tests/Fixtures/IgnoredOctaneFailure.php create mode 100644 packages/laravel/tests/Fixtures/ScopedFailingJob.php create mode 100644 packages/laravel/tests/OctaneTest.php create mode 100644 packages/laravel/tests/RuntimeTest.php diff --git a/packages/laravel/.gitignore b/packages/laravel/.gitignore new file mode 100644 index 00000000..559dc605 --- /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 100644 index 00000000..2c58e5d9 --- /dev/null +++ b/packages/laravel/LICENSE.md @@ -0,0 +1,23 @@ +MIT License + +Copyright (c) 2020 PostHog (part of Hiberly Inc) + +Copyright (c) 2014 Segment Inc. friends@segment.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/laravel/README.md b/packages/laravel/README.md new file mode 100644 index 00000000..ebf74451 --- /dev/null +++ b/packages/laravel/README.md @@ -0,0 +1,222 @@ +# 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 the 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. + +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. 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. +Auth is resolved lazily only while handling a request; no user model is retained by +the analytics context after cleanup. Queue jobs start fresh; identity is not copied +into job payloads. Set it explicitly inside jobs. Nested synchronous jobs restore the +caller's context when they finish or fail. + +Global request middleware, queue events, Artisan command events, and Octane +request/task/tick events flush and clean context automatically. 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`. + +```php +try { + // Application work... +} catch (Throwable $exception) { + PostHog::captureException($exception, properties: ['component' => 'import']); +} +``` + +Manual captures and ordinary `report()` calls are handled. 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. + +## 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; 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`. +All other public PHP client methods are forwarded with current context: + +```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. 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. 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`. + +## 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. + +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). + +Package development uses a local Composer path repository pointing at the core SDK: + +```sh +cd packages/laravel +composer install +composer test +``` + +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 00000000..23675644 --- /dev/null +++ b/packages/laravel/composer.json @@ -0,0 +1,24 @@ +{ + "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" + }, + "repositories": [{"type": "path", "url": "../..", "options": {"versions": {"posthog/posthog-php": "4.20.x-dev"}}}], + "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 00000000..6ce0cded --- /dev/null +++ b/packages/laravel/config/posthog.php @@ -0,0 +1,25 @@ + 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, + // 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 00000000..4c162c25 --- /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 00000000..f4981626 --- /dev/null +++ b/packages/laravel/src/Commands/DoctorCommand.php @@ -0,0 +1,30 @@ +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; + } + $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 00000000..134b465e --- /dev/null +++ b/packages/laravel/src/Commands/InstallCommand.php @@ -0,0 +1,22 @@ +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 00000000..63a0a912 --- /dev/null +++ b/packages/laravel/src/Commands/TestCommand.php @@ -0,0 +1,29 @@ +option('send') || !$posthog->enabled()) { + $this->warn('No event sent. Configure PostHog and pass --send to opt in.'); + return self::FAILURE; + } + $captured = $posthog->capture('posthog test event', [], 'posthog-test-' . Str::uuid()); + $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/Context.php b/packages/laravel/src/Context.php new file mode 100644 index 00000000..d867c1de --- /dev/null +++ b/packages/laravel/src/Context.php @@ -0,0 +1,114 @@ +context = $data; + $this->flags = []; + $this->request = null; + } + + public function end(): void + { + $this->begin(); + } + + public function data(): array + { + $data = $this->context; + try { + $resolver = $this->app['config']->get('posthog.distinct_id'); + $id = $resolver ? ($this->app->make($resolver))($this->app) : null; + if ($id === null && $this->request !== null) { + $id = $this->request->user()?->getAuthIdentifier(); + } + } catch (Throwable) { + return $data; + } + 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) { + } + } + return $data; + } + + public function snapshot(): array + { + return $this->data(); + } + + public function with(array $data, callable $callback): mixed + { + $previous = $this->context; + $flags = $this->flags; + $request = $this->request; + $this->begin(array_replace_recursive($previous, $data)); + $this->request = $request; + 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; + } + } + + public function fromRequest(Request $request): void + { + $this->begin($this->app['config']->get('posthog.header_context', true) + ? CorePostHog::contextFromHeaders($request->headers->all()) : []); + $this->request = $request; + } + + /** @internal Isolate nested queue executions without discarding the caller's context. */ + public function enter(): void + { + $this->scopes[] = [$this->context, $this->flags, $this->request]; + $this->begin(); + } + + /** @internal Restore the scope active before queue execution. */ + public function leave(): void + { + if ($this->scopes === []) { + $this->end(); + return; + } + [$this->context, $this->flags, $this->request] = array_pop($this->scopes); + } + + 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 00000000..117c104d --- /dev/null +++ b/packages/laravel/src/ExceptionReporter.php @@ -0,0 +1,71 @@ +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): void + { + $this->rememberContext($exception); + $snapshot = $this->snapshots[$exception]; + $snapshot['options'] ??= ['handled' => false, 'mechanism' => $mechanism]; + $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' => 'middleware']; + } + + public function report(Throwable $exception): 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(); + $manager = $this->app->make(PostHog::class); + try { + $manager->reportException($exception, $snapshot['data'], $options); + $manager->flush(); + } 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 00000000..b8d38178 --- /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 00000000..f02f07e5 --- /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/Middleware/RequestContext.php b/packages/laravel/src/Middleware/RequestContext.php new file mode 100644 index 00000000..d0383438 --- /dev/null +++ b/packages/laravel/src/Middleware/RequestContext.php @@ -0,0 +1,35 @@ +context->fromRequest($request); + try { + return $next($request); + } catch (Throwable $exception) { + $this->reporter->remember($exception, 'middleware'); + throw $exception; + } finally { + try { + $this->posthog->flush(); + } catch (Throwable) { + // A transport failure must not change the HTTP response. + } finally { + $this->context->end(); + } + } + } +} diff --git a/packages/laravel/src/PostHog.php b/packages/laravel/src/PostHog.php new file mode 100644 index 00000000..9e1b538f --- /dev/null +++ b/packages/laravel/src/PostHog.php @@ -0,0 +1,226 @@ +app['config']->get('posthog.enabled', true) + && trim((string) $this->app['config']->get('posthog.api_key')) !== ''; + } + + public function client(): Client + { + if ($this->sdk !== null) { + return $this->sdk; + } + if (!$this->enabled()) { + return $this->sdk = 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'; + $options['library_name'] = 'posthog-laravel'; + $options['library_version'] = self::VERSION; + if (($config['release_id'] ?? null) !== null) { + $options['release_id'] = $config['release_id']; + } + // 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['flag_definition_cache_provider']) && ($config['flag_cache']['enabled'] ?? false)) { + $options['flag_definition_cache_provider'] = $this->app->make(FlagDefinitionCacheProvider::class); + } + return $this->sdk = new Client( + $config['api_key'], + $options, + loadFeatureFlags: false, + secretKey: $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) { + if ($flagKeys !== []) { + $this->loadDefinitionsIfNeeded(); + } + return $this->client()->evaluateFlags(...$inputs); + }); + } + + 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(); + } + } + + /** @internal Report a Laravel failure using its originating boundary snapshot. */ + public function reportException(Throwable $exception, array $snapshot, array $options): bool + { + if (!$this->enabled()) { + return false; + } + $properties = isset($snapshot['groups']) ? ['$groups' => $snapshot['groups']] : []; + return $this->client()->withContext( + $snapshot, + fn () => $this->client()->captureException($exception, null, $properties, $options), + ['fresh' => true], + ); + } + + public function flush(): bool + { + return $this->sdk !== null ? (bool) $this->sdk->flush() : false; + } + + public function shutdown(): bool + { + return $this->sdk !== null ? $this->sdk->shutdown() : false; + } + + public function __call(string $method, array $arguments): mixed + { + $data = $this->app->make(Context::class)->data(); + $legacyFlags = [ + 'getAllFlags' => 0, + 'getFeatureFlagResult' => 1, + 'getFeatureFlag' => 1, + 'getFeatureFlagPayload' => 1, + 'isFeatureEnabled' => 1, + ]; + if ($this->enabled() && isset($legacyFlags[$method])) { + $distinctId = $arguments['distinctId'] ?? $arguments[$legacyFlags[$method]] ?? null; + if ($distinctId === null || $distinctId === '') { + $distinctId = $data['distinctId'] ?? null; + } + if (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 00000000..3743ebbe --- /dev/null +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -0,0 +1,122 @@ +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(\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); + }); + $this->callAfterResolving(ExceptionHandler::class, function ($handler) { + if (method_exists($handler, 'reportable')) { + $handler->reportable(function (Throwable $exception) { + Container::getInstance()->make(ExceptionReporter::class)->report($exception); + }); + } + }); + $events = $this->app['events']; + $events->listen(JobProcessing::class, function () { + Container::getInstance()->make(Context::class)->enter(); + }); + $events->listen(JobExceptionOccurred::class, function ($event) { + $app = Container::getInstance(); + $app->make(ExceptionReporter::class)->remember($event->exception, 'task'); + $this->finish($app, true); + }); + $events->listen(JobProcessed::class, fn () => $this->finish(Container::getInstance(), true)); + foreach ( + [ + CommandStarting::class, + '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(); + } + }); + } + foreach ( + [ + Looping::class, + CommandFinished::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) { + $app = $event->sandbox; + $app->make(ExceptionReporter::class)->remember($event->exception, 'runtime'); + $this->finish($app); + }); + $this->app->terminating(fn () => $this->finish(Container::getInstance())); + } + + private function finish(Container $app, bool $queue = false): void + { + try { + $app->make(PostHog::class)->flush(); + } catch (Throwable) { + // Lifecycle cleanup must continue when a transport fails. + } finally { + $context = $app->make(Context::class); + $queue ? $context->leave() : $context->end(); + } + } +} diff --git a/packages/laravel/src/Testing/PostHogFake.php b/packages/laravel/src/Testing/PostHogFake.php new file mode 100644 index 00000000..34d6b3a4 --- /dev/null +++ b/packages/laravel/src/Testing/PostHogFake.php @@ -0,0 +1,156 @@ +disabledClient ??= new Client(null, ['consumer' => 'noop'], loadFeatureFlags: false); + } + + protected function message(string $method, array $message): bool + { + $data = $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 reportException(Throwable $exception, array $snapshot, array $options): bool + { + $this->recorded[] = ['method' => 'captureException', 'message' => [ + 'exception' => $exception, + 'distinctId' => $snapshot['distinctId'] ?? null, + 'properties' => array_replace( + $snapshot['properties'] ?? [], + isset($snapshot['groups']) ? ['$groups' => $snapshot['groups']] : [], + ), + 'options' => $options, + ]]; + return true; + } + + public function flush(): bool + { + return true; + } + + public function shutdown(): bool + { + return true; + } +} diff --git a/packages/laravel/tests/Fixtures/CaptureCallback.php b/packages/laravel/tests/Fixtures/CaptureCallback.php new file mode 100644 index 00000000..f8b03f66 --- /dev/null +++ b/packages/laravel/tests/Fixtures/CaptureCallback.php @@ -0,0 +1,14 @@ +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 00000000..610eca4e --- /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 00000000..5259aae6 --- /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 00000000..ab863bd8 --- /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 00000000..3e32bb9d --- /dev/null +++ b/packages/laravel/tests/Fixtures/IdentityResolver.php @@ -0,0 +1,11 @@ +with(['distinctId' => $this->id, 'groups' => ['team' => 'job-team']], function () { + throw new RuntimeException('scoped failure'); + }); + } +} diff --git a/packages/laravel/tests/OctaneTest.php b/packages/laravel/tests/OctaneTest.php new file mode 100644 index 00000000..7b49a717 --- /dev/null +++ b/packages/laravel/tests/OctaneTest.php @@ -0,0 +1,86 @@ +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'] + ); + $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'], + ); + $this->assertCount(2, $fake->recorded('captureException')); + } +} diff --git a/packages/laravel/tests/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php new file mode 100644 index 00000000..6bc1a191 --- /dev/null +++ b/packages/laravel/tests/RuntimeTest.php @@ -0,0 +1,482 @@ +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, 'sdk'); + $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'], $messages[0]['properties']); + $this->assertSame(['$session_id' => 'session-second'], $messages[2]['properties']); + $this->assertSame([], $this->app->make(Context::class)->snapshot()); + $this->assertStringNotContainsString('private', json_encode($messages)); + } + + 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'] + ); + $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 + ); + $fake->assertExceptionCaptured( + fn ($message) => $message['distinctId'] === 'manual-user' + && $message['options']['handled'] === true + ); + } + + 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'] + ); + $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, 'sdk'))->setValue($manager, $client); + $context = $this->app->make(Context::class); + $input = new ArrayInput([]); + $output = new NullOutput(); + $this->app['events']->dispatch(new \Illuminate\Console\Events\CommandStarting('test', $input, $output)); + $context->begin(['distinctId' => 'console']); + $this->app['events']->dispatch(new \Illuminate\Console\Events\CommandFinished('test', $input, $output, 1)); + $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, 'sdk'))->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()); + $response = $this->app->make(RequestContext::class)->handle(Request::create('/'), fn () => 'ok'); + $this->assertSame('ok', $response); + $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, 'sdk'))->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, 'sdk'))->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, 'sdk'))->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'); + $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->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']); + } + + 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 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 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(); + } +} From 1536956a260c3b8f9568b15a0a17cbc77ed2a263 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 09:47:38 +0100 Subject: [PATCH 03/32] feat: propagate safe Laravel queue context and prepare package distribution --- .../413684c92c1a43b38f4ea69024be861c.json | 5 ++ packages/laravel/README.md | 21 ++++---- packages/laravel/composer.json | 1 - packages/laravel/config/posthog.php | 1 + .../laravel/src/PostHogServiceProvider.php | 11 +++- packages/laravel/src/QueueContext.php | 46 ++++++++++++++++ packages/laravel/src/Testing/PostHogFake.php | 3 +- .../tests/Fixtures/AmbientCaptureJob.php | 17 ++++++ packages/laravel/tests/QueueContextTest.php | 54 +++++++++++++++++++ packages/laravel/tests/RuntimeTest.php | 17 ++++++ 10 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 .laravel-changes/413684c92c1a43b38f4ea69024be861c.json create mode 100644 packages/laravel/src/QueueContext.php create mode 100644 packages/laravel/tests/Fixtures/AmbientCaptureJob.php create mode 100644 packages/laravel/tests/QueueContextTest.php diff --git a/.laravel-changes/413684c92c1a43b38f4ea69024be861c.json b/.laravel-changes/413684c92c1a43b38f4ea69024be861c.json new file mode 100644 index 00000000..c17b3645 --- /dev/null +++ b/.laravel-changes/413684c92c1a43b38f4ea69024be861c.json @@ -0,0 +1,5 @@ +{ + "package": "posthog-laravel", + "bump": "initial", + "summary": "Add the Laravel package with request and worker context, exception reporting, flags, and test fakes." +} diff --git a/packages/laravel/README.md b/packages/laravel/README.md index ebf74451..0fb38816 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -81,9 +81,12 @@ app(Context::class)->with([ `Context::begin()`, `data()`, `snapshot()` and `end()` support explicit integrations. `with()` restores the previous data and flag snapshots even when the callback throws. Auth is resolved lazily only while handling a request; no user model is retained by -the analytics context after cleanup. Queue jobs start fresh; identity is not copied -into job payloads. Set it explicitly inside jobs. Nested synchronous jobs restore the -caller's context when they finish or fail. +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 restore the caller's context +when they finish or fail. Global request middleware, queue events, Artisan command events, and Octane request/task/tick events flush and clean context automatically. SDK creation is lazy, @@ -210,13 +213,11 @@ assertions accept an optional predicate. `flags($coreSnapshot)` configures fake 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). -Package development uses a local Composer path repository pointing at the core SDK: - -```sh -cd packages/laravel -composer install -composer test -``` +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 index 23675644..a5fd4d10 100644 --- a/packages/laravel/composer.json +++ b/packages/laravel/composer.json @@ -14,7 +14,6 @@ "phpunit/phpunit": "^11.0 || ^12.0", "laravel/octane": "^2.0" }, - "repositories": [{"type": "path", "url": "../..", "options": {"versions": {"posthog/posthog-php": "4.20.x-dev"}}}], "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"}}}, diff --git a/packages/laravel/config/posthog.php b/packages/laravel/config/posthog.php index 6ce0cded..133a1826 100644 --- a/packages/laravel/config/posthog.php +++ b/packages/laravel/config/posthog.php @@ -8,6 +8,7 @@ '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. diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php index 3743ebbe..6143c0b6 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -61,8 +61,15 @@ public function boot(): void } }); $events = $this->app['events']; - $events->listen(JobProcessing::class, function () { - Container::getInstance()->make(Context::class)->enter(); + 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) { $app = Container::getInstance(); diff --git a/packages/laravel/src/QueueContext.php b/packages/laravel/src/QueueContext.php new file mode 100644 index 00000000..5f6d1643 --- /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/Testing/PostHogFake.php b/packages/laravel/src/Testing/PostHogFake.php index 34d6b3a4..61fd6aa9 100644 --- a/packages/laravel/src/Testing/PostHogFake.php +++ b/packages/laravel/src/Testing/PostHogFake.php @@ -27,7 +27,8 @@ public function client(): Client protected function message(string $method, array $message): bool { - $data = $this->app->make(Context::class)->data(); + $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'] ?? []); diff --git a/packages/laravel/tests/Fixtures/AmbientCaptureJob.php b/packages/laravel/tests/Fixtures/AmbientCaptureJob.php new file mode 100644 index 00000000..ff5dbb74 --- /dev/null +++ b/packages/laravel/tests/Fixtures/AmbientCaptureJob.php @@ -0,0 +1,17 @@ +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/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php index 6bc1a191..7be2b6f4 100644 --- a/packages/laravel/tests/RuntimeTest.php +++ b/packages/laravel/tests/RuntimeTest.php @@ -445,6 +445,23 @@ public function testInstallPublishesWithoutOverwritingApplicationConfig(): void } } + 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); From 18d9ac6845cecda568626278ad364e39e7de545b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 09:51:01 +0100 Subject: [PATCH 04/32] feat: enrich Laravel captures with safe request metadata --- packages/laravel/README.md | 2 ++ packages/laravel/src/Context.php | 8 ++++++++ packages/laravel/tests/RuntimeTest.php | 8 ++++++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 0fb38816..2caaed6e 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -51,6 +51,8 @@ fallback. `X-PostHog-Session-Id` provides session correlation. Headers are untru 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 diff --git a/packages/laravel/src/Context.php b/packages/laravel/src/Context.php index d867c1de..8867d1d6 100644 --- a/packages/laravel/src/Context.php +++ b/packages/laravel/src/Context.php @@ -4,6 +4,7 @@ use Illuminate\Contracts\Container\Container; use Illuminate\Http\Request; +use Illuminate\Routing\Route; use PostHog\FeatureFlagEvaluations; use PostHog\PostHog as CorePostHog; use Throwable; @@ -34,6 +35,13 @@ public function end(): void public function data(): array { $data = $this->context; + 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(), '/'); + } + } try { $resolver = $this->app['config']->get('posthog.distinct_id'); $id = $resolver ? ($this->app->make($resolver))($this->app) : null; diff --git a/packages/laravel/tests/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php index 7be2b6f4..f170d2c8 100644 --- a/packages/laravel/tests/RuntimeTest.php +++ b/packages/laravel/tests/RuntimeTest.php @@ -90,8 +90,12 @@ public function testActualHttpRequestsResolveAuthAfterMiddlewareAndNeverLeak(): } $messages = array_column($fake->recorded('capture'), 'message'); $this->assertSame(['first', 'override', 'second', 'override'], array_column($messages, 'distinctId')); - $this->assertSame(['$session_id' => 'session-first'], $messages[0]['properties']); - $this->assertSame(['$session_id' => 'session-second'], $messages[2]['properties']); + $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)); } From 04967bfa01bda70ca96ce7ec4d17d982f0e5613a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 09:54:22 +0100 Subject: [PATCH 05/32] feat: add opt-in Laravel server event collectors --- packages/laravel/AUTOCAPTURE.md | 186 +++++++ packages/laravel/README.md | 9 +- packages/laravel/config/autocapture.php | 23 + .../laravel/src/Autocapture/AuthEvents.php | 33 ++ .../laravel/src/Autocapture/Collector.php | 63 +++ .../laravel/src/Autocapture/DomainEvents.php | 37 ++ .../laravel/src/Autocapture/ModelEvents.php | 40 ++ packages/laravel/src/Autocapture/Outcomes.php | 56 +++ .../src/Autocapture/RequestSummary.php | 78 +++ .../src/AutocaptureServiceProvider.php | 113 +++++ packages/laravel/tests/AutocaptureTest.php | 452 ++++++++++++++++++ .../tests/Fixtures/CollectedRecord.php | 12 + .../laravel/tests/Fixtures/OrderCompleted.php | 14 + 13 files changed, 1114 insertions(+), 2 deletions(-) create mode 100644 packages/laravel/AUTOCAPTURE.md create mode 100644 packages/laravel/config/autocapture.php create mode 100644 packages/laravel/src/Autocapture/AuthEvents.php create mode 100644 packages/laravel/src/Autocapture/Collector.php create mode 100644 packages/laravel/src/Autocapture/DomainEvents.php create mode 100644 packages/laravel/src/Autocapture/ModelEvents.php create mode 100644 packages/laravel/src/Autocapture/Outcomes.php create mode 100644 packages/laravel/src/Autocapture/RequestSummary.php create mode 100644 packages/laravel/src/AutocaptureServiceProvider.php create mode 100644 packages/laravel/tests/AutocaptureTest.php create mode 100644 packages/laravel/tests/Fixtures/CollectedRecord.php create mode 100644 packages/laravel/tests/Fixtures/OrderCompleted.php diff --git a/packages/laravel/AUTOCAPTURE.md b/packages/laravel/AUTOCAPTURE.md new file mode 100644 index 00000000..720aabd4 --- /dev/null +++ b/packages/laravel/AUTOCAPTURE.md @@ -0,0 +1,186 @@ +# Optional server event collectors + +These are **server events**, not UI interaction autocapture. The package does not +observe clicks or emit `$autocapture`. All collectors and Blade flag conditionals +are off by default. The main `PostHogServiceProvider` discovers the separate +`PostHog\Laravel\AutocaptureServiceProvider` automatically. Register only the main +provider; registering the collector provider again does not add duplicate listeners. + +## Configuration + +Publish the optional configuration: + +```sh +php artisan vendor:publish --tag=posthog-autocapture-config +``` + +Edit `config/posthog-autocapture.php`. The complete defaults are in +[`config/autocapture.php`](config/autocapture.php). These settings are merged into +`posthog.autocapture`; values explicitly set under `autocapture` in +`config/posthog.php` take precedence **per top-level section**. When overriding a +section there, include its complete settings. Both approaches support config caching. +Use class names, not closures, in configuration. + +```php +return [ + 'requests' => [ + 'enabled' => true, + 'exclude' => ['up', 'health', 'health/*', 'assets/*', 'build/*', '*.css', '*.js'], + 'sample_rate' => 1.0, + ], + 'pageviews' => ['enabled' => false], + 'auth' => [ + 'enabled' => true, + 'events' => ['registered', 'login', 'logout'], + ], + 'models' => [ + 'enabled' => true, + 'allow' => [App\Models\Order::class => ['state']], + ], + 'domain_events' => [ + App\Events\OrderCompleted::class => [ + 'event' => 'order_completed', + 'properties' => ['amount' => 'amount', 'currency' => 'details.currency'], + ], + ], + 'jobs' => ['enabled' => true, 'exclude' => [App\Jobs\InternalMaintenance::class]], + 'commands' => ['enabled' => false, 'exclude' => ['posthog:*']], + 'scheduler' => ['enabled' => false, 'exclude' => []], + 'blade' => ['enabled' => false], +]; +``` + +Every `enabled` default is `false`. `domain_events` and `models.allow` default to +empty arrays. Auth defaults include only registered/login/logout; add `failed` and +`lockout` explicitly to capture those outcomes. `exclude` patterns use Laravel's +case-sensitive `Str::is` wildcard matching. Request exclusions match the route +and concrete path **only for filtering**, without capturing that concrete path. +Defaults exclude `up`, `health`, `health/*`, `build/*`, `assets/*`, `vendor/*`, +`favicon.ico`, `robots.txt`, and common asset extensions; add your application's +other health/static routes. Unmatched routes are never captured. Sampling clamps +`requests.sample_rate` to 0–1 and makes one decision per request for both summary +and pageview events. Zero drops both; the default 1 captures every eligible request. + +### Privacy and identity + +Collectors emit only the properties listed below, plus normal SDK metadata and +explicit identity/groups. Arbitrary context properties are not copied. Collection +never reads request bodies, query values, cookies, credentials, email, IP addresses, +job arguments or full model/event payloads. Auth uses `getAuthIdentifier()`; configure +an opaque identifier rather than email if your application customizes that method. +Application-selected attributes/property mappings may contain PII: allowlist only +fields you intend to send and use `sdk.before_send` for additional redaction. + +Events require an existing analytics identity; collectors do not invent anonymous +IDs. HTTP uses the runtime's lazy authenticated identity/header fallback. Queue jobs +use the runtime's bounded queue context propagation or explicit job context. Console +and scheduler events need explicit context or a configured identity resolver. Failed +login/lockout events may use existing anonymous context but **never** the attempted +user identifier. Missing credentials or disabled SDK configuration cannot send traffic. +Failures in analytics do not change application responses or replace original errors. + +## Exact event schemas + +The following properties are collector-owned. `duration_ms` is a nonnegative float +measured by a monotonic clock, not a trace/span. No request IDs or parameters are +added implicitly. Each event passes through the ordinary PHP capture pipeline. + +| Event | Properties | +| --- | --- | +| `laravel_request` | `route: string`, `method: string`, `status_code: int`, `duration_ms: float` | +| `$pageview` | Request properties plus `$current_url: string`, `$pathname: string` | +| `laravel_auth_registered` | Empty properties | +| `laravel_auth_login` | `guard: string` when supplied | +| `laravel_auth_logout` | `guard: string` when supplied | +| `laravel_auth_failed` | `guard: string` when supplied; no credentials or attempted ID | +| `laravel_auth_lockout` | Empty properties; no request/credentials/attempted ID | +| `laravel_model_created` | `model: string` (exact class), `attributes: array` (only allowlisted JSON-safe values) | +| `laravel_model_updated` | Same as created; selected values at update time, not a before/after diff | +| `laravel_model_deleted` | Same as created; selected values at deletion time | +| Configured domain event name | Only explicitly mapped output names and JSON-safe values | +| `laravel_job` | `name: string` (queued job class, not display name), `outcome: 'success'\|'failed'`, `attempts: int`, `duration_ms: float` | +| `laravel_command` | `name: string` (first command token only), `outcome: 'success'\|'failed'`, `exit_code: int`, `duration_ms: float` | +| `laravel_scheduled_task` | `name: string` (task PHP class only), `outcome: 'success'\|'failed'\|'background_started'`, `exit_code: int\|null`, `duration_ms: float` | + +### Requests and server pageviews + +`route` and `$pathname` are normalized matched route templates with a leading `/`, +for example `/orders/{order}`, never `/orders/123`. Request summaries include all +methods and status codes for eligible matched routes. Escaping middleware errors +are summarized as 500 and rethrown unchanged; framework-rendered errors use the +response status. Streaming duration measures response creation, not body delivery. + +Pageviews are a **separate opt-in** and require a matched route, HTML Content-Type, +GET method, and 2xx status. They can be enabled without request summaries. The +`$current_url` is the sanitized configured `app.url` origin (HTTP/HTTPS scheme, +host and optional port only) followed by the route template. Credentials, paths +and queries in `app.url` are discarded; the request Host header is not trusted. +Invalid configured origins suppress pageviews. + +**Browser duplication warning:** disable server pageviews if a browser SDK already +captures the same pageviews, or disable equivalent browser pageviews. No automatic +cross-SDK deduplication is provided. Server pageviews do not track client-side +navigation, browser lifecycle or actual browser rendering. + +### Models and transactions + +Only exact allowlisted Eloquent classes are collected for created/updated/deleted +lifecycle events. An empty attribute list emits class metadata with an empty +`attributes` array—no implicit primary key, relation, dirty attributes or `toArray()`. +Only existing attributes are read, with their normal casts/accessors; JSON-safe +scalar/null/array values are accepted, objects/resources/invalid JSON values are +omitted. Do not allowlist a sensitive accessor or JSON column. + +Values, identity and groups are snapshotted at the change, then emitted **after the +model connection's outer transaction commits**. Rollback (including a nested +savepoint rollback) discards the corresponding pending events. No model object is +retained by the commit callback. Outside a transaction, collection is immediate. +This is not a durable outbox; process crashes or transport failures can lose events. +Each actual model lifecycle event generates one event: multiple saves are not +coalesced. Bulk query-builder updates/deletes do not produce model events. Soft +deletes produce the normal deleted event; restores are not collected. + +### Domain events + +Mappings select exact event classes and named public property/array-key paths. +No wildcard subscriptions, getter calls, magic model attribute access, model +serialization or automatic inheritance matching are performed. Missing paths and +non-JSON-safe values are omitted. Domain collection is immediate, including inside +a transaction; use Laravel's own after-commit event dispatch when the domain event +must reflect committed state. Mapping is read at provider boot, so rebuild cached +config/restart long-running workers after changing it. + +### Worker outcomes and duplication + +Jobs emit once per started attempt; a retry is another attempt, not deduplicated +against the previous failure. Repeated completion events for an already completed +attempt are ignored. Command metadata excludes all arguments/options. Scheduler +names deliberately use task PHP classes, not descriptions or command lines. A +foreground task emits one outcome even when Laravel emits Finished then Failed for +a nonzero exit. Background launch emits `background_started`, **not completion**; +there is no cross-process completion/heartbeat guarantee. Skipped scheduled tasks +and hard-killed jobs/commands without a terminal framework event are not captured. +Weak object keys retain neither worker payloads nor completed execution objects. + +Enabling several collectors intentionally produces different events for different +boundaries: a request that logs in a user and saves a model can produce request, +auth, and model events. Explicit application captures remain independent. The +package does not deduplicate these against your manual instrumentation. + +## Optional Blade flag conditionals + +Enable `blade.enabled`, then use: + +```blade +@posthogFeature('new-checkout') + +@else + +@endposthogFeature +``` + +This reads `evaluateFlags()->isEnabled()` using the runtime's existing context +snapshot. Repeated checks share evaluation and core exposure deduplication; a new +identity/context evaluates separately. Disabled/unavailable flags fail closed. +There is no boot-time evaluation, payload read, independent flag cache, or custom +exposure event. This conditional is a presentation helper, **not authorization**. diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 0fb38816..4fed8082 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -194,8 +194,13 @@ metadata; advanced SDK options remain accessible under `sdk`. - `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. -No collectors are enabled by this foundation package. Optional collectors can be -registered by a separate `AutocaptureServiceProvider` when installed. +Optional server event collectors are included but **all are off by default**. +The main provider discovers the separate `AutocaptureServiceProvider` automatically. +See [collector configuration and exact event schemas](AUTOCAPTURE.md) for request +summaries, separately enabled server HTML pageviews, auth outcomes, explicit domain +mappings, after-commit model events, worker outcomes, and Blade flag conditionals. +Server pageviews can duplicate browser SDK pageviews: enable only one equivalent +source. These collectors do not observe UI interactions or emit `$autocapture`. ## Testing diff --git a/packages/laravel/config/autocapture.php b/packages/laravel/config/autocapture.php new file mode 100644 index 00000000..17d36369 --- /dev/null +++ b/packages/laravel/config/autocapture.php @@ -0,0 +1,23 @@ + [ + 'enabled' => false, + 'exclude' => [ + 'up', 'health', 'health/*', 'build/*', 'assets/*', 'vendor/*', 'favicon.ico', 'robots.txt', + '*.css', '*.js', '*.map', '*.png', '*.jpg', '*.svg', '*.ico', '*.woff*', + ], + 'sample_rate' => 1.0, + ], + // Server HTML pageviews can duplicate browser SDK pageviews. Enable only one source. + 'pageviews' => ['enabled' => false], + 'auth' => ['enabled' => false, 'events' => ['registered', 'login', 'logout']], + // Exact model class => approved attribute names. Empty lists capture class metadata only. + 'models' => ['enabled' => false, 'allow' => []], + // Exact event class => ['event' => 'name', 'properties' => ['output' => 'public.property.path']]. + 'domain_events' => [], + 'jobs' => ['enabled' => false, 'exclude' => []], + 'commands' => ['enabled' => false, 'exclude' => []], + 'scheduler' => ['enabled' => false, 'exclude' => []], + 'blade' => ['enabled' => false], +]; diff --git a/packages/laravel/src/Autocapture/AuthEvents.php b/packages/laravel/src/Autocapture/AuthEvents.php new file mode 100644 index 00000000..0ba5300a --- /dev/null +++ b/packages/laravel/src/Autocapture/AuthEvents.php @@ -0,0 +1,33 @@ +get( + 'posthog.autocapture.auth.events', + [], + ), true) + ) { + return; + } + try { + $snapshot = Collector::snapshot(); + if (!in_array($name, ['failed', 'lockout'], true)) { + $id = $event->user?->getAuthIdentifier(); + if (is_scalar($id) && (string) $id !== '') { + $snapshot['distinctId'] = (string) $id; + } + } + $properties = isset($event->guard) && is_string($event->guard) ? ['guard' => $event->guard] : []; + Collector::capture('laravel_auth_' . $name, $properties, $snapshot); + } catch (Throwable) { + } + } +} diff --git a/packages/laravel/src/Autocapture/Collector.php b/packages/laravel/src/Autocapture/Collector.php new file mode 100644 index 00000000..cd75eb8e --- /dev/null +++ b/packages/laravel/src/Autocapture/Collector.php @@ -0,0 +1,63 @@ +get('posthog.autocapture.' . $section . '.enabled', false); + } + + public static function snapshot(): array + { + return array_intersect_key(Container::getInstance()->make(Context::class)->snapshot(), [ + 'distinctId' => true, 'groups' => true, + ]); + } + + public static function capture(string $event, array $properties, ?array $snapshot = null): void + { + $context = null; + try { + $app = Container::getInstance(); + $snapshot ??= self::snapshot(); + $id = $snapshot['distinctId'] ?? null; + if (!is_scalar($id) || (string) $id === '') { + return; + } + $context = $app->make(Context::class); + $context->enter(); + $context->begin($snapshot); + $app->make(PostHog::class)->capture([ + 'event' => $event, 'properties' => $properties, + 'distinctId' => (string) $id, 'groups' => $snapshot['groups'] ?? [], + ]); + } catch (Throwable) { + // Analytics must never replace an application result or its original exception. + } finally { + $context?->leave(); + } + } + + public static function jsonSafe(mixed $value, int $depth = 0): bool + { + if ($depth > 16 || is_object($value) || is_resource($value)) { + return false; + } + if (is_array($value)) { + foreach ($value as $item) { + if (!self::jsonSafe($item, $depth + 1)) { + return false; + } + } + } + return json_encode($value) !== false; + } +} diff --git a/packages/laravel/src/Autocapture/DomainEvents.php b/packages/laravel/src/Autocapture/DomainEvents.php new file mode 100644 index 00000000..e75405df --- /dev/null +++ b/packages/laravel/src/Autocapture/DomainEvents.php @@ -0,0 +1,37 @@ + $path) { + if (!is_string($name) || !is_string($path)) { + continue; + } + $value = $event; + foreach (explode('.', $path) as $part) { + // Public properties and array keys only: never invoke model magic or getters. + $values = is_object($value) ? get_object_vars($value) : $value; + if (!is_array($values) || !array_key_exists($part, $values)) { + continue 2; + } + $value = $values[$part]; + } + if (Collector::jsonSafe($value)) { + $properties[$name] = $value; + } + } + Collector::capture($mapping['event'], $properties); + } catch (Throwable) { + } + } +} diff --git a/packages/laravel/src/Autocapture/ModelEvents.php b/packages/laravel/src/Autocapture/ModelEvents.php new file mode 100644 index 00000000..e924610b --- /dev/null +++ b/packages/laravel/src/Autocapture/ModelEvents.php @@ -0,0 +1,40 @@ +get('posthog.autocapture.models.allow', []); + if (!array_key_exists($model::class, $models) || !is_array($models[$model::class])) { + return; + } + $attributes = []; + $stored = $model->getAttributes(); + foreach ($models[$model::class] as $key) { + if (is_string($key) && array_key_exists($key, $stored)) { + $value = $model->getAttribute($key); + if (Collector::jsonSafe($value)) { + $attributes[$key] = $value; + } + } + } + $properties = ['model' => $model::class, 'attributes' => $attributes]; + $snapshot = Collector::snapshot(); + // Register with this model's connection; no model or payload survives until commit. + $model->getConnection()->afterCommit(static function () use ($operation, $properties, $snapshot) { + Collector::capture('laravel_model_' . $operation, $properties, $snapshot); + }); + } catch (Throwable) { + } + } +} diff --git a/packages/laravel/src/Autocapture/Outcomes.php b/packages/laravel/src/Autocapture/Outcomes.php new file mode 100644 index 00000000..7bd36f0b --- /dev/null +++ b/packages/laravel/src/Autocapture/Outcomes.php @@ -0,0 +1,56 @@ +starts = new WeakMap(); + } + + public function start(string $section, object $key): void + { + if (Collector::enabled($section)) { + $this->starts[$key] = hrtime(true); + } + } + + public function finish(string $section, object $key, callable $metadata): void + { + if (!isset($this->starts[$key])) { + return; + } + $started = $this->starts[$key]; + unset($this->starts[$key]); + try { + if (!Collector::enabled($section)) { + return; + } + $properties = $metadata(); + if (!is_string($properties['name'] ?? null) || $properties['name'] === '') { + return; + } + $excluded = Container::getInstance()['config']->get('posthog.autocapture.' . $section . '.exclude', []); + foreach ($excluded as $pattern) { + if (Str::is($pattern, $properties['name'])) { + return; + } + } + $properties['duration_ms'] = (hrtime(true) - $started) / 1e6; + $event = [ + 'jobs' => 'laravel_job', 'commands' => 'laravel_command', 'scheduler' => 'laravel_scheduled_task', + ][$section]; + Collector::capture($event, $properties); + } catch (Throwable) { + } + } +} diff --git a/packages/laravel/src/Autocapture/RequestSummary.php b/packages/laravel/src/Autocapture/RequestSummary.php new file mode 100644 index 00000000..d8fe8ca7 --- /dev/null +++ b/packages/laravel/src/Autocapture/RequestSummary.php @@ -0,0 +1,78 @@ +collect($request, $response, $started); + } catch (Throwable) { + // Never mask an application failure, including a failing collector callback. + } + } + } + + private function collect(Request $request, mixed $response, int $started): void + { + if (!Collector::enabled('requests') && !Collector::enabled('pageviews')) { + return; + } + $route = $request->route(); + if (!$route instanceof Route) { + return; + } + $template = '/' . ltrim($route->uri(), '/'); + $config = Container::getInstance()['config']; + foreach ($config->get('posthog.autocapture.requests.exclude', []) as $pattern) { + $pattern = ltrim($pattern, '/'); + if (Str::is($pattern, ltrim($template, '/')) || Str::is($pattern, $request->path())) { + return; + } + } + $rate = max(0.0, min(1.0, (float) $config->get('posthog.autocapture.requests.sample_rate', 1.0))); + if ($rate === 0.0 || ($rate < 1.0 && random_int(1, 1000000) / 1000000 > $rate)) { + return; + } + $properties = [ + 'route' => $template, + 'method' => $request->method(), + 'status_code' => $response?->getStatusCode() ?? 500, + 'duration_ms' => (hrtime(true) - $started) / 1e6, + ]; + if (Collector::enabled('requests')) { + Collector::capture('laravel_request', $properties); + } + if ( + Collector::enabled('pageviews') && $request->isMethod('GET') + && $properties['status_code'] >= 200 && $properties['status_code'] < 300 + && str_starts_with(strtolower($response?->headers->get('Content-Type', '') ?? ''), 'text/html') + ) { + // A configured origin avoids trusting a request Host header or exposing URL credentials. + $url = parse_url((string) $config->get('app.url')); + if ( + !is_array($url) || !isset($url['scheme'], $url['host']) + || !in_array($url['scheme'], ['http', 'https'], true) + ) { + return; + } + $origin = $url['scheme'] . '://' . $url['host'] . (isset($url['port']) ? ':' . $url['port'] : ''); + Collector::capture('$pageview', $properties + [ + '$current_url' => $origin . $template, '$pathname' => $template, + ]); + } + } +} diff --git a/packages/laravel/src/AutocaptureServiceProvider.php b/packages/laravel/src/AutocaptureServiceProvider.php new file mode 100644 index 00000000..2c1b4c29 --- /dev/null +++ b/packages/laravel/src/AutocaptureServiceProvider.php @@ -0,0 +1,113 @@ +mergeConfigFrom(__DIR__ . '/../config/autocapture.php', 'posthog-autocapture'); + $config = $this->app['config']; + $config->set('posthog.autocapture', array_replace( + $config->get('posthog-autocapture', []), + $config->get('posthog.autocapture', []), + )); + } + + public function boot(): void + { + $this->publishes([ + __DIR__ . '/../config/autocapture.php' => config_path('posthog-autocapture.php'), + ], 'posthog-autocapture-config'); + $this->callAfterResolving(Kernel::class, fn ($kernel) => $kernel->prependMiddleware(RequestSummary::class)); + $events = $this->app['events']; + foreach (['Registered', 'Login', 'Logout', 'Failed', 'Lockout'] as $name) { + $events->listen('Illuminate\\Auth\\Events\\' . $name, static function ($event) use ($name) { + (new AuthEvents())->collect(strtolower($name), $event); + }); + } + foreach (['created', 'updated', 'deleted'] as $operation) { + $events->listen('eloquent.' . $operation . ': *', static function ($name, $payload) use ($operation) { + (new ModelEvents())->collect($operation, $payload[0]); + }); + } + foreach ($this->app['config']->get('posthog.autocapture.domain_events', []) as $class => $mapping) { + if (is_string($class) && is_array($mapping)) { + $events->listen($class, static function ($event) use ($class, $mapping) { + if (is_object($event) && $event::class === $class) { + (new DomainEvents())->collect($event, $mapping); + } + }); + } + } + $this->registerOutcomes($events); + $this->callAfterResolving('blade.compiler', static function ($blade) { + $blade->if('posthogFeature', static function (string $key): bool { + if (!Collector::enabled('blade')) { + return false; + } + try { + return Container::getInstance()->make(PostHog::class)->evaluateFlags()->isEnabled($key); + } catch (Throwable) { + return false; + } + }); + }); + } + + private function registerOutcomes(Dispatcher $events): void + { + $outcomes = new Outcomes(); + $events->listen(\Illuminate\Queue\Events\JobProcessing::class, static function ($event) use ($outcomes) { + $outcomes->start('jobs', $event->job); + }); + foreach (['JobProcessed' => 'success', 'JobExceptionOccurred' => 'failed'] as $event => $outcome) { + $events->listen('Illuminate\\Queue\\Events\\' . $event, static function ($event) use ($outcomes, $outcome) { + $outcomes->finish('jobs', $event->job, static fn () => [ + 'name' => $event->job->resolveQueuedJobClass(), + 'outcome' => $outcome, 'attempts' => $event->job->attempts(), + ]); + }); + } + $events->listen(\Illuminate\Console\Events\CommandStarting::class, static function ($event) use ($outcomes) { + $outcomes->start('commands', $event->input); + }); + $events->listen(\Illuminate\Console\Events\CommandFinished::class, static function ($event) use ($outcomes) { + $outcomes->finish('commands', $event->input, static fn () => [ + 'name' => preg_split('/\s+/', trim($event->command ?? ''))[0], + 'outcome' => $event->exitCode === 0 ? 'success' : 'failed', 'exit_code' => $event->exitCode, + ]); + }); + $events->listen( + \Illuminate\Console\Events\ScheduledTaskStarting::class, + static function ($event) use ($outcomes) { + $outcomes->start('scheduler', $event->task); + }, + ); + foreach (['ScheduledTaskFinished' => false, 'ScheduledTaskFailed' => true] as $event => $failed) { + $events->listen( + 'Illuminate\\Console\\Events\\' . $event, + static function ($event) use ($outcomes, $failed) { + $outcomes->finish('scheduler', $event->task, static fn () => [ + 'name' => $event->task::class, + 'outcome' => $failed ? 'failed' : ($event->task->runInBackground ? 'background_started' + : ($event->task->exitCode === 0 ? 'success' : 'failed')), + 'exit_code' => $event->task->exitCode, + ]); + }, + ); + } + } +} diff --git a/packages/laravel/tests/AutocaptureTest.php b/packages/laravel/tests/AutocaptureTest.php new file mode 100644 index 00000000..0ef10fdb --- /dev/null +++ b/packages/laravel/tests/AutocaptureTest.php @@ -0,0 +1,452 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('queue.default', 'sync'); + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', ['driver' => 'sqlite', 'database' => ':memory:']); + $app['config']->set('posthog.autocapture.domain_events', [OrderCompleted::class => [ + 'event' => 'order_completed', + 'properties' => [ + 'amount' => 'amount', 'currency' => 'details.currency', 'unsafe' => 'model', 'absent' => 'absent', + ], + ]]); + } + + private function identity(string $id = 'actor'): void + { + $this->app->make(Context::class)->begin(['distinctId' => $id, 'groups' => ['team' => $id]]); + } + + private function enable(string $section): void + { + $this->app['config']->set('posthog.autocapture.' . $section . '.enabled', true); + } + + private function events(): array + { + return array_column(PostHog::getFacadeRoot()->recorded('capture'), 'message'); + } + + private function modelTable(): void + { + Schema::create('collected_records', function (Blueprint $table) { + $table->id(); + $table->string('state'); + $table->string('email'); + }); + } + + public function testDefaultCollectorsAreOffAndProviderRegistrationIsIdempotent(): void + { + $fake = PostHog::fake(); + $this->app->register(AutocaptureServiceProvider::class); + $this->identity(); + Route::get('/off', fn () => response('ok')->header('Content-Type', 'text/html')); + $this->get('/off', ['X-PostHog-Distinct-Id' => 'actor'])->assertOk(); + $this->app['events']->dispatch(new \Illuminate\Auth\Events\Login( + 'web', + new GenericUser(['id' => 'actor']), + false, + )); + $this->modelTable(); + CollectedRecord::create(['state' => 'new', 'email' => 'secret@example.test']); + $input = new ArrayInput([]); + $this->app['events']->dispatch(new CommandStarting('safe', $input, new NullOutput())); + $this->app['events']->dispatch(new CommandFinished('safe', $input, new NullOutput(), 0)); + $fake->assertNothingCaptured(); + $this->assertSame('off', trim(Blade::render("@posthogFeature('flag')\n on\n@else\n off\n@endposthogFeature"))); + } + + public function testPublishedCollectorConfigAndNestedOverridesAreHonored(): void + { + $path = $this->app->configPath('posthog-autocapture.php'); + $original = is_file($path) ? file_get_contents($path) : null; + @unlink($path); + try { + $this->artisan('vendor:publish', ['--tag' => 'posthog-autocapture-config'])->assertSuccessful(); + $this->assertFileExists($path); + $defaults = require $path; + $this->assertFalse($defaults['requests']['enabled']); + $this->assertSame([], $defaults['domain_events']); + $config = $this->app['config']; + $config->set('posthog.autocapture', []); + $config->set('posthog-autocapture.requests.enabled', true); + (new AutocaptureServiceProvider($this->app))->register(); + $this->assertTrue($config->get('posthog.autocapture.requests.enabled')); + $config->set('posthog.autocapture.requests', ['enabled' => false]); + (new AutocaptureServiceProvider($this->app))->register(); + $this->assertFalse($config->get('posthog.autocapture.requests.enabled')); + } finally { + if ($original === null) { + @unlink($path); + } else { + file_put_contents($path, $original); + } + } + } + + public function testRequestSummaryAndPageviewUseTemplatesAndConfiguredOriginOnly(): void + { + $fake = PostHog::fake(); + $this->enable('requests'); + $this->enable('pageviews'); + $this->app->register(AutocaptureServiceProvider::class); + $this->app['config']->set('app.url', 'https://user:secret@app.example:8443/base?secret=hidden'); + Route::get('/orders/{order}', fn () => response('

secret

')->header('Content-Type', 'text/html')); + foreach (['first-private', 'second-private'] as $id) { + $this->get('/orders/' . $id . '?token=secret', ['X-PostHog-Distinct-Id' => $id])->assertOk(); + } + $fake->assertCapturedCount(4); + $messages = $this->events(); + $this->assertSame( + ['first-private', 'first-private', 'second-private', 'second-private'], + array_column($messages, 'distinctId'), + ); + $this->assertSame(['route', 'method', 'status_code', 'duration_ms'], array_keys($messages[0]['properties'])); + $this->assertSame('/orders/{order}', $messages[0]['properties']['route']); + $this->assertSame('https://app.example:8443/orders/{order}', $messages[1]['properties']['$current_url']); + $this->assertSame('/orders/{order}', $messages[1]['properties']['$pathname']); + $this->assertGreaterThanOrEqual(0, $messages[0]['properties']['duration_ms']); + $this->assertStringNotContainsString('secret', json_encode(array_column($messages, 'properties'))); + $this->assertStringNotContainsString('private', json_encode(array_column($messages, 'properties'))); + $this->assertSame([], $this->app->make(Context::class)->snapshot()); + } + + public function testHealthAssetsUnmatchedAndSamplingDoNotCollectAndPageviewIsSeparate(): void + { + $fake = PostHog::fake(); + $this->enable('requests'); + $this->enable('pageviews'); + foreach (['/up', '/health/check', '/assets/{file}', '/bundle.js'] as $uri) { + Route::get($uri, fn () => response('ok')->header('Content-Type', 'text/html')); + } + foreach (['/up', '/health/check', '/assets/private', '/bundle.js', '/unmatched'] as $uri) { + $this->get($uri, ['X-PostHog-Distinct-Id' => 'actor']); + } + $fake->assertNothingCaptured(); + Route::get('/json', fn () => response()->json(['ok' => true])); + Route::post('/html', fn () => response('ok')->header('Content-Type', 'text/html')); + Route::get('/redirect', fn () => redirect('/json')); + Route::get('/error', fn () => response('error', 503)->header('Content-Type', 'text/html')); + $this->get('/json', ['X-PostHog-Distinct-Id' => 'actor']); + $this->post('/html', [], ['X-PostHog-Distinct-Id' => 'actor']); + $this->get('/redirect', ['X-PostHog-Distinct-Id' => 'actor']); + $this->get('/error', ['X-PostHog-Distinct-Id' => 'actor']); + $fake->assertCapturedCount(4); + $fake->assertNotCaptured('$pageview'); + $this->app['config']->set('posthog.autocapture.requests.sample_rate', 0); + $this->get('/json', ['X-PostHog-Distinct-Id' => 'actor']); + $fake->assertCapturedCount(4); + } + + public function testPageviewCanBeEnabledWithoutRequestSummary(): void + { + $fake = PostHog::fake(); + $this->enable('pageviews'); + Route::get('/page', fn () => response('ok')->header('Content-Type', 'text/html')); + $this->get('/page', ['X-PostHog-Distinct-Id' => 'actor']); + $fake->assertCapturedCount(1); + $fake->assertCaptured('$pageview'); + $fake->assertNotCaptured('laravel_request'); + } + + public function testAuthUsesOnlyIdentifierAndGuardAndFailedLockoutNeedExplicitOptIn(): void + { + $fake = PostHog::fake(); + $this->enable('auth'); + $this->identity('anonymous'); + $user = new GenericUser(['id' => 123, 'email' => 'secret@example.test', 'password' => 'secret']); + $events = $this->app['events']; + $events->dispatch(new \Illuminate\Auth\Events\Registered($user)); + $events->dispatch(new \Illuminate\Auth\Events\Login('web', $user, true)); + $events->dispatch(new \Illuminate\Auth\Events\Logout('web', $user)); + $failed = new \Illuminate\Auth\Events\Failed( + 'web', + $user, + ['email' => 'secret@example.test', 'password' => 'secret'], + ); + $lockout = new \Illuminate\Auth\Events\Lockout(Request::create('/?password=secret')); + $events->dispatch($failed); + $events->dispatch($lockout); + $fake->assertCapturedCount(3); + $this->app['config']->set('posthog.autocapture.auth.events', ['failed', 'lockout']); + $events->dispatch($failed); + $events->dispatch($lockout); + $this->assertSame(['123', '123', '123', 'anonymous', 'anonymous'], array_column($this->events(), 'distinctId')); + $this->assertSame( + [[], ['guard' => 'web'], ['guard' => 'web'], ['guard' => 'web'], []], + array_column($this->events(), 'properties'), + ); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + } + + public function testDomainMapsOnlyExactClassAndPublicJsonSafeProperties(): void + { + $fake = PostHog::fake(); + $this->identity(); + $this->app['events']->dispatch(new OrderCompleted(model: new GenericUser(['email' => 'secret']))); + $this->assertSame(['amount' => 42, 'currency' => 'USD'], $this->events()[0]['properties']); + $this->app['events']->dispatch(new class extends OrderCompleted { + }); + $fake->assertCapturedCount(1); + } + + public function testModelsCaptureAfterCommitWithOriginalIdentityNoPayloadAndRollbackDrops(): void + { + $fake = PostHog::fake(); + $this->modelTable(); + $this->enable('models'); + $this->app['config']->set('posthog.autocapture.models.allow', [CollectedRecord::class => ['state']]); + $this->identity('original'); + DB::beginTransaction(); + $record = CollectedRecord::create(['state' => 'created', 'email' => 'secret@example.test']); + $record->update(['state' => 'updated']); + $record->delete(); + $fake->assertNothingCaptured(); + $this->identity('next'); + DB::commit(); + $fake->assertCapturedCount(3); + $this->assertSame(['original', 'original', 'original'], array_column($this->events(), 'distinctId')); + $this->assertSame( + ['created', 'updated', 'updated'], + array_map(fn ($event) => $event['properties']['attributes']['state'], $this->events()), + ); + $this->assertSame(['team' => 'original'], $this->events()[0]['groups']); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + $this->assertSame('next', $this->app->make(Context::class)->data()['distinctId']); + $exception = new RuntimeException('original failure'); + try { + DB::transaction(function () use ($exception) { + CollectedRecord::create(['state' => 'rolled back', 'email' => 'secret']); + throw $exception; + }); + $this->fail('Transaction must throw'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + $fake->assertCapturedCount(3); + $this->assertSame(0, CollectedRecord::count()); + $this->app['config']->set('posthog.autocapture.models.allow', [CollectedRecord::class => []]); + CollectedRecord::create(['state' => 'safe', 'email' => 'secret']); + $this->assertSame(['model' => CollectedRecord::class, 'attributes' => []], $this->events()[3]['properties']); + } + + public function testNestedRollbackDropsOnlyRolledBackModelEventsAndUnallowlistedModelsDoNotCollect(): void + { + $fake = PostHog::fake(); + $this->modelTable(); + $this->enable('models'); + $this->identity(); + CollectedRecord::create(['state' => 'not allowed', 'email' => 'secret']); + $fake->assertNothingCaptured(); + $this->app['config']->set('posthog.autocapture.models.allow', [CollectedRecord::class => []]); + DB::beginTransaction(); + CollectedRecord::create(['state' => 'outer', 'email' => 'secret']); + DB::beginTransaction(); + CollectedRecord::create(['state' => 'inner', 'email' => 'secret']); + DB::rollBack(); + $fake->assertNothingCaptured(); + DB::commit(); + $fake->assertCapturedCount(1); + } + + public function testQueueOutcomesAreOncePerAttemptAndNeverContainJobPayloadOrException(): void + { + $fake = PostHog::fake(); + $this->enable('jobs'); + $job = $this->createStub(\Illuminate\Contracts\Queue\Job::class); + $job->method('resolveQueuedJobClass')->willReturn('App\\Jobs\\Import'); + $job->method('resolveName')->willReturn('secret display name'); + $job->method('attempts')->willReturn(2); + $events = $this->app['events']; + foreach (['first', 'second'] as $id) { + $events->dispatch(new JobProcessing('sync', $job)); + $this->identity($id); + $event = $id === 'first' ? new JobProcessed('sync', $job) + : new JobExceptionOccurred('sync', $job, new RuntimeException('secret failure')); + $events->dispatch($event); + $events->dispatch($event); + } + $fake->assertCapturedCount(2); + $this->assertSame(['first', 'second'], array_column($this->events(), 'distinctId')); + $this->assertSame( + ['success', 'failed'], + array_map(fn ($event) => $event['properties']['outcome'], $this->events()), + ); + $this->assertSame(['name', 'outcome', 'attempts', 'duration_ms'], array_keys($this->events()[0]['properties'])); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + $this->assertSame([], $this->app->make(Context::class)->data()); + } + + public function testRealSyncJobSuccessAndFailureKeepOriginalExceptionAndOuterWorkerContext(): void + { + $fake = PostHog::fake(); + $this->enable('jobs'); + $this->identity('outer'); + \Illuminate\Support\Facades\Bus::dispatchSync(new Fixtures\CaptureJob('job-user')); + try { + \Illuminate\Support\Facades\Bus::dispatchSync(new Fixtures\FailingCaptureJob('failed-user')); + $this->fail('Job must fail'); + } catch (RuntimeException $exception) { + $this->assertSame('failed job', $exception->getMessage()); + } + $fake->assertCaptured('laravel_job', fn ($event) => $event['distinctId'] === 'job-user' + && $event['properties']['name'] === Fixtures\CaptureJob::class + && $event['properties']['outcome'] === 'success'); + $fake->assertCaptured('laravel_job', fn ($event) => $event['distinctId'] === 'failed-user' + && $event['properties']['name'] === Fixtures\FailingCaptureJob::class + && $event['properties']['outcome'] === 'failed'); + $this->assertSame('outer', $this->app->make(Context::class)->data()['distinctId']); + } + + public function testCommandAndSchedulerMetadataExcludeArgumentsAndDeduplicateFailure(): void + { + $fake = PostHog::fake(); + $this->enable('commands'); + $this->enable('scheduler'); + $input = new ArrayInput(['secret' => 'password']); + $output = new NullOutput(); + $events = $this->app['events']; + $events->dispatch(new CommandStarting('import --secret=password', $input, $output)); + $this->identity(); + $events->dispatch(new CommandFinished('import --secret=password', $input, $output, 7)); + $this->identity(); + $task = new Event($this->createStub(EventMutex::class), 'php artisan import --secret=password'); + $task->exitCode = 1; + $events->dispatch(new ScheduledTaskStarting($task)); + $events->dispatch(new ScheduledTaskFinished($task, 0.1)); + $events->dispatch(new ScheduledTaskFailed($task, new RuntimeException('secret'), 0.1)); + $task->runInBackground = true; + $task->exitCode = null; + $events->dispatch(new ScheduledTaskStarting($task)); + $events->dispatch(new ScheduledTaskFinished($task, 0.1)); + $fake->assertCapturedCount(3); + $this->assertSame('import', $this->events()[0]['properties']['name']); + $this->assertSame('failed', $this->events()[1]['properties']['outcome']); + $this->assertSame('background_started', $this->events()[2]['properties']['outcome']); + $this->assertStringNotContainsString('password', json_encode($this->events())); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + } + + public function testBladeUsesSharedFlagSnapshotsAndCoreExposureDeduplicationAcrossScopes(): void + { + $this->enable('blade'); + $http = $this->createMock(\PostHog\HttpClient::class); + $http->expects($this->exactly(2))->method('sendRequest')->willReturn(new \PostHog\HttpResponse( + json_encode(['flags' => ['checkout' => ['enabled' => true, 'variant' => null]]]), + 200, + )); + $captures = []; + $sdk = new \PostHog\Client('project-key', [ + 'consumer' => 'noop', + 'before_send' => static function ($event) use (&$captures) { + $captures[] = $event; + return $event; + }, + ], $http, loadFeatureFlags: false); + $manager = $this->app->make(\PostHog\Laravel\PostHog::class); + (new \ReflectionProperty(\PostHog\Laravel\PostHog::class, 'sdk'))->setValue($manager, $sdk); + foreach (['first', 'second'] as $id) { + $this->identity($id); + $snapshot = $manager->evaluateFlags(); + \PostHog\Laravel\Autocapture\Collector::capture('between reads', []); + $this->assertSame($snapshot, $manager->evaluateFlags()); + for ($i = 0; $i < 2; $i++) { + $template = "@posthogFeature('checkout')\n on\n@else\n off\n@endposthogFeature"; + $this->assertSame('on', trim(Blade::render($template))); + } + } + $exposures = array_values(array_filter($captures, fn ($event) => $event['event'] === '$feature_flag_called')); + $this->assertCount(2, $exposures); + $this->assertSame(['first', 'second'], array_column($exposures, 'distinct_id')); + } + + public function testOutcomeExclusionsAndNoIdentityDoNotInventAnonymousEvents(): void + { + $fake = PostHog::fake(); + $this->enable('jobs'); + $this->app['config']->set('posthog.autocapture.jobs.exclude', ['App\\Jobs\\*']); + $job = $this->createStub(\Illuminate\Contracts\Queue\Job::class); + $job->method('resolveQueuedJobClass')->willReturn('App\\Jobs\\Import'); + $job->method('attempts')->willReturn(1); + $this->app['events']->dispatch(new JobProcessing('sync', $job)); + $this->identity(); + $this->app['events']->dispatch(new JobProcessed('sync', $job)); + $this->app['events']->dispatch(new OrderCompleted()); + $fake->assertNothingCaptured(); + } + + public function testCollectorFailureNeverMasksOriginalExceptionOrLeaksContextProperties(): void + { + $this->enable('requests'); + $this->app->instance(\PostHog\Laravel\PostHog::class, new class ($this->app) extends \PostHog\Laravel\PostHog { + public function capture( + string|array $event, + array $properties = [], + ?string $distinctId = null, + array $groups = [], + ): bool { + throw new RuntimeException('collector failed'); + } + }); + $this->identity(); + $request = Request::create('/test?secret=password'); + $route = new \Illuminate\Routing\Route('GET', 'test', fn () => null); + $request->setRouteResolver(fn () => $route); + $exception = new RuntimeException('original'); + try { + (new RequestSummary())->handle($request, fn () => throw $exception); + $this->fail('Original exception must be thrown'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + $fake = PostHog::fake(); + $this->app->make(Context::class)->begin(['distinctId' => 'actor', 'properties' => ['password' => 'secret']]); + $this->app['events']->dispatch(new OrderCompleted()); + $this->assertStringNotContainsString('secret', json_encode($this->events())); + $this->assertSame(['password' => 'secret'], $this->app->make(Context::class)->data()['properties']); + $fake->assertCapturedCount(1); + } +} diff --git a/packages/laravel/tests/Fixtures/CollectedRecord.php b/packages/laravel/tests/Fixtures/CollectedRecord.php new file mode 100644 index 00000000..4d40bd46 --- /dev/null +++ b/packages/laravel/tests/Fixtures/CollectedRecord.php @@ -0,0 +1,12 @@ + 'USD'], + public mixed $model = null, + ) { + } +} From 53cdbc56f11457a461dfe23987d2105abd317459 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:00:48 +0100 Subject: [PATCH 06/32] fix: preserve explicit and nested Laravel scopes through lifecycle events --- packages/laravel/README.md | 10 ++-- packages/laravel/src/Context.php | 13 +++-- .../laravel/src/PostHogServiceProvider.php | 54 +++++++++++-------- packages/laravel/tests/RuntimeTest.php | 31 +++++++++++ 4 files changed, 77 insertions(+), 31 deletions(-) diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 2caaed6e..9ccd055c 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -45,9 +45,9 @@ Inject `PostHog\Laravel\PostHog` to use the same API without a facade. `posthog` 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 the 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 +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. @@ -87,8 +87,8 @@ the analytics context after cleanup. Queue jobs start fresh and receive only bou 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 restore the caller's context -when they finish or fail. +Jobs may override it explicitly. Nested synchronous jobs and Artisan commands restore +the caller's context and flag snapshots when they finish or fail. Global request middleware, queue events, Artisan command events, and Octane request/task/tick events flush and clean context automatically. SDK creation is lazy, diff --git a/packages/laravel/src/Context.php b/packages/laravel/src/Context.php index 8867d1d6..ac851334 100644 --- a/packages/laravel/src/Context.php +++ b/packages/laravel/src/Context.php @@ -15,6 +15,7 @@ class Context private array $flags = []; private array $scopes = []; private ?Request $request = null; + private bool $headerIdentity = false; public function __construct(private Container $app) { @@ -25,6 +26,7 @@ public function begin(array $data = []): void $this->context = $data; $this->flags = []; $this->request = null; + $this->headerIdentity = false; } public function end(): void @@ -44,7 +46,8 @@ public function data(): array } try { $resolver = $this->app['config']->get('posthog.distinct_id'); - $id = $resolver ? ($this->app->make($resolver))($this->app) : null; + $explicitId = !$this->headerIdentity && isset($data['distinctId']); + $id = $explicitId ? $data['distinctId'] : ($resolver ? ($this->app->make($resolver))($this->app) : null); if ($id === null && $this->request !== null) { $id = $this->request->user()?->getAuthIdentifier(); } @@ -77,8 +80,10 @@ public function with(array $data, callable $callback): mixed $previous = $this->context; $flags = $this->flags; $request = $this->request; + $headerIdentity = $this->headerIdentity; $this->begin(array_replace_recursive($previous, $data)); $this->request = $request; + $this->headerIdentity = !array_key_exists('distinctId', $data) && $headerIdentity; try { return $callback(); } catch (Throwable $exception) { @@ -88,6 +93,7 @@ public function with(array $data, callable $callback): mixed $this->context = $previous; $this->flags = $flags; $this->request = $request; + $this->headerIdentity = $headerIdentity; } } @@ -96,12 +102,13 @@ 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->context, $this->flags, $this->request]; + $this->scopes[] = [$this->context, $this->flags, $this->request, $this->headerIdentity]; $this->begin(); } @@ -112,7 +119,7 @@ public function leave(): void $this->end(); return; } - [$this->context, $this->flags, $this->request] = array_pop($this->scopes); + [$this->context, $this->flags, $this->request, $this->headerIdentity] = array_pop($this->scopes); } public function flags(string $key, callable $evaluate): FeatureFlagEvaluations diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php index 6143c0b6..3c745b2b 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -72,14 +72,16 @@ public function boot(): void } }); $events->listen(JobExceptionOccurred::class, function ($event) { - $app = Container::getInstance(); - $app->make(ExceptionReporter::class)->remember($event->exception, 'task'); - $this->finish($app, true); + Container::getInstance()->make(ExceptionReporter::class)->remember($event->exception, 'task'); + }); + $events->listen(CommandStarting::class, function () { + $context = Container::getInstance()->make(Context::class); + $data = $context->snapshot(); + $context->enter(); + $context->begin($data); }); - $events->listen(JobProcessed::class, fn () => $this->finish(Container::getInstance(), true)); foreach ( [ - CommandStarting::class, 'Laravel\\Octane\\Events\\RequestReceived', 'Laravel\\Octane\\Events\\TaskReceived', 'Laravel\\Octane\\Events\\TickReceived', @@ -94,28 +96,34 @@ public function boot(): void } }); } - foreach ( - [ - Looping::class, - CommandFinished::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) { - $app = $event->sandbox; - $app->make(ExceptionReporter::class)->remember($event->exception, 'runtime'); - $this->finish($app); + $event->sandbox->make(ExceptionReporter::class)->remember($event->exception, 'runtime'); + }); + // End-event consumers must observe the completed scope before cleanup restores its caller. + $this->app->booted(function () use ($events) { + foreach ([JobProcessed::class, JobExceptionOccurred::class, CommandFinished::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); + }); }); $this->app->terminating(fn () => $this->finish(Container::getInstance())); } - private function finish(Container $app, bool $queue = false): void + private function finish(Container $app, bool $restore = false): void { try { $app->make(PostHog::class)->flush(); @@ -123,7 +131,7 @@ private function finish(Container $app, bool $queue = false): void // Lifecycle cleanup must continue when a transport fails. } finally { $context = $app->make(Context::class); - $queue ? $context->leave() : $context->end(); + $restore ? $context->leave() : $context->end(); } } } diff --git a/packages/laravel/tests/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php index f170d2c8..1532bc84 100644 --- a/packages/laravel/tests/RuntimeTest.php +++ b/packages/laravel/tests/RuntimeTest.php @@ -100,6 +100,37 @@ public function testActualHttpRequestsResolveAuthAfterMiddlewareAndNeverLeak(): $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 testHeaderFallbackAndNestedContextRestoreOnFailure(): void { $context = $this->app->make(Context::class); From 460d2d7095ecd2b2b3144c395f9ad02dff6067f5 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:10:39 +0100 Subject: [PATCH 07/32] fix: retain failed command context until Laravel reports the exception --- packages/laravel/src/ExceptionReporter.php | 24 +++++++++++++++++++ .../laravel/src/PostHogServiceProvider.php | 11 +++++++-- .../tests/Fixtures/ReportingConsoleKernel.php | 13 ++++++++++ packages/laravel/tests/RuntimeTest.php | 21 ++++++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 packages/laravel/tests/Fixtures/ReportingConsoleKernel.php diff --git a/packages/laravel/src/ExceptionReporter.php b/packages/laravel/src/ExceptionReporter.php index 117c104d..82c46090 100644 --- a/packages/laravel/src/ExceptionReporter.php +++ b/packages/laravel/src/ExceptionReporter.php @@ -10,6 +10,7 @@ class ExceptionReporter { private WeakMap $snapshots; private WeakMap $reported; + private ?array $failedCommand = null; public function __construct(private Container $app) { @@ -17,6 +18,20 @@ public function __construct(private Container $app) $this->reported = new WeakMap(); } + /** @internal Symfony terminates commands before Laravel reports their exceptions. */ + public function commandFinished(int $exitCode): void + { + if ($exitCode !== 0) { + $this->failedCommand ??= $this->app->make(Context::class)->snapshot(); + } + } + + /** @internal A previous completed command must not identify a later failure. */ + public function commandStarting(): void + { + $this->failedCommand = null; + } + public function rememberContext(Throwable $exception): void { $this->snapshots[$exception] ??= ['data' => $this->app->make(Context::class)->snapshot()]; @@ -60,6 +75,15 @@ public function report(Throwable $exception): void $this->reported[$exception] = true; $snapshot = $this->snapshots[$exception] ?? ['data' => $this->app->make(Context::class)->snapshot()]; $options = $snapshot['options'] ?? $this->reportOptions(); + if (!isset($this->snapshots[$exception]) && !$options['handled'] && $this->failedCommand !== null) { + foreach ($exception->getTrace() as $frame) { + if (is_a($frame['class'] ?? '', \Illuminate\Console\Command::class, true)) { + $snapshot['data'] = $this->failedCommand; + $this->failedCommand = null; + break; + } + } + } $manager = $this->app->make(PostHog::class); try { $manager->reportException($exception, $snapshot['data'], $options); diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php index 3c745b2b..d8a512e1 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -75,7 +75,9 @@ public function boot(): void Container::getInstance()->make(ExceptionReporter::class)->remember($event->exception, 'task'); }); $events->listen(CommandStarting::class, function () { - $context = Container::getInstance()->make(Context::class); + $app = Container::getInstance(); + $app->make(ExceptionReporter::class)->commandStarting(); + $context = $app->make(Context::class); $data = $context->snapshot(); $context->enter(); $context->begin($data); @@ -101,7 +103,12 @@ public function boot(): void }); // End-event consumers must observe the completed scope before cleanup restores its caller. $this->app->booted(function () use ($events) { - foreach ([JobProcessed::class, JobExceptionOccurred::class, CommandFinished::class] as $event) { + $events->listen(CommandFinished::class, function ($event) { + $app = Container::getInstance(); + $app->make(ExceptionReporter::class)->commandFinished($event->exitCode); + $this->finish($app, true); + }); + foreach ([JobProcessed::class, JobExceptionOccurred::class] as $event) { $events->listen($event, fn () => $this->finish(Container::getInstance(), true)); } foreach ( diff --git a/packages/laravel/tests/Fixtures/ReportingConsoleKernel.php b/packages/laravel/tests/Fixtures/ReportingConsoleKernel.php new file mode 100644 index 00000000..98d43cfc --- /dev/null +++ b/packages/laravel/tests/Fixtures/ReportingConsoleKernel.php @@ -0,0 +1,13 @@ +app->make(Context::class); + $context->begin(['distinctId' => 'caller']); + $kernel = new \PostHog\Laravel\Tests\Fixtures\ReportingConsoleKernel($this->app, $this->app['events']); + $this->app->instance(\Illuminate\Contracts\Console\Kernel::class, $kernel); + \Illuminate\Support\Facades\Artisan::clearResolvedInstance(\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'); + }); + $this->assertSame(1, $kernel->handle(new ArrayInput(['command' => 'posthog:failing']), new NullOutput())); + $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); From caa7a73e3012a4cc2bf59e8bde6def63a5f4d6d5 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:13:14 +0100 Subject: [PATCH 08/32] fix: preserve collector privacy defaults and session correlation --- .../eb4dfed4373d440294ff58970818d3e7.json | 5 +++++ packages/laravel/AUTOCAPTURE.md | 4 ++-- packages/laravel/src/Autocapture/Collector.php | 4 ++-- .../laravel/src/AutocaptureServiceProvider.php | 10 ++++++---- packages/laravel/src/Testing/PostHogFake.php | 3 +++ packages/laravel/tests/AutocaptureTest.php | 15 +++++++++++++++ 6 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 .laravel-changes/eb4dfed4373d440294ff58970818d3e7.json diff --git a/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json b/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json new file mode 100644 index 00000000..0721ba99 --- /dev/null +++ b/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json @@ -0,0 +1,5 @@ +{ + "package": "posthog-laravel", + "bump": "initial", + "summary": "Add opt-in Laravel request, auth, domain, model and worker event collectors." +} diff --git a/packages/laravel/AUTOCAPTURE.md b/packages/laravel/AUTOCAPTURE.md index 720aabd4..962611e1 100644 --- a/packages/laravel/AUTOCAPTURE.md +++ b/packages/laravel/AUTOCAPTURE.md @@ -64,7 +64,7 @@ and pageview events. Zero drops both; the default 1 captures every eligible requ ### Privacy and identity Collectors emit only the properties listed below, plus normal SDK metadata and -explicit identity/groups. Arbitrary context properties are not copied. Collection +explicit identity, session correlation and groups. Arbitrary context properties are not copied. Collection never reads request bodies, query values, cookies, credentials, email, IP addresses, job arguments or full model/event payloads. Auth uses `getAuthIdentifier()`; configure an opaque identifier rather than email if your application customizes that method. @@ -131,7 +131,7 @@ Only existing attributes are read, with their normal casts/accessors; JSON-safe scalar/null/array values are accepted, objects/resources/invalid JSON values are omitted. Do not allowlist a sensitive accessor or JSON column. -Values, identity and groups are snapshotted at the change, then emitted **after the +Values, identity, session and groups are snapshotted at the change, then emitted **after the model connection's outer transaction commits**. Rollback (including a nested savepoint rollback) discards the corresponding pending events. No model object is retained by the commit callback. Outside a transaction, collection is immediate. diff --git a/packages/laravel/src/Autocapture/Collector.php b/packages/laravel/src/Autocapture/Collector.php index cd75eb8e..4f237f28 100644 --- a/packages/laravel/src/Autocapture/Collector.php +++ b/packages/laravel/src/Autocapture/Collector.php @@ -7,7 +7,7 @@ use PostHog\Laravel\PostHog; use Throwable; -/** @internal Collectors retain only identity and groups, never arbitrary context payloads. */ +/** @internal Collectors retain correlation IDs and groups, never arbitrary context payloads. */ class Collector { public static function enabled(string $section): bool @@ -18,7 +18,7 @@ public static function enabled(string $section): bool public static function snapshot(): array { return array_intersect_key(Container::getInstance()->make(Context::class)->snapshot(), [ - 'distinctId' => true, 'groups' => true, + 'distinctId' => true, 'sessionId' => true, 'groups' => true, ]); } diff --git a/packages/laravel/src/AutocaptureServiceProvider.php b/packages/laravel/src/AutocaptureServiceProvider.php index 2c1b4c29..8f2104b0 100644 --- a/packages/laravel/src/AutocaptureServiceProvider.php +++ b/packages/laravel/src/AutocaptureServiceProvider.php @@ -20,10 +20,12 @@ public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/autocapture.php', 'posthog-autocapture'); $config = $this->app['config']; - $config->set('posthog.autocapture', array_replace( - $config->get('posthog-autocapture', []), - $config->get('posthog.autocapture', []), - )); + $settings = $config->get('posthog-autocapture', []); + foreach ($config->get('posthog.autocapture', []) as $section => $values) { + $settings[$section] = $section !== 'domain_events' && is_array($values) + ? array_replace($settings[$section] ?? [], $values) : $values; + } + $config->set('posthog.autocapture', $settings); } public function boot(): void diff --git a/packages/laravel/src/Testing/PostHogFake.php b/packages/laravel/src/Testing/PostHogFake.php index 61fd6aa9..4d997b7d 100644 --- a/packages/laravel/src/Testing/PostHogFake.php +++ b/packages/laravel/src/Testing/PostHogFake.php @@ -32,6 +32,9 @@ protected function message(string $method, array $message): bool $message['distinctId'] ??= $data['distinctId'] ?? null; $message['groups'] ??= $data['groups'] ?? []; $message['properties'] = array_replace($data['properties'] ?? [], $message['properties'] ?? []); + if (isset($data['sessionId']) && !array_key_exists('$session_id', $message['properties'])) { + $message['properties']['$session_id'] = $data['sessionId']; + } $this->recorded[] = ['method' => $method, 'message' => $message]; return true; } diff --git a/packages/laravel/tests/AutocaptureTest.php b/packages/laravel/tests/AutocaptureTest.php index 0ef10fdb..ccb5bc1d 100644 --- a/packages/laravel/tests/AutocaptureTest.php +++ b/packages/laravel/tests/AutocaptureTest.php @@ -118,6 +118,10 @@ public function testPublishedCollectorConfigAndNestedOverridesAreHonored(): void $config->set('posthog.autocapture.requests', ['enabled' => false]); (new AutocaptureServiceProvider($this->app))->register(); $this->assertFalse($config->get('posthog.autocapture.requests.enabled')); + $this->assertSame($defaults['requests']['exclude'], $config->get('posthog.autocapture.requests.exclude')); + $config->set('posthog.autocapture.requests.exclude', []); + (new AutocaptureServiceProvider($this->app))->register(); + $this->assertSame([], $config->get('posthog.autocapture.requests.exclude')); } finally { if ($original === null) { @unlink($path); @@ -154,6 +158,17 @@ public function testRequestSummaryAndPageviewUseTemplatesAndConfiguredOriginOnly $this->assertSame([], $this->app->make(Context::class)->snapshot()); } + public function testAutomaticRequestsKeepSessionCorrelationWithoutArbitraryProperties(): void + { + $fake = PostHog::fake(); + $this->enable('requests'); + Route::get('/session', fn () => 'ok'); + $this->get('/session', [ + 'X-PostHog-Distinct-Id' => 'person', 'X-PostHog-Session-Id' => 'session', + ])->assertOk(); + $fake->assertCaptured('laravel_request', fn ($event) => $event['properties']['$session_id'] === 'session'); + } + public function testHealthAssetsUnmatchedAndSamplingDoNotCollectAndPageviewIsSeparate(): void { $fake = PostHog::fake(); From 47bec038ac775991b2fb286371656746a3b77e51 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:14:51 +0100 Subject: [PATCH 09/32] feat: add an optional read-only Laravel Pennant driver --- .../482cca29796b4cc3bf4b96f0b476e65d.json | 5 + packages/laravel/PENNANT.md | 63 ++++++++++ packages/laravel/README.md | 5 + packages/laravel/composer.json | 4 +- .../laravel/src/Features/PennantCache.php | 25 ++++ .../src/Features/PennantMiddleware.php | 18 +++ .../src/Features/PennantServiceProvider.php | 30 +++++ .../laravel/src/Features/PostHogDriver.php | 78 ++++++++++++ .../laravel/src/PostHogServiceProvider.php | 3 + packages/laravel/tests/PennantTest.php | 115 ++++++++++++++++++ 10 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 .laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json create mode 100644 packages/laravel/PENNANT.md create mode 100644 packages/laravel/src/Features/PennantCache.php create mode 100644 packages/laravel/src/Features/PennantMiddleware.php create mode 100644 packages/laravel/src/Features/PennantServiceProvider.php create mode 100644 packages/laravel/src/Features/PostHogDriver.php create mode 100644 packages/laravel/tests/PennantTest.php diff --git a/.laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json b/.laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json new file mode 100644 index 00000000..830e270f --- /dev/null +++ b/.laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json @@ -0,0 +1,5 @@ +{ + "package": "posthog-laravel", + "bump": "initial", + "summary": "Add an optional read-only Laravel Pennant driver for PostHog feature flags." +} diff --git a/packages/laravel/PENNANT.md b/packages/laravel/PENNANT.md new file mode 100644 index 00000000..57864304 --- /dev/null +++ b/packages/laravel/PENNANT.md @@ -0,0 +1,63 @@ +# Laravel Pennant + +The optional, read-only driver uses the PHP SDK's feature flag snapshots. Install +Pennant in your application, then select the store: + +```sh +composer require laravel/pennant:^1.26 +``` + +```dotenv +PENNANT_STORE=posthog +``` + +Package discovery registers a `posthog` store if the application has not already +configured one. No Pennant migration or database-backed flag store is needed. + +```php +use Laravel\Pennant\Feature; + +Feature::for('account-123')->active('new-checkout'); +$variant = Feature::for('account-123')->value('checkout-layout'); +``` + +Values retain their boolean or variant-string type. Unknown or unresolved flags +return `false`, not an accidentally active `null`. Reads use the SDK's normal +exposure events; repeated cached reads do not generate duplicate exposures. + +String and integer scopes are distinct IDs. Authenticated user/model scopes use +their auth identifier or model key, without a class-name prefix, to match ordinary +SDK capture. Models are not serialized and their properties are not sent. +Pennant normally uses the authenticated user as its default scope. When that +scope is null, the driver uses the runtime's current analytics context. Tracing +headers remain untrusted analytics input, never authorization evidence. + +For custom scope types, use Pennant's scope-identifier contract to resolve an +explicit string/integer identifier. Configure runtime group resolvers for group +flag targeting. Use the native `evaluateFlags()` API for person/group properties, +local-only evaluation, payloads, key subsets and unresolved reasons: + +```php +$payload = \PostHog\Laravel\Facades\PostHog::evaluateFlags()->getFlagPayload('checkout-layout'); +``` + +## Read-only behavior + +Define and manage flags in PostHog. `define`, `activate`, `deactivate`, `delete`, +`purge` and other write operations throw `LogicException`; the driver never pretends +to modify remote flag definitions. Use remote flag key strings, not locally defined +PHP feature classes. Listing defined keys uses the current native snapshot without +recording exposure. + +## Cache lifetime + +The SDK reuses snapshots for identical complete evaluation inputs. Pennant also +has its own feature/scope cache. The integration clears PostHog stores around HTTP, +queue and command boundaries, including failed jobs. Pennant's own Octane lifecycle +integration remains active, and the driver resolves the current application rather +than retaining a request manager in a singleton. + +If targeting properties/groups change for the **same scope within one request**, +call `Feature::flushCache()` before another Pennant read. Alternatively use native +`evaluateFlags()` directly, which keys its snapshot cache by all evaluation inputs. +Do not retain a scoped PostHog manager in an application singleton. diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 9ccd055c..d4ca5efa 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -181,6 +181,11 @@ refresh lazily after the interval. There is no background timer. The PHP client' `loadFlags()` remains available for an explicit refresh. You can instead supply a custom provider class in `sdk.flag_definition_cache_provider`. +## Laravel Pennant + +An optional read-only PostHog store supports Pennant boolean and variant reads. +See [Pennant setup, scope mapping and cache behavior](PENNANT.md). + ## Configuration callbacks and full PHP access `posthog.sdk` passes PHP SDK options through. `before_send`, `error_handler`, and diff --git a/packages/laravel/composer.json b/packages/laravel/composer.json index a5fd4d10..44916163 100644 --- a/packages/laravel/composer.json +++ b/packages/laravel/composer.json @@ -12,8 +12,10 @@ "require-dev": { "orchestra/testbench": "^10.0 || ^11.0", "phpunit/phpunit": "^11.0 || ^12.0", - "laravel/octane": "^2.0" + "laravel/octane": "^2.0", + "laravel/pennant": "^1.0" }, + "suggest": {"laravel/pennant": "Use PostHog flags through Laravel Pennant (^1.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"}}}, diff --git a/packages/laravel/src/Features/PennantCache.php b/packages/laravel/src/Features/PennantCache.php new file mode 100644 index 00000000..400df9e8 --- /dev/null +++ b/packages/laravel/src/Features/PennantCache.php @@ -0,0 +1,25 @@ +resolved(FeatureManager::class)) { + return; + } + $manager = $app->make(FeatureManager::class); + $manager->setContainer($app); + foreach ($app['config']->get('pennant.stores', []) as $name => $store) { + if (($store['driver'] ?? null) === 'posthog') { + $manager->store($name)->flushCache(); + } + } + } +} diff --git a/packages/laravel/src/Features/PennantMiddleware.php b/packages/laravel/src/Features/PennantMiddleware.php new file mode 100644 index 00000000..ff6b7d6f --- /dev/null +++ b/packages/laravel/src/Features/PennantMiddleware.php @@ -0,0 +1,18 @@ +app['config']->get('pennant.stores.posthog') === null) { + $this->app['config']->set('pennant.stores.posthog', ['driver' => 'posthog']); + } + $this->callAfterResolving(FeatureManager::class, function ($manager) { + $manager->extend('posthog', function () { + return new PostHogDriver(); + }); + }); + $this->callAfterResolving(Kernel::class, fn ($kernel) => $kernel->prependMiddleware(PennantMiddleware::class)); + $this->app['events']->listen([ + \Illuminate\Queue\Events\JobProcessing::class, + \Illuminate\Queue\Events\JobProcessed::class, + \Illuminate\Queue\Events\JobExceptionOccurred::class, + \Illuminate\Console\Events\CommandStarting::class, + \Illuminate\Console\Events\CommandFinished::class, + ], fn () => PennantCache::flush()); + } +} diff --git a/packages/laravel/src/Features/PostHogDriver.php b/packages/laravel/src/Features/PostHogDriver.php new file mode 100644 index 00000000..fe7ede19 --- /dev/null +++ b/packages/laravel/src/Features/PostHogDriver.php @@ -0,0 +1,78 @@ +identity($scope); + return Container::getInstance()->make(PostHog::class)->evaluateFlags($id)->getFlag($feature) ?? false; + } + + public function getAll(array $features): array + { + $values = []; + foreach ($features as $feature => $scopes) { + $values[$feature] = []; + foreach ($scopes as $key => $scope) { + $values[$feature][$key] = $this->get($feature, $scope); + } + } + return $values; + } + + public function defined(): array + { + return Container::getInstance()->make(PostHog::class)->evaluateFlags()->getKeys(); + } + + private function identity(mixed $scope): ?string + { + if ($scope === null) { + return null; + } + if ($scope instanceof Authenticatable) { + $scope = $scope->getAuthIdentifier(); + } elseif ($scope instanceof Model) { + $scope = $scope->getKey(); + } + if (is_string($scope) || is_int($scope)) { + return (string) $scope; + } + throw new InvalidArgumentException('PostHog Pennant scopes must have a string or integer identifier.'); + } + + public function define(string $feature, callable $resolver): void + { + throw new LogicException('PostHog flags are read-only. Define them in PostHog.'); + } + + public function set(string $feature, mixed $scope, mixed $value): void + { + throw new LogicException('PostHog flags are read-only. Update them in PostHog.'); + } + + public function setForAllScopes(string $feature, mixed $value): void + { + throw new LogicException('PostHog flags are read-only. Update them in PostHog.'); + } + + public function delete(string $feature, mixed $scope): void + { + throw new LogicException('PostHog flags are read-only. Remove them in PostHog.'); + } + + public function purge(?array $features): void + { + throw new LogicException('PostHog flags are read-only. Remove them in PostHog.'); + } +} diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php index 3c745b2b..4e41eaa7 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -35,6 +35,9 @@ public function register(): void $config['flag_cache']['refresh_interval'], ); }); + if (class_exists(\Laravel\Pennant\FeatureManager::class)) { + $this->app->register(Features\PennantServiceProvider::class); + } if (class_exists(AutocaptureServiceProvider::class)) { $this->app->register(AutocaptureServiceProvider::class); } diff --git a/packages/laravel/tests/PennantTest.php b/packages/laravel/tests/PennantTest.php new file mode 100644 index 00000000..7a5b928c --- /dev/null +++ b/packages/laravel/tests/PennantTest.php @@ -0,0 +1,115 @@ +set('posthog.api_key', 'test-key'); + $app['config']->set('pennant.default', 'posthog'); + } + + public function testValuesUseOneSnapshotPerIdentityAndUnknownFlagsAreInactive(): void + { + $events = []; + $this->sdk(2, $events); + $this->assertSame('first', Feature::for('first')->value('layout')); + $this->assertSame('first', Feature::for('first')->value('layout')); + $this->assertTrue(Feature::for('first')->active('checkout')); + $this->assertFalse(Feature::for('first')->active('unknown')); + $this->assertFalse(Feature::for('first')->active('disabled')); + $this->assertSame('second', Feature::for('second')->value('layout')); + $payload = $this->app->make(PostHog::class)->evaluateFlags('first')->getFlagPayload('layout'); + $this->assertSame(['theme' => 'dark'], $payload); + $layoutExposures = array_filter($events, fn ($event) => $event['event'] === '$feature_flag_called' + && $event['distinct_id'] === 'first' && $event['properties']['$feature_flag'] === 'layout'); + $this->assertCount(1, $layoutExposures); + } + + public function testAnonymousPennantScopeDoesNotCacheAnotherRequestsIdentity(): void + { + $events = []; + $this->sdk(2, $events); + Route::get('/pennant/{id}', function ($id) { + return $this->app->make(Context::class)->with(['distinctId' => $id], fn () => Feature::value('layout')); + }); + $this->get('/pennant/first')->assertSee('first'); + $this->get('/pennant/second')->assertSee('second'); + } + + public function testModelScopeUsesTheSameIdentifierAsNativeCapture(): void + { + $events = []; + $this->sdk(1, $events); + $user = new \Illuminate\Foundation\Auth\User(); + $user->setKeyType('string'); + $user->setRawAttributes(['id' => 'account-123']); + $this->assertSame('account-123', Feature::for($user)->value('layout')); + $this->assertSame('account-123', $events[0]['distinct_id']); + } + + public static function writes(): array + { + return [ + ['define', ['remote', null]], + ['set', ['remote', 'user', true]], + ['setForAllScopes', ['remote', true]], + ['delete', ['remote', 'user']], + ['purge', [null]], + ]; + } + + #[DataProvider('writes')] + public function testRemoteFlagsCannotBeMutated(string $method, array $arguments): void + { + if ($method === 'define') { + $arguments[1] = fn () => true; + } + $this->expectException(LogicException::class); + (new PostHogDriver())->$method(...$arguments); + } + + private function sdk(int $requests, array &$events): void + { + $http = $this->createMock(HttpClient::class); + $http->expects($this->exactly($requests))->method('sendRequest')->willReturnCallback(function ( + $path, + $payload, + ) { + $id = json_decode($payload, true)['distinct_id']; + return new HttpResponse(json_encode(['flags' => [ + 'layout' => ['enabled' => true, 'variant' => $id, 'metadata' => ['payload' => '{"theme":"dark"}']], + 'checkout' => ['enabled' => true, 'variant' => null], + 'disabled' => ['enabled' => false, 'variant' => null], + ]]), 200); + }); + $sdk = new Client('test-key', [ + 'consumer' => 'noop', + 'before_send' => function ($event) use (&$events) { + $events[] = $event; + return $event; + }, + ], $http, loadFeatureFlags: false); + (new \ReflectionProperty(PostHog::class, 'sdk'))->setValue($this->app->make(PostHog::class), $sdk); + } +} From 3b2ad5a8904fe7baa5d3e849dbce41bb3509d00a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:33:41 +0100 Subject: [PATCH 10/32] fix: use explicit HTTP and console execution lifecycle boundaries --- packages/laravel/README.md | 18 ++- packages/laravel/src/ConsoleKernel.php | 67 +++++++++++ packages/laravel/src/ConsoleLifecycle.php | 87 ++++++++++++++ packages/laravel/src/ExceptionReporter.php | 24 ---- .../laravel/src/Middleware/RequestContext.php | 21 ++-- .../laravel/src/PostHogServiceProvider.php | 30 ++--- packages/laravel/src/RequestLifecycle.php | 43 +++++++ packages/laravel/tests/LifecycleTest.php | 110 ++++++++++++++++++ packages/laravel/tests/RuntimeTest.php | 25 ++-- 9 files changed, 364 insertions(+), 61 deletions(-) create mode 100644 packages/laravel/src/ConsoleKernel.php create mode 100644 packages/laravel/src/ConsoleLifecycle.php create mode 100644 packages/laravel/src/RequestLifecycle.php create mode 100644 packages/laravel/tests/LifecycleTest.php diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 9ccd055c..0de79551 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -90,8 +90,18 @@ disable propagation. This metadata is analytics context, never authorization evi Jobs may override it explicitly. Nested synchronous jobs and Artisan commands restore the caller's context and flag snapshots when they finish or fail. -Global request middleware, queue events, Artisan command events, and Octane -request/task/tick events flush and clean context automatically. SDK creation is lazy, +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. 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. 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. @@ -107,7 +117,9 @@ Automatic flush failures do not replace an HTTP response or worker failure. Expl 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`. +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 { diff --git a/packages/laravel/src/ConsoleKernel.php b/packages/laravel/src/ConsoleKernel.php new file mode 100644 index 00000000..7836c58a --- /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 00000000..2e07b629 --- /dev/null +++ b/packages/laravel/src/ConsoleLifecycle.php @@ -0,0 +1,87 @@ +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(); + } + } + + 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'); + } catch (Throwable) { + } + } + + private function leave(): void + { + try { + $this->app->make(PostHog::class)->flush(); + } catch (Throwable) { + } finally { + $this->app->make(Context::class)->leave(); + } + } +} diff --git a/packages/laravel/src/ExceptionReporter.php b/packages/laravel/src/ExceptionReporter.php index 82c46090..117c104d 100644 --- a/packages/laravel/src/ExceptionReporter.php +++ b/packages/laravel/src/ExceptionReporter.php @@ -10,7 +10,6 @@ class ExceptionReporter { private WeakMap $snapshots; private WeakMap $reported; - private ?array $failedCommand = null; public function __construct(private Container $app) { @@ -18,20 +17,6 @@ public function __construct(private Container $app) $this->reported = new WeakMap(); } - /** @internal Symfony terminates commands before Laravel reports their exceptions. */ - public function commandFinished(int $exitCode): void - { - if ($exitCode !== 0) { - $this->failedCommand ??= $this->app->make(Context::class)->snapshot(); - } - } - - /** @internal A previous completed command must not identify a later failure. */ - public function commandStarting(): void - { - $this->failedCommand = null; - } - public function rememberContext(Throwable $exception): void { $this->snapshots[$exception] ??= ['data' => $this->app->make(Context::class)->snapshot()]; @@ -75,15 +60,6 @@ public function report(Throwable $exception): void $this->reported[$exception] = true; $snapshot = $this->snapshots[$exception] ?? ['data' => $this->app->make(Context::class)->snapshot()]; $options = $snapshot['options'] ?? $this->reportOptions(); - if (!isset($this->snapshots[$exception]) && !$options['handled'] && $this->failedCommand !== null) { - foreach ($exception->getTrace() as $frame) { - if (is_a($frame['class'] ?? '', \Illuminate\Console\Command::class, true)) { - $snapshot['data'] = $this->failedCommand; - $this->failedCommand = null; - break; - } - } - } $manager = $this->app->make(PostHog::class); try { $manager->reportException($exception, $snapshot['data'], $options); diff --git a/packages/laravel/src/Middleware/RequestContext.php b/packages/laravel/src/Middleware/RequestContext.php index d0383438..97f762e6 100644 --- a/packages/laravel/src/Middleware/RequestContext.php +++ b/packages/laravel/src/Middleware/RequestContext.php @@ -3,32 +3,31 @@ namespace PostHog\Laravel\Middleware; use Closure; -use PostHog\Laravel\Context; +use Illuminate\Contracts\Http\Kernel; use PostHog\Laravel\ExceptionReporter; -use PostHog\Laravel\PostHog; +use PostHog\Laravel\RequestLifecycle; use Throwable; class RequestContext { - public function __construct(private Context $context, private ExceptionReporter $reporter, private PostHog $posthog) - { + public function __construct( + private RequestLifecycle $lifecycle, + private ExceptionReporter $reporter, + private Kernel $kernel, + ) { } public function handle($request, Closure $next): mixed { - $this->context->fromRequest($request); + $this->lifecycle->begin($request); try { return $next($request); } catch (Throwable $exception) { $this->reporter->remember($exception, 'middleware'); throw $exception; } finally { - try { - $this->posthog->flush(); - } catch (Throwable) { - // A transport failure must not change the HTTP response. - } finally { - $this->context->end(); + if (!method_exists($this->kernel, 'whenRequestLifecycleIsLongerThan')) { + $this->lifecycle->finish(); } } } diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php index d8a512e1..c852345a 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -6,7 +6,6 @@ use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Http\Kernel; use Illuminate\Console\Events\CommandStarting; -use Illuminate\Console\Events\CommandFinished; use Illuminate\Queue\Events\JobProcessing; use Illuminate\Queue\Events\JobProcessed; use Illuminate\Queue\Events\JobExceptionOccurred; @@ -24,6 +23,11 @@ public function register(): void $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'); @@ -52,6 +56,11 @@ public function boot(): void } $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')) { @@ -75,12 +84,8 @@ public function boot(): void Container::getInstance()->make(ExceptionReporter::class)->remember($event->exception, 'task'); }); $events->listen(CommandStarting::class, function () { - $app = Container::getInstance(); - $app->make(ExceptionReporter::class)->commandStarting(); - $context = $app->make(Context::class); - $data = $context->snapshot(); - $context->enter(); - $context->begin($data); + // The first CLI kernel may already be handling input when package providers boot. + Container::getInstance()->make(ConsoleLifecycle::class)->startRoot(); }); foreach ( [ @@ -103,11 +108,6 @@ public function boot(): void }); // End-event consumers must observe the completed scope before cleanup restores its caller. $this->app->booted(function () use ($events) { - $events->listen(CommandFinished::class, function ($event) { - $app = Container::getInstance(); - $app->make(ExceptionReporter::class)->commandFinished($event->exitCode); - $this->finish($app, true); - }); foreach ([JobProcessed::class, JobExceptionOccurred::class] as $event) { $events->listen($event, fn () => $this->finish(Container::getInstance(), true)); } @@ -127,11 +127,15 @@ public function boot(): void $this->finish($event->sandbox); }); }); - $this->app->terminating(fn () => $this->finish(Container::getInstance())); } 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) { diff --git a/packages/laravel/src/RequestLifecycle.php b/packages/laravel/src/RequestLifecycle.php new file mode 100644 index 00000000..39e322c1 --- /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/tests/LifecycleTest.php b/packages/laravel/tests/LifecycleTest.php new file mode 100644 index 00000000..d3c439f0 --- /dev/null +++ b/packages/laravel/tests/LifecycleTest.php @@ -0,0 +1,110 @@ +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'); + } + + 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/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php index f4159252..d518f10f 100644 --- a/packages/laravel/tests/RuntimeTest.php +++ b/packages/laravel/tests/RuntimeTest.php @@ -136,15 +136,19 @@ public function testFailedCommandSnapshotSurvivesTerminationBeforeKernelReportin $fake = Facade::fake(); $context = $this->app->make(Context::class); $context->begin(['distinctId' => 'caller']); - $kernel = new \PostHog\Laravel\Tests\Fixtures\ReportingConsoleKernel($this->app, $this->app['events']); - $this->app->instance(\Illuminate\Contracts\Console\Kernel::class, $kernel); + $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'); }); - $this->assertSame(1, $kernel->handle(new ArrayInput(['command' => 'posthog:failing']), new NullOutput())); + $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']); @@ -291,11 +295,12 @@ public function testConsoleAndOctaneBoundariesFlushAndReset(): void $manager = $this->app->make(PostHog::class); (new \ReflectionProperty(PostHog::class, 'sdk'))->setValue($manager, $client); $context = $this->app->make(Context::class); - $input = new ArrayInput([]); - $output = new NullOutput(); - $this->app['events']->dispatch(new \Illuminate\Console\Events\CommandStarting('test', $input, $output)); - $context->begin(['distinctId' => 'console']); - $this->app['events']->dispatch(new \Illuminate\Console\Events\CommandFinished('test', $input, $output, 1)); + $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) [ @@ -318,8 +323,8 @@ public function testLifecycleCleanupSurvivesFlushFailure(): void $context->begin(['distinctId' => 'stale']); $this->app['events']->dispatch('Laravel\\Octane\\Events\\TaskTerminated', [(object) []]); $this->assertSame([], $context->data()); - $response = $this->app->make(RequestContext::class)->handle(Request::create('/'), fn () => 'ok'); - $this->assertSame('ok', $response); + Route::get('/flush-failure', fn () => 'ok'); + $this->get('/flush-failure')->assertOk(); $this->assertSame([], $context->data()); $client->fail = false; } From 5465c69ccac1ba922457cb247cf0d7aabcc175f0 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:47:51 +0100 Subject: [PATCH 11/32] test: construct synthetic URL credentials without secret-scan false positives --- packages/laravel/tests/AutocaptureTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/laravel/tests/AutocaptureTest.php b/packages/laravel/tests/AutocaptureTest.php index ccb5bc1d..e6f403ec 100644 --- a/packages/laravel/tests/AutocaptureTest.php +++ b/packages/laravel/tests/AutocaptureTest.php @@ -137,7 +137,8 @@ public function testRequestSummaryAndPageviewUseTemplatesAndConfiguredOriginOnly $this->enable('requests'); $this->enable('pageviews'); $this->app->register(AutocaptureServiceProvider::class); - $this->app['config']->set('app.url', 'https://user:secret@app.example:8443/base?secret=hidden'); + $origin = implode('', ['https://', 'user', ':', 'secret', '@', 'app.example:8443/base?secret=hidden']); + $this->app['config']->set('app.url', $origin); Route::get('/orders/{order}', fn () => response('

secret

')->header('Content-Type', 'text/html')); foreach (['first-private', 'second-private'] as $id) { $this->get('/orders/' . $id . '?token=secret', ['X-PostHog-Distinct-Id' => $id])->assertOk(); From 2f07fdec75db32c6e3d17fc0b3f5646c4a1847d0 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:48:52 +0100 Subject: [PATCH 12/32] test: verify Pennant normalizes custom and model scope contracts --- packages/laravel/tests/PennantTest.php | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/laravel/tests/PennantTest.php b/packages/laravel/tests/PennantTest.php index 7a5b928c..280d79a5 100644 --- a/packages/laravel/tests/PennantTest.php +++ b/packages/laravel/tests/PennantTest.php @@ -3,6 +3,7 @@ namespace PostHog\Laravel\Tests; use Illuminate\Support\Facades\Route; +use Laravel\Pennant\Contracts\FeatureScopeable; use Laravel\Pennant\Feature; use Laravel\Pennant\PennantServiceProvider; use LogicException; @@ -68,6 +69,35 @@ public function testModelScopeUsesTheSameIdentifierAsNativeCapture(): void $this->assertSame('account-123', $events[0]['distinct_id']); } + public function testPennantScopeContractIsResolvedBeforeTheDriverReceivesIt(): void + { + $events = []; + $this->sdk(1, $events); + $scope = new class implements FeatureScopeable { + public function toFeatureIdentifier(string $driver): mixed + { + return 'custom-' . $driver; + } + }; + $this->assertSame('custom-posthog', Feature::for($scope)->value('layout')); + $this->assertSame('custom-posthog', $events[0]['distinct_id']); + } + + public function testModelScopeContractTakesPrecedenceOverItsAuthenticationIdentifier(): void + { + $events = []; + $this->sdk(1, $events); + $user = new class extends \Illuminate\Foundation\Auth\User implements FeatureScopeable { + public function toFeatureIdentifier(string $driver): mixed + { + return 'model-' . $driver; + } + }; + $user->setRawAttributes(['id' => 123]); + $this->assertSame('model-posthog', Feature::for($user)->value('layout')); + $this->assertSame('model-posthog', $events[0]['distinct_id']); + } + public static function writes(): array { return [ From 0e3243ee46def0be86f153169bc24d31876936d4 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 11:01:17 +0100 Subject: [PATCH 13/32] fix: capture explicitly failed Laravel jobs as failures --- .../src/AutocaptureServiceProvider.php | 8 +++++++- packages/laravel/tests/AutocaptureTest.php | 12 ++++++++++++ .../tests/Fixtures/ExplicitlyFailedJob.php | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php diff --git a/packages/laravel/src/AutocaptureServiceProvider.php b/packages/laravel/src/AutocaptureServiceProvider.php index 8f2104b0..a2eecdb1 100644 --- a/packages/laravel/src/AutocaptureServiceProvider.php +++ b/packages/laravel/src/AutocaptureServiceProvider.php @@ -75,7 +75,13 @@ private function registerOutcomes(Dispatcher $events): void $events->listen(\Illuminate\Queue\Events\JobProcessing::class, static function ($event) use ($outcomes) { $outcomes->start('jobs', $event->job); }); - foreach (['JobProcessed' => 'success', 'JobExceptionOccurred' => 'failed'] as $event => $outcome) { + foreach ( + [ + 'JobProcessed' => 'success', + 'JobExceptionOccurred' => 'failed', + 'JobFailed' => 'failed', + ] as $event => $outcome + ) { $events->listen('Illuminate\\Queue\\Events\\' . $event, static function ($event) use ($outcomes, $outcome) { $outcomes->finish('jobs', $event->job, static fn () => [ 'name' => $event->job->resolveQueuedJobClass(), diff --git a/packages/laravel/tests/AutocaptureTest.php b/packages/laravel/tests/AutocaptureTest.php index e6f403ec..856f6a27 100644 --- a/packages/laravel/tests/AutocaptureTest.php +++ b/packages/laravel/tests/AutocaptureTest.php @@ -357,6 +357,18 @@ public function testRealSyncJobSuccessAndFailureKeepOriginalExceptionAndOuterWor $this->assertSame('outer', $this->app->make(Context::class)->data()['distinctId']); } + public function testExplicitlyFailedJobIsNotReportedAsSuccessfulWhenItReturns(): void + { + $fake = PostHog::fake(); + $this->enable('jobs'); + $this->identity('actor'); + \Illuminate\Support\Facades\Bus::dispatchSync(new Fixtures\ExplicitlyFailedJob()); + $fake->assertCapturedCount(1); + $fake->assertCaptured('laravel_job', fn ($event) => $event['properties']['outcome'] === 'failed' + && $event['properties']['name'] === Fixtures\ExplicitlyFailedJob::class); + $this->assertSame('actor', $this->app->make(Context::class)->data()['distinctId']); + } + public function testCommandAndSchedulerMetadataExcludeArgumentsAndDeduplicateFailure(): void { $fake = PostHog::fake(); diff --git a/packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php b/packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php new file mode 100644 index 00000000..e902ccdd --- /dev/null +++ b/packages/laravel/tests/Fixtures/ExplicitlyFailedJob.php @@ -0,0 +1,19 @@ +fail(new RuntimeException('explicit job failure')); + } +} From 8773c4377aace83707198b15581925138b740d31 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 11:42:44 +0100 Subject: [PATCH 14/32] fix: address framework integration review feedback --- lib/Client.php | 14 +++--- lib/Consumer/ForkCurl.php | 2 +- lib/QueueConsumer.php | 3 +- lib/RequestContext.php | 16 ++++-- test/ConsumerTransportTestCase.php | 41 ++++++++++++++++ test/FrameworkIntegrationTest.php | 79 ++++++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 12 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index 88ba9a57..3c5b87af 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -337,10 +337,10 @@ public function __construct( ? $options['release_id'] : ($envReleaseId === false ? null : $envReleaseId); $this->releaseId = StringNormalizer::normalizeOptional($releaseId); - $this->options['library_name'] = StringNormalizer::normalizeOptional($options['library_name'] ?? null) - ?? PostHog::LIBRARY; - $this->options['library_version'] = StringNormalizer::normalizeOptional($options['library_version'] ?? null) - ?? PostHog::VERSION; + foreach (['library_name' => PostHog::LIBRARY, 'library_version' => PostHog::VERSION] as $key => $default) { + $value = StringNormalizer::normalizeOptional($options[$key] ?? null); + $this->options[$key] = $value !== null && !preg_match('/[\x00-\x1f\x7f]/', $value) ? $value : $default; + } $this->options['host'] = StringNormalizer::normalizeHost($options['host'] ?? null); if (!$this->enabled) { if (($this->options['consumer'] ?? null) !== 'noop') { @@ -657,9 +657,11 @@ public function captureException( return false; } + $mechanism = $options['mechanism'] ?? null; + $handled = $options['handled'] ?? null; $exceptionList = ExceptionPayloadBuilder::overridePrimaryMechanism($exceptionList, [ - 'type' => $options['mechanism'] ?? 'generic', - 'handled' => $options['handled'] ?? true, + 'type' => is_string($mechanism) && trim($mechanism) !== '' ? $mechanism : 'generic', + 'handled' => is_bool($handled) ? $handled : true, ]); $traceProperties = array_diff_key( diff --git a/lib/Consumer/ForkCurl.php b/lib/Consumer/ForkCurl.php index 02ab83fe..d3d6afee 100644 --- a/lib/Consumer/ForkCurl.php +++ b/lib/Consumer/ForkCurl.php @@ -94,7 +94,7 @@ public function flushBatch($messages) $cmd .= " '" . $url . "'"; // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. - $cmd .= " -H 'User-Agent: " . $this->userAgent() . "'"; + $cmd .= " -H " . escapeshellarg("User-Agent: " . $this->userAgent()); if ($this->debug()) { try { diff --git a/lib/QueueConsumer.php b/lib/QueueConsumer.php index d7e0eba7..d7297547 100644 --- a/lib/QueueConsumer.php +++ b/lib/QueueConsumer.php @@ -299,6 +299,7 @@ protected function payload($batch) */ protected function userAgent(): string { - return PostHog::LIBRARY . "/" . PostHog::VERSION; + return ($this->options['library_name'] ?? PostHog::LIBRARY) + . '/' . ($this->options['library_version'] ?? PostHog::VERSION); } } diff --git a/lib/RequestContext.php b/lib/RequestContext.php index 62ae3aa9..399c9a07 100644 --- a/lib/RequestContext.php +++ b/lib/RequestContext.php @@ -23,6 +23,8 @@ final class RequestContext private static array $stacks = []; private static int $nextScopeId = 1; + private static ?\WeakMap $fiberKeys = null; + private static int $nextFiberKey = 1; /** * Run a callback with context that is restored automatically afterwards. @@ -52,11 +54,11 @@ public static function withContext( public static function enterContext(array $data, array $options = [], ?int $contextKey = null): ContextScope { $contextKey = self::contextKey($contextKey); - $fiber = \Fiber::getCurrent(); + $fiberKey = self::fiberKey(); $scopeId = self::push($data, (bool) ($options['fresh'] ?? false), $contextKey); - return new ContextScope(static function () use ($scopeId, $contextKey, $fiber): void { - self::pop($scopeId, $contextKey, $fiber === null ? 0 : spl_object_id($fiber)); + return new ContextScope(static function () use ($scopeId, $contextKey, $fiberKey): void { + self::pop($scopeId, $contextKey, $fiberKey); }); } @@ -209,7 +211,13 @@ private static function contextKey(?int $contextKey): int private static function fiberKey(): int { $fiber = \Fiber::getCurrent(); - return $fiber === null ? 0 : spl_object_id($fiber); + if ($fiber === null) { + return 0; + } + + // Fiber object IDs can be reused while a detached scope is still open. + self::$fiberKeys ??= new \WeakMap(); + return self::$fiberKeys[$fiber] ??= self::$nextFiberKey++; } private static function unsetStackIfEmpty(int $contextKey, int $fiberKey): void diff --git a/test/ConsumerTransportTestCase.php b/test/ConsumerTransportTestCase.php index c124cbba..2dc8c9e8 100644 --- a/test/ConsumerTransportTestCase.php +++ b/test/ConsumerTransportTestCase.php @@ -81,6 +81,47 @@ public function testAlias(bool $compressed): void self::assertSame('user-id', $event['properties']['distinct_id']); } + public static function libraryMetadataCases(): array + { + return [ + 'configured metadata' => [ + ['library_name' => 'posthog-laravel', 'library_version' => '0.1.0'], 'posthog-laravel/0.1.0', + ], + 'normalized metadata' => [ + ['library_name' => " posthog-laravel\t", 'library_version' => ' 0.1.0 '], 'posthog-laravel/0.1.0', + ], + 'empty metadata' => [ + ['library_name' => " \t", 'library_version' => null], PostHog::LIBRARY . '/' . PostHog::VERSION, + ], + 'library name only' => [['library_name' => 'posthog-laravel'], 'posthog-laravel/' . PostHog::VERSION], + 'library version only' => [['library_version' => '0.1.0'], PostHog::LIBRARY . '/0.1.0'], + 'quoted metadata' => [ + ['library_name' => "posthog'laravel", 'library_version' => '0.1.0'], "posthog'laravel/0.1.0", + ], + 'line break in name' => [ + ['library_name' => "posthog\r\nX-Injected: yes", 'library_version' => '0.1.0'], + PostHog::LIBRARY . '/0.1.0', + ], + 'null byte in version' => [ + ['library_name' => 'posthog-laravel', 'library_version' => "0.1\0.0"], + 'posthog-laravel/' . PostHog::VERSION, + ], + ]; + } + + #[DataProvider('libraryMetadataCases')] + public function testBatchUserAgentMatchesConfiguredLibrary(array $options, string $expectedUserAgent): void + { + $client = $this->client($options); + self::assertTrue($client->capture(['distinctId' => 'user', 'event' => 'configured-library'])); + self::assertTrue($client->flush()); + $requests = $this->server->requests(); + self::assertCount(1, $requests); + self::assertSame($expectedUserAgent, $requests[0]['headers']['user-agent']); + $event = json_decode($requests[0]['body'], true, 512, JSON_THROW_ON_ERROR)['batch'][0]; + self::assertSame($expectedUserAgent, $event['properties']['$lib'] . '/' . $event['properties']['$lib_version']); + } + protected function flushEvent(Client $client, bool $compressed = false): array { self::assertSame([], $this->server->requests()); diff --git a/test/FrameworkIntegrationTest.php b/test/FrameworkIntegrationTest.php index 2dfd323e..8d48b48e 100644 --- a/test/FrameworkIntegrationTest.php +++ b/test/FrameworkIntegrationTest.php @@ -3,6 +3,7 @@ namespace PostHog\Test; use Fiber; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use PostHog\Client; use RuntimeException; @@ -98,6 +99,84 @@ public function testScopeDestructionClosesItsContext(): void self::assertNull($client->getContext()); } + public function testAbandonedFiberRunsContextCleanupWithoutCyclicCollection(): void + { + $client = $this->client(); + $finished = false; + $gcEnabled = gc_enabled(); + gc_disable(); + try { + $fiber = new Fiber(function () use ($client, &$finished): void { + try { + $client->withContext(['distinctId' => 'abandoned'], static function (): void { + Fiber::suspend(); + }); + } finally { + $finished = true; + } + }); + $fiber->start(); + $reference = \WeakReference::create($fiber); + unset($fiber); + + self::assertTrue($finished, 'Dropping a suspended Fiber must run its finally blocks immediately'); + self::assertNull($reference->get()); + } finally { + gc_collect_cycles(); + if ($gcEnabled) { + gc_enable(); + } + } + } + + public function testDetachedScopeDoesNotLeakIntoAReplacementFiber(): void + { + $client = $this->client(); + $read = static function () use ($client): void { + self::assertNull($client->getContext()); + }; + $enter = static fn () => Fiber::suspend($client->enterContext(['distinctId' => 'abandoned'])); + $fiber = new Fiber($enter); + $scope = $fiber->start(); + try { + unset($fiber); + (new Fiber($read))->start(); + } finally { + $scope->close(); + } + } + + public static function exceptionMetadataCases(): array + { + return [ + 'empty mechanism' => [['mechanism' => '', 'handled' => false], 'generic', false], + 'whitespace mechanism' => [['mechanism' => " \t", 'handled' => false], 'generic', false], + 'numeric mechanism' => [['mechanism' => 123, 'handled' => false], 'generic', false], + 'array mechanism' => [['mechanism' => [], 'handled' => false], 'generic', false], + 'null mechanism' => [['mechanism' => null, 'handled' => false], 'generic', false], + 'string handled' => [['mechanism' => 'task', 'handled' => 'false'], 'task', true], + 'numeric handled' => [['mechanism' => 'task', 'handled' => 0], 'task', true], + 'array handled' => [['mechanism' => 'task', 'handled' => []], 'task', true], + 'null handled' => [['mechanism' => 'task', 'handled' => null], 'task', true], + 'valid overrides' => [['mechanism' => 'auto.framework', 'handled' => false], 'auto.framework', false], + 'nonempty zero mechanism' => [['mechanism' => '0', 'handled' => false], '0', false], + ]; + } + + #[DataProvider('exceptionMetadataCases')] + public function testExceptionMetadataOverridesAreValidatedIndependently( + array $options, + string $expectedMechanism, + bool $expectedHandled + ): void { + $client = $this->client(); + self::assertTrue($client->captureException(new RuntimeException('error'), 'user', [], $options)); + $properties = $this->events($client)[0]['properties']; + self::assertSame($expectedMechanism, $properties['$exception_list'][0]['mechanism']['type']); + self::assertSame($expectedHandled, $properties['$exception_list'][0]['mechanism']['handled']); + self::assertSame($expectedHandled, $properties['$exception_handled']); + } + public function testFrameworkExceptionOptionsPreserveChainAndStack(): void { $client = $this->client(); From 5e93cc57bac7d070f44fecbba235adf284c85b02 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:29:05 +0100 Subject: [PATCH 15/32] fix(laravel): discover Pennant flags for the requested scope --- packages/laravel/PENNANT.md | 13 +- .../laravel/src/Features/PostHogDriver.php | 11 +- packages/laravel/tests/PennantTest.php | 132 +++++++++++++++++- 3 files changed, 149 insertions(+), 7 deletions(-) diff --git a/packages/laravel/PENNANT.md b/packages/laravel/PENNANT.md index 57864304..29905103 100644 --- a/packages/laravel/PENNANT.md +++ b/packages/laravel/PENNANT.md @@ -46,8 +46,17 @@ $payload = \PostHog\Laravel\Facades\PostHog::evaluateFlags()->getFlagPayload('ch Define and manage flags in PostHog. `define`, `activate`, `deactivate`, `delete`, `purge` and other write operations throw `LogicException`; the driver never pretends to modify remote flag definitions. Use remote flag key strings, not locally defined -PHP feature classes. Listing defined keys uses the current native snapshot without -recording exposure. +PHP feature classes. + +`Feature::defined()` intentionally returns an empty array: this read-only external +store has no local definitions. Pennant calls it during feature-name resolution, +before resolving the requested scope, so it never evaluates flags or performs I/O. +Use `Feature::for($scope)->all()` or `Feature::for($scope)->loadAll()` to discover +PostHog flags for a scope through Pennant's external-definition contract. Discovery +uses that scope's native snapshot without recording exposure; reading the discovered +values uses normal deduplicated exposures. Bulk and subsequent individual reads +reuse the same snapshot, preserving variants and disabled values. Unknown or +unresolved flags remain inactive. ## Cache lifetime diff --git a/packages/laravel/src/Features/PostHogDriver.php b/packages/laravel/src/Features/PostHogDriver.php index fe7ede19..3d133449 100644 --- a/packages/laravel/src/Features/PostHogDriver.php +++ b/packages/laravel/src/Features/PostHogDriver.php @@ -6,11 +6,12 @@ use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Database\Eloquent\Model; use InvalidArgumentException; +use Laravel\Pennant\Contracts\DefinesFeaturesExternally; use Laravel\Pennant\Contracts\Driver; use LogicException; use PostHog\Laravel\PostHog; -final class PostHogDriver implements Driver +final class PostHogDriver implements Driver, DefinesFeaturesExternally { public function get(string $feature, mixed $scope): mixed { @@ -32,7 +33,13 @@ public function getAll(array $features): array public function defined(): array { - return Container::getInstance()->make(PostHog::class)->evaluateFlags()->getKeys(); + // Pennant calls this before resolving the scope; there are no local definitions. + return []; + } + + public function definedFeaturesForScope(mixed $scope): array + { + return Container::getInstance()->make(PostHog::class)->evaluateFlags($this->identity($scope))->getKeys(); } private function identity(mixed $scope): ?string diff --git a/packages/laravel/tests/PennantTest.php b/packages/laravel/tests/PennantTest.php index 280d79a5..31dde211 100644 --- a/packages/laravel/tests/PennantTest.php +++ b/packages/laravel/tests/PennantTest.php @@ -47,6 +47,121 @@ public function testValuesUseOneSnapshotPerIdentityAndUnknownFlagsAreInactive(): $this->assertCount(1, $layoutExposures); } + public function testExplicitScopeNameResolutionDoesNotEvaluateTheAmbientIdentity(): void + { + $events = []; + $this->sdk(1, $events, 'explicit-b'); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient-a']); + + $this->assertSame('explicit-b', Feature::for('explicit-b')->value('layout')); + $this->assertTrue(Feature::for('explicit-b')->active('checkout')); + $this->assertFalse(Feature::for('explicit-b')->active('disabled')); + $this->assertFalse(Feature::for('explicit-b')->active('unknown')); + $this->assertSame([], Feature::defined()); + } + + public function testDefinedListsNoLocalDefinitionsAndDoesNotPerformIo(): void + { + $events = []; + $this->sdk(0, $events); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient-a']); + + $this->assertSame([], Feature::defined()); + $this->assertSame([], Feature::defined()); + $this->assertSame([], $events); + } + + public function testAllDiscoversExternalFlagsForTheExplicitScope(): void + { + $events = []; + $this->sdk(1, $events, 'explicit-b', [ + 'b-only' => ['enabled' => true, 'variant' => null], + 'unresolved' => ['failed' => true], + ]); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient-a']); + + $this->assertSame([ + 'layout' => 'explicit-b', + 'checkout' => true, + 'disabled' => false, + 'b-only' => true, + ], Feature::for('explicit-b')->all()); + $this->assertCount(4, $events); + $this->assertSame('explicit-b', Feature::for('explicit-b')->value('layout')); + $this->assertCount(4, $events); + $this->assertFalse(Feature::for('explicit-b')->active('unresolved')); + $this->assertFalse(Feature::for('explicit-b')->active('unknown')); + } + + public function testLoadAllNormalizesTheScopeAndReusesTheNativeSnapshot(): void + { + $events = []; + $this->sdk(1, $events, 'custom-posthog'); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient-a']); + $scope = new class implements FeatureScopeable { + public function toFeatureIdentifier(string $driver): mixed + { + return 'custom-' . $driver; + } + }; + + $this->assertSame([ + 'layout' => ['custom-posthog'], + 'checkout' => [true], + 'disabled' => [false], + ], Feature::for($scope)->loadAll()); + $this->assertSame('custom-posthog', Feature::for($scope)->value('layout')); + $this->assertTrue(Feature::for($scope)->active('checkout')); + $this->assertFalse(Feature::for($scope)->active('disabled')); + $this->assertCount(3, $events); + + Feature::flushCache(); + $this->assertSame('custom-posthog', Feature::for($scope)->value('layout')); + $this->assertSame('custom-posthog', $this->app->make(PostHog::class) + ->evaluateFlags('custom-posthog')->getFlag('layout')); + $this->assertCount(3, $events); + } + + public function testScopedKeyEnumerationDoesNotRecordExposure(): void + { + $events = []; + $this->sdk(1, $events, 'explicit-b'); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient-a']); + + $this->assertSame( + ['layout', 'checkout', 'disabled'], + Feature::definedFeaturesForScope('explicit-b')->all(), + ); + $this->assertSame([], $events); + $this->assertSame([], Feature::defined()); + $this->assertSame('explicit-b', Feature::for('explicit-b')->value('layout')); + $this->assertCount(1, $events); + } + + public function testBulkReadsWithoutAnIdentityAreEmptyAndDoNotPerformIo(): void + { + $events = []; + $this->sdk(0, $events); + + $this->assertSame([], Feature::all()); + $this->assertSame([], Feature::loadAll()); + $this->assertFalse(Feature::value('unknown')); + $this->assertSame([], $events); + } + + public function testDisabledBulkReadsAreEmptyAndDoNotPerformIo(): void + { + $events = []; + $this->sdk(0, $events); + $this->app['config']->set('posthog.enabled', false); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient-a']); + + $this->assertSame([], Feature::for('explicit-b')->all()); + $this->assertSame([], Feature::for('explicit-b')->loadAll()); + $this->assertSame([], $events); + $this->assertFalse(Feature::for('explicit-b')->value('unknown')); + } + public function testAnonymousPennantScopeDoesNotCacheAnotherRequestsIdentity(): void { $events = []; @@ -119,19 +234,30 @@ public function testRemoteFlagsCannotBeMutated(string $method, array $arguments) (new PostHogDriver())->$method(...$arguments); } - private function sdk(int $requests, array &$events): void - { + private function sdk( + int $requests, + array &$events, + ?string $expectedIdentity = null, + array $extraFlags = [], + ): void { $http = $this->createMock(HttpClient::class); $http->expects($this->exactly($requests))->method('sendRequest')->willReturnCallback(function ( $path, $payload, + ) use ( + $expectedIdentity, + $extraFlags, ) { + $this->assertSame('/flags/?v=2', $path); $id = json_decode($payload, true)['distinct_id']; + if ($expectedIdentity !== null) { + $this->assertSame($expectedIdentity, $id); + } return new HttpResponse(json_encode(['flags' => [ 'layout' => ['enabled' => true, 'variant' => $id, 'metadata' => ['payload' => '{"theme":"dark"}']], 'checkout' => ['enabled' => true, 'variant' => null], 'disabled' => ['enabled' => false, 'variant' => null], - ]]), 200); + ] + $extraFlags]), 200); }); $sdk = new Client('test-key', [ 'consumer' => 'noop', From ecbc8cc73ff96ce71f7d485844b3c11e9143f177 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:29:35 +0100 Subject: [PATCH 16/32] fix(flags): retain snapshot context for lazy exposure events --- lib/Client.php | 11 +++- .../ContextualFeatureFlagEvaluationsHost.php | 47 +++++++++++++++ test/FeatureFlagEvaluationsTest.php | 59 +++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 lib/Internal/ContextualFeatureFlagEvaluationsHost.php diff --git a/lib/Client.php b/lib/Client.php index 3c5b87af..93f6f2ca 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -1225,13 +1225,18 @@ public function evaluateFlags( ?array $flagKeys = null ): FeatureFlagEvaluations { $distinctId = $this->resolveDistinctId($distinctId); + $host = new Internal\ContextualFeatureFlagEvaluationsHost( + $this, + $this->getContext() ?? [], + $this->contextKey(), + ); if ($distinctId === '') { $this->warnMissingDistinctId('evaluateFlags()'); return new FeatureFlagEvaluations( $distinctId, [], $groups, - $this, + $host, disableGeoip: $disableGeoip, ); } @@ -1241,7 +1246,7 @@ public function evaluateFlags( $distinctId, [], $groups, - $this, + $host, disableGeoip: $disableGeoip, ); } @@ -1407,7 +1412,7 @@ public function evaluateFlags( $distinctId, $records, $groups, - $this, + $host, $requestId, $evaluatedAt, null, diff --git a/lib/Internal/ContextualFeatureFlagEvaluationsHost.php b/lib/Internal/ContextualFeatureFlagEvaluationsHost.php new file mode 100644 index 00000000..0c5d1d8d --- /dev/null +++ b/lib/Internal/ContextualFeatureFlagEvaluationsHost.php @@ -0,0 +1,47 @@ +context, + fn () => $this->client->captureFlagCalledIfNeeded( + $distinctId, + $key, + $response, + $properties, + $groups, + $disableGeoip, + ), + ['fresh' => true], + $this->contextKey, + ); + } + + public function logWarning(string $message): void + { + $this->client->logWarning($message); + } +} diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index 4d89543a..b61b66fa 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -113,6 +113,65 @@ public function testEvaluateFlagsReturnsSnapshotAndMakesOneFlagsRequest(): void $this->assertSame(1, $this->flagsRequestCount()); } + public static function delayedExposureReads(): array + { + return [ + 'boolean read' => ['isEnabled', false], + 'value read' => ['getFlag', false], + 'filtered value read' => ['getFlag', true], + ]; + } + + #[DataProvider('delayedExposureReads')] + public function testLazyExposureRetainsOriginatingContext(string $accessor, bool $filtered): void + { + $this->makeClient(); + $snapshot = $this->client->withContext([ + 'distinctId' => 'origin', + 'sessionId' => 'origin-session', + 'properties' => ['$request_path' => '/orders/{order}', 'boundary' => 'origin'], + ], fn () => $this->client->evaluateFlags()); + if ($filtered) { + $snapshot = $snapshot->only(['simple-test']); + } + $this->client->withContext([ + 'distinctId' => 'later', + 'sessionId' => 'later-session', + 'properties' => ['$request_path' => '/later', 'boundary' => 'later'], + ], function () use ($snapshot, $accessor): void { + self::assertTrue($snapshot->$accessor('simple-test')); + self::assertTrue($snapshot->getFlag('simple-test')); + $this->client->capture(['event' => 'later-event']); + }); + $this->client->flush(); + $events = $this->batchRequests()[0]['batch']; + self::assertCount(2, $events); + self::assertSame('$feature_flag_called', $events[0]['event']); + self::assertSame('origin', $events[0]['distinct_id']); + self::assertSame('origin-session', $events[0]['properties']['$session_id']); + self::assertSame('/orders/{order}', $events[0]['properties']['$request_path']); + self::assertSame('origin', $events[0]['properties']['boundary']); + self::assertSame('later', $events[1]['distinct_id']); + self::assertSame('later-session', $events[1]['properties']['$session_id']); + self::assertSame('later', $events[1]['properties']['boundary']); + self::assertSame(1, $this->flagsRequestCount()); + self::assertNull($this->client->getContext()); + } + + public function testUnscopedSnapshotDoesNotInheritLaterContext(): void + { + $this->makeClient(); + $snapshot = $this->client->evaluateFlags('explicit-user'); + $this->client->withContext([ + 'distinctId' => 'later', 'sessionId' => 'later-session', 'properties' => ['boundary' => 'later'], + ], fn () => $snapshot->getFlag('simple-test')); + $this->client->flush(); + $event = $this->batchRequests()[0]['batch'][0]; + self::assertSame('explicit-user', $event['distinct_id']); + self::assertArrayNotHasKey('$session_id', $event['properties']); + self::assertArrayNotHasKey('boundary', $event['properties']); + } + public function testNoFeatureFlagCalledEventsUntilAccess(): void { $this->makeClient(); From d9d98972565aec431dd1105a76c399c4a3cee3b1 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:34:33 +0100 Subject: [PATCH 17/32] fix(laravel): bridge runtime scopes and isolate diagnostics Preserve identity fallback and normalize invokable transport error handlers. Bind flag construction to immutable Laravel data; delayed exposure regression awaits the approved core host dependency. --- packages/laravel/README.md | 29 +- packages/laravel/src/Commands/TestCommand.php | 6 +- packages/laravel/src/Context.php | 66 ++++- packages/laravel/src/PostHog.php | 67 ++++- .../tests/Fixtures/TransportErrorHandler.php | 10 + packages/laravel/tests/RuntimeContextTest.php | 271 ++++++++++++++++++ 6 files changed, 433 insertions(+), 16 deletions(-) create mode 100644 packages/laravel/tests/Fixtures/TransportErrorHandler.php create mode 100644 packages/laravel/tests/RuntimeContextTest.php diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 0de79551..cff4a8cf 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -82,13 +82,22 @@ app(Context::class)->with([ `Context::begin()`, `data()`, `snapshot()` and `end()` support explicit integrations. `with()` restores the previous data and flag snapshots even when the callback throws. +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 Artisan commands restore -the caller's context and flag snapshots when they finish or fail. +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. @@ -101,7 +110,13 @@ automatically. Console integration decorates the existing console kernel contrac nested `Artisan::call()` executions restore their caller in `finally`, while the root command stays active through kernel termination. 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. SDK creation is lazy, +application's commands, custom kernel, and exception-reporting policy are preserved. +Direct child-command `$this->call()`/`callSilent()` execution bypasses those kernel +boundaries and does **not** automatically restore context. Wrap such calls in an +explicit `PostHog::withContext([], fn () => $this->call(...))` scope when the child +can mutate analytics context; automatic direct child-command isolation remains +unsupported. +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. @@ -152,7 +167,9 @@ if ($flags->isEnabled('new-checkout')) { 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; payload reads are silent. An explicit +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`, @@ -208,6 +225,10 @@ metadata; advanced SDK options remain accessible under `sdk`. - `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. diff --git a/packages/laravel/src/Commands/TestCommand.php b/packages/laravel/src/Commands/TestCommand.php index 63a0a912..9131b340 100644 --- a/packages/laravel/src/Commands/TestCommand.php +++ b/packages/laravel/src/Commands/TestCommand.php @@ -17,7 +17,11 @@ public function handle(PostHog $posthog): int $this->warn('No event sent. Configure PostHog and pass --send to opt in.'); return self::FAILURE; } - $captured = $posthog->capture('posthog test event', [], 'posthog-test-' . Str::uuid()); + $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.'); diff --git a/packages/laravel/src/Context.php b/packages/laravel/src/Context.php index ac851334..5d8167f0 100644 --- a/packages/laravel/src/Context.php +++ b/packages/laravel/src/Context.php @@ -5,6 +5,7 @@ use Illuminate\Contracts\Container\Container; use Illuminate\Http\Request; use Illuminate\Routing\Route; +use PostHog\ContextScope; use PostHog\FeatureFlagEvaluations; use PostHog\PostHog as CorePostHog; use Throwable; @@ -16,6 +17,9 @@ class Context private array $scopes = []; private ?Request $request = null; private bool $headerIdentity = false; + private bool $resolved = false; + private array $contextScopes = []; + private int $nextScopeId = 0; public function __construct(private Container $app) { @@ -27,6 +31,7 @@ public function begin(array $data = []): void $this->flags = []; $this->request = null; $this->headerIdentity = false; + $this->resolved = false; } public function end(): void @@ -37,6 +42,9 @@ public function end(): void 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(); @@ -44,15 +52,20 @@ public function data(): array $data['properties']['$request_path'] ??= '/' . ltrim($route->uri(), '/'); } } - try { - $resolver = $this->app['config']->get('posthog.distinct_id'); - $explicitId = !$this->headerIdentity && isset($data['distinctId']); - $id = $explicitId ? $data['distinctId'] : ($resolver ? ($this->app->make($resolver))($this->app) : null); - if ($id === null && $this->request !== null) { + $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) { + } + } + if ($id === null && $this->request !== null) { + try { $id = $this->request->user()?->getAuthIdentifier(); + } catch (Throwable) { } - } catch (Throwable) { - return $data; } if (is_scalar($id) && (string) $id !== '') { $data['distinctId'] = (string) $id; @@ -81,7 +94,9 @@ public function with(array $data, callable $callback): mixed $flags = $this->flags; $request = $this->request; $headerIdentity = $this->headerIdentity; + $resolved = $this->resolved; $this->begin(array_replace_recursive($previous, $data)); + $this->resolved = $resolved; $this->request = $request; $this->headerIdentity = !array_key_exists('distinctId', $data) && $headerIdentity; try { @@ -94,9 +109,42 @@ public function with(array $data, callable $callback): mixed $this->flags = $flags; $this->request = $request; $this->headerIdentity = $headerIdentity; + $this->resolved = $resolved; } } + /** @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]; + } + + private function restore(array $state): void + { + [$this->context, $this->flags, $this->request, $this->headerIdentity, $this->resolved] = $state; + } + public function fromRequest(Request $request): void { $this->begin($this->app['config']->get('posthog.header_context', true) @@ -108,7 +156,7 @@ public function fromRequest(Request $request): void /** @internal Isolate nested queue executions without discarding the caller's context. */ public function enter(): void { - $this->scopes[] = [$this->context, $this->flags, $this->request, $this->headerIdentity]; + $this->scopes[] = $this->state(); $this->begin(); } @@ -119,7 +167,7 @@ public function leave(): void $this->end(); return; } - [$this->context, $this->flags, $this->request, $this->headerIdentity] = array_pop($this->scopes); + $this->restore(array_pop($this->scopes)); } public function flags(string $key, callable $evaluate): FeatureFlagEvaluations diff --git a/packages/laravel/src/PostHog.php b/packages/laravel/src/PostHog.php index 9e1b538f..fc72d73c 100644 --- a/packages/laravel/src/PostHog.php +++ b/packages/laravel/src/PostHog.php @@ -2,8 +2,10 @@ namespace PostHog\Laravel; +use Closure; use Illuminate\Contracts\Container\Container; use PostHog\Client; +use PostHog\ContextScope; use PostHog\FeatureFlagEvaluations; use Throwable; @@ -50,6 +52,9 @@ public function client(): Client $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); } @@ -152,11 +157,15 @@ public function evaluateFlags( disableGeoip: $disableGeoip, ); } - return $context->flags(hash('sha256', serialize($inputs)), function () use ($inputs, $flagKeys) { + return $context->flags(hash('sha256', serialize($inputs)), function () use ($inputs, $flagKeys, $data) { if ($flagKeys !== []) { $this->loadDefinitionsIfNeeded(); } - return $this->client()->evaluateFlags(...$inputs); + return $this->client()->withContext( + $data, + fn () => $this->client()->evaluateFlags(...$inputs), + ['fresh' => true], + ); }); } @@ -198,6 +207,60 @@ public function shutdown(): bool return $this->sdk !== null ? $this->sdk->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->client()->withContext( + $this->app->make(Context::class)->data(), + fn () => $this->client()->getContext(), + ['fresh' => true], + ); + } + public function __call(string $method, array $arguments): mixed { $data = $this->app->make(Context::class)->data(); diff --git a/packages/laravel/tests/Fixtures/TransportErrorHandler.php b/packages/laravel/tests/Fixtures/TransportErrorHandler.php new file mode 100644 index 00000000..0d96a13b --- /dev/null +++ b/packages/laravel/tests/Fixtures/TransportErrorHandler.php @@ -0,0 +1,10 @@ +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 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 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 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']); + } + } +} From 26ced2f8f0f8da89468ee6e978065e68e69c2dc8 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:42:28 +0100 Subject: [PATCH 18/32] fix(laravel): clear Pennant cache after restoring queue context --- .../laravel/src/PostHogServiceProvider.php | 1 + .../Fixtures/PennantEndEventProvider.php | 20 +++++++++ packages/laravel/tests/PennantTest.php | 45 ++++++++++++++++++- 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 packages/laravel/tests/Fixtures/PennantEndEventProvider.php diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php index c1ac4973..d169d23a 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -146,6 +146,7 @@ private function finish(Container $app, bool $restore = false): void } finally { $context = $app->make(Context::class); $restore ? $context->leave() : $context->end(); + Features\PennantCache::flush(); } } } diff --git a/packages/laravel/tests/Fixtures/PennantEndEventProvider.php b/packages/laravel/tests/Fixtures/PennantEndEventProvider.php new file mode 100644 index 00000000..d513ce06 --- /dev/null +++ b/packages/laravel/tests/Fixtures/PennantEndEventProvider.php @@ -0,0 +1,20 @@ +app['events']->listen([JobProcessed::class, JobExceptionOccurred::class], function (): void { + if ($this->app['config']->get('pennant-test.read-on-job-end', false)) { + $this->app['config']->set('pennant-test.job-value', Feature::value('layout')); + } + }); + } +} diff --git a/packages/laravel/tests/PennantTest.php b/packages/laravel/tests/PennantTest.php index 31dde211..a22dedfc 100644 --- a/packages/laravel/tests/PennantTest.php +++ b/packages/laravel/tests/PennantTest.php @@ -2,6 +2,7 @@ namespace PostHog\Laravel\Tests; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Route; use Laravel\Pennant\Contracts\FeatureScopeable; use Laravel\Pennant\Feature; @@ -16,12 +17,16 @@ use PostHog\Laravel\Features\PostHogDriver; use PostHog\Laravel\PostHog; use PostHog\Laravel\PostHogServiceProvider; +use PostHog\Laravel\Tests\Fixtures\CaptureJob; +use PostHog\Laravel\Tests\Fixtures\FailingCaptureJob; +use PostHog\Laravel\Tests\Fixtures\PennantEndEventProvider; +use RuntimeException; class PennantTest extends TestCase { protected function getPackageProviders($app): array { - return [PennantServiceProvider::class, PostHogServiceProvider::class]; + return [PennantServiceProvider::class, PostHogServiceProvider::class, PennantEndEventProvider::class]; } protected function defineEnvironment($app): void @@ -162,6 +167,44 @@ public function testDisabledBulkReadsAreEmptyAndDoNotPerformIo(): void $this->assertFalse(Feature::for('explicit-b')->value('unknown')); } + public static function queueCallers(): array + { + return [ + 'anonymous caller, success' => [null, false], + 'anonymous caller, failure' => [null, true], + 'identified caller, success' => ['parent-owner', false], + 'identified caller, failure' => ['parent-owner', true], + ]; + } + + #[DataProvider('queueCallers')] + public function testQueueCompletionConsumersCannotPolluteTheRestoredCallersCache( + ?string $parentId, + bool $fail, + ): void { + $this->app['config']->set('queue.default', 'sync'); + $this->app['config']->set('pennant-test.read-on-job-end', true); + $events = []; + $this->sdk($parentId === null ? 1 : 2, $events); + $context = $this->app->make(Context::class); + $context->begin($parentId === null ? [] : ['distinctId' => $parentId]); + $this->assertSame($parentId ?? false, Feature::value('layout')); + $parentSnapshot = $this->app->make(PostHog::class)->evaluateFlags(); + try { + Bus::dispatchSync($fail ? new FailingCaptureJob('job-owner') : new CaptureJob('job-owner')); + $this->assertFalse($fail, 'The failing job must rethrow its original exception'); + } catch (RuntimeException $exception) { + $this->assertTrue($fail); + $this->assertSame('failed job', $exception->getMessage()); + } + $this->assertSame('job-owner', $this->app['config']->get('pennant-test.job-value')); + $this->assertSame($parentId, $context->data()['distinctId'] ?? null); + $this->assertSame($parentId ?? false, Feature::value('layout')); + if ($parentId !== null) { + $this->assertSame($parentSnapshot, $this->app->make(PostHog::class)->evaluateFlags()); + } + } + public function testAnonymousPennantScopeDoesNotCacheAnotherRequestsIdentity(): void { $events = []; From 8526b3285e78d06b33ae1895fa72f86d353b8f8c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:45:29 +0100 Subject: [PATCH 19/32] fix(laravel): preserve raw context precedence in manager getter --- packages/laravel/src/PostHog.php | 6 +-- packages/laravel/tests/RuntimeContextTest.php | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/laravel/src/PostHog.php b/packages/laravel/src/PostHog.php index fc72d73c..8cfae735 100644 --- a/packages/laravel/src/PostHog.php +++ b/packages/laravel/src/PostHog.php @@ -254,11 +254,7 @@ public function enterContext(array $data, array $options = []): ContextScope public function getContext(): ?array { - return $this->client()->withContext( - $this->app->make(Context::class)->data(), - fn () => $this->client()->getContext(), - ['fresh' => true], - ); + return $this->withContext([], fn () => $this->client()->getContext()); } public function __call(string $method, array $arguments): mixed diff --git a/packages/laravel/tests/RuntimeContextTest.php b/packages/laravel/tests/RuntimeContextTest.php index b2f4bbbf..ed9a6e88 100644 --- a/packages/laravel/tests/RuntimeContextTest.php +++ b/packages/laravel/tests/RuntimeContextTest.php @@ -184,6 +184,52 @@ public function testManagerScopesBridgeRawClientAndRestoreSnapshots(): void $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); From 9601245725ad6110dd08972fec72c6421a4d4965 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:46:23 +0100 Subject: [PATCH 20/32] fix(laravel): bound deferred model events to their owning transactions --- packages/laravel/AUTOCAPTURE.md | 19 +- .../src/Autocapture/DeferredModelEvents.php | 200 ++++++++++ .../laravel/src/Autocapture/ModelEvents.php | 22 +- .../src/AutocaptureServiceProvider.php | 14 + packages/laravel/tests/ModelEventsTest.php | 346 ++++++++++++++++++ 5 files changed, 593 insertions(+), 8 deletions(-) create mode 100644 packages/laravel/src/Autocapture/DeferredModelEvents.php create mode 100644 packages/laravel/tests/ModelEventsTest.php diff --git a/packages/laravel/AUTOCAPTURE.md b/packages/laravel/AUTOCAPTURE.md index 962611e1..be3f625d 100644 --- a/packages/laravel/AUTOCAPTURE.md +++ b/packages/laravel/AUTOCAPTURE.md @@ -133,10 +133,23 @@ omitted. Do not allowlist a sensitive accessor or JSON column. Values, identity, session and groups are snapshotted at the change, then emitted **after the model connection's outer transaction commits**. Rollback (including a nested -savepoint rollback) discards the corresponding pending events. No model object is -retained by the commit callback. Outside a transaction, collection is immediate. +savepoint rollback) discards the corresponding pending events. No model or request +object is retained. Outside a transaction, collection is immediate, even when a +different connection has an open transaction. + +All connections share an internal pending budget of **1,000 events and 1 MiB of +JSON-encoded event names, properties and correlation context**. If either limit +would be exceeded, the newest event is dropped (including an oversized allowlisted +attribute); there are no additional configuration options. Events without a usable +identity consume no pending capacity. Commit and rollback release capacity; +abandoned/disconnected transactions are also discarded before the next admission. +Callback registration happens only after admission, once per transaction record, +and is also capped at 1,000 records (including stale records retained by Laravel +after a connection reset). Admitted events retain their original identity and +operation order within each connection's outer transaction. + This is not a durable outbox; process crashes or transport failures can lose events. -Each actual model lifecycle event generates one event: multiple saves are not +Each admitted model lifecycle event generates one event: multiple saves are not coalesced. Bulk query-builder updates/deletes do not produce model events. Soft deletes produce the normal deleted event; restores are not collected. diff --git a/packages/laravel/src/Autocapture/DeferredModelEvents.php b/packages/laravel/src/Autocapture/DeferredModelEvents.php new file mode 100644 index 00000000..f14c1b8e --- /dev/null +++ b/packages/laravel/src/Autocapture/DeferredModelEvents.php @@ -0,0 +1,200 @@ + + */ + private array $pending = []; + private int $bytes = 0; + /** @var WeakMap Number of pending events per registered record. */ + private WeakMap $records; + /** @var WeakMap Only connections with admitted events. */ + private WeakMap $connections; + + public function __construct(private DatabaseTransactionsManager $transactions) + { + $this->records = new WeakMap(); + $this->connections = new WeakMap(); + } + + public function defer(Connection $connection, string $event, array $properties, array $snapshot): void + { + $this->prune(); + if (count($this->pending) >= self::MAX_EVENTS) { + return; + } + $encoded = json_encode([$event, $properties, $snapshot], JSON_PRESERVE_ZERO_FRACTION); + if ($encoded === false || strlen($encoded) > self::MAX_BYTES - $this->bytes) { + return; + } + // Connection::afterCommit forwards to a shared manager without a connection name. + $record = $this->transactions->getPendingTransactions()->last( + fn ($record) => $record->connection === $connection->getName() + && $record->level === $connection->transactionLevel(), + ); + if ($record === null) { + return; + } + // Laravel 12 can retain old records after a PDO reset, even after their payloads are discarded. + if (!isset($this->records[$record]) && count($this->records) >= self::MAX_EVENTS) { + return; + } + $root = $record; + // A reset can leave an older level-1 record as parent; it is not this transaction's root. + while ($root->parent !== null && $root->parent->level < $root->level) { + $root = $root->parent; + } + $rootReference = WeakReference::create($root); + if (!isset($this->records[$record])) { + // Admission precedes registration: rejected writes cannot grow callback bookkeeping. + // Flush this root's batch even if an earlier application callback starts another transaction. + $record->addCallback(function () use ($rootReference) { + if (($root = $rootReference->get()) !== null) { + $this->flush($root); + } + }); + $this->records[$record] = 0; + } + $this->records[$record]++; + $this->connections[$connection] = [ + 'root' => $rootReference, + 'count' => ($this->connections[$connection]['count'] ?? 0) + 1, + ]; + $this->pending[] = [ + 'connection' => WeakReference::create($connection), + 'pdo' => WeakReference::create($connection->getRawPdo()), + 'transaction' => WeakReference::create($record), + 'root' => $rootReference, + 'encoded' => $encoded, + ]; + $this->bytes += strlen($encoded); + } + + public function beginning(TransactionBeginning $event): void + { + $connection = $event->connection; + if (!isset($this->connections[$connection])) { + return; + } + $root = $this->transactions->getPendingTransactions()->last( + fn ($record) => $record->connection === $connection->getName(), + ); + if ($root === null) { + return; + } + while ($root->parent !== null && $root->parent->level < $root->level) { + $root = $root->parent; + } + // A callback may start/roll back a new root while an older committed root is awaiting its callback. + $state = $this->connections[$connection]; + $state['root'] = WeakReference::create($root); + $this->connections[$connection] = $state; + } + + public function committing(TransactionCommitting $event): void + { + $this->prune(); + } + + public function rolledBack(TransactionRolledBack $event): void + { + $state = $this->connections[$event->connection] ?? null; + $this->prune($state === null ? null : $state['root']->get()); + } + + /** @return array{count: int, bytes: int} */ + public function pendingUsage(): array + { + return ['count' => count($this->pending), 'bytes' => $this->bytes]; + } + + private function flush(DatabaseTransactionRecord $root): void + { + $ready = []; + foreach ($this->pending as $key => $entry) { + if ($entry['root']->get() === $root) { + $ready[] = $entry['encoded']; + $this->remove($key); + } + } + $this->prune(); + // Release capacity before capture and preserve operation order across nested commits. + foreach ($ready as $encoded) { + [$name, $properties, $snapshot] = json_decode($encoded, true); + Collector::capture($name, $properties, $snapshot); + } + } + + private function prune(?DatabaseTransactionRecord $rolledBackRoot = null): void + { + $pending = $this->transactions->getPendingTransactions(); + $committed = $this->transactions->getCommittedTransactions(); + $managed = static fn ($record) => $pending->containsStrict($record) || $committed->containsStrict($record); + foreach ($this->pending as $key => $entry) { + $connection = $entry['connection']->get(); + $record = $entry['transaction']->get(); + $root = $entry['root']->get(); + if ( + $connection === null || $connection->getRawPdo() === null + || $entry['pdo']->get() !== $connection->getRawPdo() + || $record === null || $root === null + || ($rolledBackRoot !== null && $root === $rolledBackRoot && !$managed($record)) + || (!$managed($record) && $managed($root)) + || ($connection->transactionLevel() === 0 && $managed($record)) + ) { + $this->remove($key); + } + // Missing records with a live root can be in Laravel's commit-callback stack. + // Only their own record callback may publish them; another connection cannot discard them. + } + // Keep zero-payload registrations counted until Laravel releases their records. + foreach ($this->records as $record => $count) { + if ($count === 0 && !$managed($record)) { + unset($this->records[$record]); + } + } + } + + private function remove(int $key): void + { + $entry = $this->pending[$key]; + $this->bytes -= strlen($entry['encoded']); + $record = $entry['transaction']->get(); + if ($record !== null && isset($this->records[$record])) { + --$this->records[$record]; + } + $connection = $entry['connection']->get(); + if ($connection !== null && isset($this->connections[$connection])) { + $state = $this->connections[$connection]; + if (--$state['count'] === 0) { + unset($this->connections[$connection]); + } else { + $this->connections[$connection] = $state; + } + } + unset($this->pending[$key]); + if ($this->pending === []) { + // Reset PHP's next numeric index when a long-lived worker drains a batch. + $this->pending = []; + } + } +} diff --git a/packages/laravel/src/Autocapture/ModelEvents.php b/packages/laravel/src/Autocapture/ModelEvents.php index e924610b..ffb503ae 100644 --- a/packages/laravel/src/Autocapture/ModelEvents.php +++ b/packages/laravel/src/Autocapture/ModelEvents.php @@ -18,6 +18,11 @@ public function collect(string $operation, Model $model): void if (!array_key_exists($model::class, $models) || !is_array($models[$model::class])) { return; } + $snapshot = Collector::snapshot(); + $id = $snapshot['distinctId'] ?? null; + if (!is_scalar($id) || (string) $id === '' || !Collector::jsonSafe($snapshot)) { + return; + } $attributes = []; $stored = $model->getAttributes(); foreach ($models[$model::class] as $key) { @@ -29,11 +34,18 @@ public function collect(string $operation, Model $model): void } } $properties = ['model' => $model::class, 'attributes' => $attributes]; - $snapshot = Collector::snapshot(); - // Register with this model's connection; no model or payload survives until commit. - $model->getConnection()->afterCommit(static function () use ($operation, $properties, $snapshot) { - Collector::capture('laravel_model_' . $operation, $properties, $snapshot); - }); + $connection = $model->getConnection(); + $event = 'laravel_model_' . $operation; + if ($connection->transactionLevel() === 0) { + Collector::capture($event, $properties, $snapshot); + return; + } + Container::getInstance()->make(DeferredModelEvents::class)->defer( + $connection, + $event, + $properties, + $snapshot, + ); } catch (Throwable) { } } diff --git a/packages/laravel/src/AutocaptureServiceProvider.php b/packages/laravel/src/AutocaptureServiceProvider.php index a2eecdb1..f72621f7 100644 --- a/packages/laravel/src/AutocaptureServiceProvider.php +++ b/packages/laravel/src/AutocaptureServiceProvider.php @@ -8,6 +8,7 @@ use Illuminate\Support\ServiceProvider; use PostHog\Laravel\Autocapture\AuthEvents; use PostHog\Laravel\Autocapture\Collector; +use PostHog\Laravel\Autocapture\DeferredModelEvents; use PostHog\Laravel\Autocapture\DomainEvents; use PostHog\Laravel\Autocapture\ModelEvents; use PostHog\Laravel\Autocapture\Outcomes; @@ -26,6 +27,10 @@ public function register(): void ? array_replace($settings[$section] ?? [], $values) : $values; } $config->set('posthog.autocapture', $settings); + $this->app->singleton( + DeferredModelEvents::class, + fn ($app) => new DeferredModelEvents($app['db.transactions']), + ); } public function boot(): void @@ -40,6 +45,15 @@ public function boot(): void (new AuthEvents())->collect(strtolower($name), $event); }); } + foreach ( + [ + 'TransactionBeginning' => 'beginning', + 'TransactionCommitting' => 'committing', + 'TransactionRolledBack' => 'rolledBack', + ] as $event => $method + ) { + $events->listen('Illuminate\\Database\\Events\\' . $event, [DeferredModelEvents::class, $method]); + } foreach (['created', 'updated', 'deleted'] as $operation) { $events->listen('eloquent.' . $operation . ': *', static function ($name, $payload) use ($operation) { (new ModelEvents())->collect($operation, $payload[0]); diff --git a/packages/laravel/tests/ModelEventsTest.php b/packages/laravel/tests/ModelEventsTest.php new file mode 100644 index 00000000..7a306ed9 --- /dev/null +++ b/packages/laravel/tests/ModelEventsTest.php @@ -0,0 +1,346 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('posthog.autocapture.models', [ + 'enabled' => true, 'allow' => [CollectedRecord::class => ['state']], + ]); + $app['config']->set('database.default', 'a'); + foreach (['a', 'b'] as $name) { + $app['config']->set('database.connections.' . $name, ['driver' => 'sqlite', 'database' => ':memory:']); + } + } + + protected function setUp(): void + { + parent::setUp(); + PostHog::fake(); + foreach (['a', 'b'] as $name) { + DB::connection($name)->getSchemaBuilder()->create('collected_records', function (Blueprint $table) { + $table->id(); + $table->text('state'); + $table->text('email'); + }); + } + $this->identity('original'); + } + + private function identity(string $id): void + { + $this->app->make(Context::class)->begin(['distinctId' => $id, 'groups' => ['team' => $id]]); + } + + private function record(string $state, string $connection = 'a'): CollectedRecord + { + return CollectedRecord::on($connection)->create(['state' => $state, 'email' => 'private']); + } + + private function messages(): array + { + return array_column(PostHog::getFacadeRoot()->recorded('capture'), 'message'); + } + + private function states(): array + { + return array_map(fn ($event) => $event['properties']['attributes']['state'], $this->messages()); + } + + private function buffer(): DeferredModelEvents + { + return $this->app->make(DeferredModelEvents::class); + } + + private function assertUsage(int $count, int $bytes): void + { + $this->assertSame(['count' => $count, 'bytes' => $bytes], $this->buffer()->pendingUsage()); + } + + private function encodedSize(string $state): int + { + return strlen(json_encode([ + 'laravel_model_created', + ['model' => CollectedRecord::class, 'attributes' => ['state' => $state]], + ['distinctId' => 'original', 'groups' => ['team' => 'original']], + ])); + } + + private function assertTransactionCallbackCount(int $expected): void + { + $transactions = $this->app['db.transactions']; + $records = $transactions->getPendingTransactions()->merge($transactions->getCommittedTransactions()); + $this->assertSame($expected, $records->sum(fn ($record) => count($record->getCallbacks()))); + } + + public function testOtherConnectionCommitCannotPublishRolledBackEvents(): void + { + DB::connection('a')->beginTransaction(); + DB::connection('b')->beginTransaction(); + $this->record('rolled back'); + DB::connection('b')->commit(); + PostHog::assertNothingCaptured(); + DB::connection('a')->rollBack(); + PostHog::assertNothingCaptured(); + $this->assertUsage(0, 0); + } + + public function testOwningConnectionCommitAndNontransactionalWritesDoNotWaitForOtherConnection(): void + { + DB::connection('a')->beginTransaction(); + DB::connection('b')->beginTransaction(); + $this->record('committed'); + $this->identity('later'); + DB::connection('a')->commit(); + $this->assertSame(['committed'], $this->states()); + $this->assertSame('original', $this->messages()[0]['distinctId']); + $this->record('immediate'); + $this->assertSame(['committed', 'immediate'], $this->states()); + DB::connection('b')->rollBack(); + $this->assertUsage(0, 0); + } + + public function testAfterCommitCallbackCanCommitOtherConnectionWithoutLosingOriginalBatch(): void + { + DB::connection('a')->beginTransaction(); + DB::connection('a')->afterCommit(fn () => DB::connection('b')->commit()); + DB::connection('b')->beginTransaction(); + $this->record('a first'); + $this->record('b', 'b'); + $this->record('a second'); + $this->identity('later'); + DB::connection('a')->commit(); + $this->assertSame(['b', 'a first', 'a second'], $this->states()); + $this->assertSame(['original', 'original', 'original'], array_column($this->messages(), 'distinctId')); + $this->assertUsage(0, 0); + } + + public function testAfterCommitCallbackCanStartAnotherTransactionWithoutPublishingItsEvents(): void + { + DB::beginTransaction(); + DB::afterCommit(function () { + DB::beginTransaction(); + $this->record('next transaction'); + }); + $this->record('original transaction'); + $this->identity('later'); + DB::commit(); + $this->assertSame(['original transaction'], $this->states()); + PostHog::assertCapturedCount(1); + DB::commit(); + $this->assertSame(['original transaction', 'next transaction'], $this->states()); + $this->assertSame(['original', 'later'], array_column($this->messages(), 'distinctId')); + $this->assertUsage(0, 0); + } + + public function testAfterCommitCallbackRollbackCannotDiscardTheCommittedRoot(): void + { + DB::beginTransaction(); + DB::afterCommit(function () { + DB::beginTransaction(); + $this->record('rolled back callback transaction'); + DB::rollBack(); + }); + $this->record('committed original'); + DB::commit(); + $this->assertSame(['committed original'], $this->states()); + $this->assertUsage(0, 0); + } + + public function testRollbackReleasesBudgetEvenWhenApplicationRetainsTransactionRecords(): void + { + DB::beginTransaction(); + $this->record('rolled back'); + $records = $this->app['db.transactions']->getPendingTransactions()->all(); + DB::rollBack(); + $this->assertUsage(0, 0); + $this->assertCount(1, $records); + PostHog::assertNothingCaptured(); + } + + public function testNestedCommitsPreserveEventOrderAndRollbackReleasesSavepointBudget(): void + { + DB::beginTransaction(); + $this->record('first'); + DB::beginTransaction(); + $this->record('discarded'); + DB::beginTransaction(); + $this->record('discarded committed child'); + DB::commit(); + DB::rollBack(); + $this->assertUsage(1, $this->encodedSize('first')); + DB::beginTransaction(); + $this->record('second'); + DB::commit(); + $this->record('third'); + PostHog::assertNothingCaptured(); + $this->identity('later'); + DB::commit(); + $this->assertSame(['first', 'second', 'third'], $this->states()); + $this->assertSame(['original', 'original', 'original'], array_column($this->messages(), 'distinctId')); + $this->assertUsage(0, 0); + DB::beginTransaction(); + DB::beginTransaction(); + $this->record('committed child of rolled back parent'); + DB::commit(); + DB::rollBack(); + $this->assertSame(['first', 'second', 'third'], $this->states()); + $this->assertUsage(0, 0); + } + + public function testCountAdmissionIsAggregateDropNewestAndBoundsCallbackBookkeeping(): void + { + DB::connection('a')->beginTransaction(); + DB::connection('b')->beginTransaction(); + for ($i = 0; $i < 1100; $i++) { + $this->record(sprintf('%04d', $i), $i % 2 === 0 ? 'a' : 'b'); + } + $this->assertTransactionCallbackCount(2); + $this->assertUsage(1000, 1000 * $this->encodedSize('0000')); + PostHog::assertNothingCaptured(); + DB::connection('a')->rollBack(); + $this->assertUsage(500, 500 * $this->encodedSize('0000')); + $this->record('new after release', 'b'); + DB::connection('b')->commit(); + PostHog::assertCapturedCount(501); + $expected = array_map(fn ($i) => sprintf('%04d', $i), range(1, 999, 2)); + $this->assertSame([...$expected, 'new after release'], $this->states()); + $this->assertUsage(0, 0); + } + + public function testDistinctTransactionRecordsCannotGrowCallbacksBeyondAdmissionBudget(): void + { + DB::beginTransaction(); + for ($i = 0; $i < 1100; $i++) { + DB::beginTransaction(); + $this->record('same'); + DB::commit(); + } + $this->assertUsage(1000, 1000 * $this->encodedSize('same')); + $this->assertTransactionCallbackCount(1000); + PostHog::assertNothingCaptured(); + DB::commit(); + PostHog::assertCapturedCount(1000); + $this->assertUsage(0, 0); + } + + public function testConnectionResetsCannotAccumulateUnboundedOrphanedCallbacks(): void + { + $connection = DB::connection('a'); + for ($i = 0; $i < 1100; $i++) { + if ($i !== 0) { + // Laravel 12 disconnect resets PDO/depth without clearing manager records. + $connection->setPdo(new \PDO('sqlite::memory:')); + $connection->getSchemaBuilder()->create('collected_records', function (Blueprint $table) { + $table->id(); + $table->text('state'); + $table->text('email'); + }); + } + $connection->beginTransaction(); + $this->record('abandoned'); + } + $this->assertUsage(0, 0); + $this->assertTransactionCallbackCount(1000); + $connection->commit(); + PostHog::assertNothingCaptured(); + DB::transaction(fn () => $this->record('fresh')); + $this->assertSame(['fresh'], $this->states()); + $this->assertUsage(0, 0); + } + + public function testByteAdmissionIncludesContextAndDropsOversizedApprovedAttributes(): void + { + DB::beginTransaction(); + $this->record(str_repeat('x', 1048576)); + $this->assertTransactionCallbackCount(0); + $this->assertUsage(0, 0); + $state = str_repeat('x', 1048576 - $this->encodedSize('')); + $this->record($state); + $this->assertUsage(1, 1048576); + $this->record('drop newest'); + $this->assertUsage(1, 1048576); + PostHog::assertNothingCaptured(); + DB::commit(); + $this->assertSame([$state], $this->states()); + $this->assertUsage(0, 0); + DB::beginTransaction(); + $this->record('fits after commit'); + DB::commit(); + $this->assertSame([$state, 'fits after commit'], $this->states()); + } + + public function testRetryAndRepeatedSavepointRollbackReleaseCapacityWithoutCallbackAccumulation(): void + { + $attempt = 0; + DB::transaction(function () use (&$attempt) { + $this->record('attempt ' . ++$attempt); + if ($attempt === 1) { + throw new RuntimeException('database is locked'); + } + }, 2); + $this->assertSame(['attempt 2'], $this->states()); + $this->assertUsage(0, 0); + DB::beginTransaction(); + for ($i = 0; $i < 1100; $i++) { + DB::beginTransaction(); + $this->record('discarded'); + DB::rollBack(); + } + $this->assertUsage(0, 0); + $this->assertTransactionCallbackCount(0); + $this->record('final'); + DB::commit(); + $this->assertSame(['attempt 2', 'final'], $this->states()); + } + + public function testNoIdentityConsumesNoPendingCapacityAndModelIsNotRetained(): void + { + $this->app->make(Context::class)->begin([]); + DB::beginTransaction(); + $this->record('no identity'); + $this->assertUsage(0, 0); + $this->assertTransactionCallbackCount(0); + $this->identity('original'); + $model = $this->record('admitted'); + $reference = \WeakReference::create($model); + unset($model); + gc_collect_cycles(); + $this->assertNull($reference->get()); + DB::rollBack(); + $this->assertUsage(0, 0); + PostHog::assertNothingCaptured(); + } + + public function testDisconnectReleasesAbandonedTransactionBudgetBeforeNextAdmission(): void + { + DB::beginTransaction(); + $this->record('abandoned'); + DB::disconnect('a'); + DB::connection('b')->beginTransaction(); + $this->record('fresh', 'b'); + $this->assertUsage(1, $this->encodedSize('fresh')); + DB::connection('b')->commit(); + $this->assertUsage(0, 0); + $this->assertSame(['fresh'], $this->states()); + } +} From 893c8c56b86ef71a8d1e2de2c41304e9feb28277 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:55:46 +0100 Subject: [PATCH 21/32] fix(laravel): omit outcomes for overlap-skipped schedules --- packages/laravel/src/Autocapture/Outcomes.php | 38 +++++++++++++ .../src/AutocaptureServiceProvider.php | 9 +-- packages/laravel/tests/AutocaptureTest.php | 2 + .../laravel/tests/SchedulerOutcomeTest.php | 56 +++++++++++++++++++ 4 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 packages/laravel/tests/SchedulerOutcomeTest.php diff --git a/packages/laravel/src/Autocapture/Outcomes.php b/packages/laravel/src/Autocapture/Outcomes.php index 7bd36f0b..7e0ea60d 100644 --- a/packages/laravel/src/Autocapture/Outcomes.php +++ b/packages/laravel/src/Autocapture/Outcomes.php @@ -2,19 +2,25 @@ namespace PostHog\Laravel\Autocapture; +use Illuminate\Console\Scheduling\Event; use Illuminate\Container\Container; use Illuminate\Support\Str; use Throwable; use WeakMap; +use WeakReference; /** @internal Weak keys prevent retaining jobs, command inputs or scheduled tasks in workers. */ class Outcomes { private WeakMap $starts; + private WeakMap $scheduled; + private WeakMap $executed; public function __construct() { $this->starts = new WeakMap(); + $this->scheduled = new WeakMap(); + $this->executed = new WeakMap(); } public function start(string $section, object $key): void @@ -24,6 +30,38 @@ public function start(string $section, object $key): void } } + public function startScheduler(Event $task): void + { + if (!Collector::enabled('scheduler')) { + return; + } + $this->start('scheduler', $task); + $this->executed[$task] = false; + if (!isset($this->scheduled[$task])) { + $this->scheduled[$task] = true; + // The task owns this callback, so it must not capture the task strongly. + $reference = WeakReference::create($task); + $executed = $this->executed; + $task->before(static function () use ($reference, $executed): void { + if (($task = $reference->get()) !== null) { + $executed[$task] = true; + } + }); + } + } + + public function finishScheduler(Event $task, bool $failed): void + { + $executed = $this->executed[$task] ?? false; + unset($this->executed[$task]); + $this->finish('scheduler', $task, static fn () => $failed || $executed ? [ + 'name' => $task::class, + 'outcome' => $failed ? 'failed' : ($task->runInBackground ? 'background_started' + : ($task->exitCode === 0 ? 'success' : 'failed')), + 'exit_code' => $task->exitCode, + ] : []); + } + public function finish(string $section, object $key, callable $metadata): void { if (!isset($this->starts[$key])) { diff --git a/packages/laravel/src/AutocaptureServiceProvider.php b/packages/laravel/src/AutocaptureServiceProvider.php index f72621f7..a17392f5 100644 --- a/packages/laravel/src/AutocaptureServiceProvider.php +++ b/packages/laravel/src/AutocaptureServiceProvider.php @@ -115,19 +115,14 @@ private function registerOutcomes(Dispatcher $events): void $events->listen( \Illuminate\Console\Events\ScheduledTaskStarting::class, static function ($event) use ($outcomes) { - $outcomes->start('scheduler', $event->task); + $outcomes->startScheduler($event->task); }, ); foreach (['ScheduledTaskFinished' => false, 'ScheduledTaskFailed' => true] as $event => $failed) { $events->listen( 'Illuminate\\Console\\Events\\' . $event, static function ($event) use ($outcomes, $failed) { - $outcomes->finish('scheduler', $event->task, static fn () => [ - 'name' => $event->task::class, - 'outcome' => $failed ? 'failed' : ($event->task->runInBackground ? 'background_started' - : ($event->task->exitCode === 0 ? 'success' : 'failed')), - 'exit_code' => $event->task->exitCode, - ]); + $outcomes->finishScheduler($event->task, $failed); }, ); } diff --git a/packages/laravel/tests/AutocaptureTest.php b/packages/laravel/tests/AutocaptureTest.php index 856f6a27..52d87143 100644 --- a/packages/laravel/tests/AutocaptureTest.php +++ b/packages/laravel/tests/AutocaptureTest.php @@ -384,11 +384,13 @@ public function testCommandAndSchedulerMetadataExcludeArgumentsAndDeduplicateFai $task = new Event($this->createStub(EventMutex::class), 'php artisan import --secret=password'); $task->exitCode = 1; $events->dispatch(new ScheduledTaskStarting($task)); + $task->callBeforeCallbacks($this->app); $events->dispatch(new ScheduledTaskFinished($task, 0.1)); $events->dispatch(new ScheduledTaskFailed($task, new RuntimeException('secret'), 0.1)); $task->runInBackground = true; $task->exitCode = null; $events->dispatch(new ScheduledTaskStarting($task)); + $task->callBeforeCallbacks($this->app); $events->dispatch(new ScheduledTaskFinished($task, 0.1)); $fake->assertCapturedCount(3); $this->assertSame('import', $this->events()[0]['properties']['name']); diff --git a/packages/laravel/tests/SchedulerOutcomeTest.php b/packages/laravel/tests/SchedulerOutcomeTest.php new file mode 100644 index 00000000..7da3f703 --- /dev/null +++ b/packages/laravel/tests/SchedulerOutcomeTest.php @@ -0,0 +1,56 @@ +set('posthog.api_key', 'test-key'); + $app['config']->set('posthog.autocapture.scheduler.enabled', true); + } + + public static function taskModes(): array + { + return ['foreground' => [false], 'background' => [true]]; + } + + #[DataProvider('taskModes')] + public function testOverlapLockRacesDoNotProduceAnOutcome(bool $background): void + { + $fake = PostHog::fake(); + $this->app->make(Context::class)->begin(['distinctId' => 'scheduler-actor']); + $mutex = $this->createMock(EventMutex::class); + $mutex->method('exists')->willReturn(false); + $mutex->expects($this->exactly(2))->method('create')->willReturnOnConsecutiveCalls(false, true); + $this->app->instance(EventMutex::class, $mutex); + $schedule = $this->app->make(Schedule::class); + $task = $schedule->exec(PHP_BINARY, ['-r', 'exit(0);'])->withoutOverlapping(); + $task->runInBackground = $background; + + $this->assertSame(0, Artisan::call('schedule:run')); + $fake->assertNotCaptured('laravel_scheduled_task'); + + $task->runInBackground = false; + $this->assertSame(0, Artisan::call('schedule:run')); + $fake->assertCapturedCount(1); + $fake->assertCaptured('laravel_scheduled_task', fn ($event) => + $event['distinctId'] === 'scheduler-actor' + && $event['properties']['outcome'] === 'success' + && $event['properties']['exit_code'] === 0); + } +} From a33384afc1a1babfb8c17007f48ca8a56c0db05c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 13:05:06 +0100 Subject: [PATCH 22/32] fix: make library metadata and release id internal integration overrides Remove library_name, library_version and release_id from the public client options. Framework integrations override internal Client methods instead, so customers keep POSTHOG_RELEASE_ID as the only documented release id input. --- .changeset/young-onions-hide.md | 2 +- lib/Client.php | 48 +++++++++++++++++------ lib/PostHog.php | 3 -- test/ConsumerTransportTestCase.php | 51 ++++++++++-------------- test/FrameworkIntegrationTest.php | 62 ++++++++++++++++++++++-------- test/IntegrationClient.php | 37 ++++++++++++++++++ 6 files changed, 142 insertions(+), 61 deletions(-) create mode 100644 test/IntegrationClient.php diff --git a/.changeset/young-onions-hide.md b/.changeset/young-onions-hide.md index 9a218d4e..dd1352a2 100644 --- a/.changeset/young-onions-hide.md +++ b/.changeset/young-onions-hide.md @@ -2,4 +2,4 @@ "posthog-php": minor --- -Add instance group identify, lifecycle context scopes, framework exception metadata, and explicit library and release configuration. +Add instance group identify, lifecycle context scopes, and framework exception metadata. diff --git a/lib/Client.php b/lib/Client.php index 93f6f2ca..a185de8a 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -273,9 +273,6 @@ class Client implements FeatureFlagEvaluationsHost * * @param array{ * host?: string, - * library_name?: string, - * library_version?: string, - * release_id?: string|null, * ssl?: bool, * timeout?: int|float, * verify_batch_events_request?: bool, @@ -332,15 +329,10 @@ public function __construct( ); $this->flagDefinitionCacheProviderShutdown = false; $this->shutdownComplete = false; - $envReleaseId = getenv(self::ENV_RELEASE_ID); - $releaseId = array_key_exists('release_id', $options) - ? $options['release_id'] - : ($envReleaseId === false ? null : $envReleaseId); - $this->releaseId = StringNormalizer::normalizeOptional($releaseId); - foreach (['library_name' => PostHog::LIBRARY, 'library_version' => PostHog::VERSION] as $key => $default) { - $value = StringNormalizer::normalizeOptional($options[$key] ?? null); - $this->options[$key] = $value !== null && !preg_match('/[\x00-\x1f\x7f]/', $value) ? $value : $default; - } + $this->releaseId = StringNormalizer::normalizeOptional($this->releaseId()); + // Consumers read these keys for the User-Agent; always overwrite caller-supplied values. + $this->options['library_name'] = $this->libraryName(); + $this->options['library_version'] = $this->libraryVersion(); $this->options['host'] = StringNormalizer::normalizeHost($options['host'] ?? null); if (!$this->enabled) { if (($this->options['consumer'] ?? null) !== 'noop') { @@ -407,6 +399,38 @@ private static function normalizeFlagDefinitionCacheProvider(mixed $provider): ? return $provider; } + /** + * Library name reported as $lib and in the User-Agent. Read once during construction. + * + * @internal Overridden by PostHog framework integrations; not part of the customer API. + */ + protected function libraryName(): string + { + return PostHog::LIBRARY; + } + + /** + * Library version reported as $lib_version and in the User-Agent. Read once during construction. + * + * @internal Overridden by PostHog framework integrations; not part of the customer API. + */ + protected function libraryVersion(): string + { + return PostHog::VERSION; + } + + /** + * Release id reported as $release_id. Read once during construction. + * + * @internal Overridden by PostHog framework integrations whose configuration is cached + * and cannot rely on the process environment; not part of the customer API. + */ + protected function releaseId(): ?string + { + $releaseId = getenv(self::ENV_RELEASE_ID); + return $releaseId === false ? null : $releaseId; + } + /** * Flush and clean up the underlying consumer when the client is destroyed. */ diff --git a/lib/PostHog.php b/lib/PostHog.php index 843706fc..d852e397 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -41,9 +41,6 @@ class PostHog * * @param array{ * host?: string, - * library_name?: string, - * library_version?: string, - * release_id?: string|null, * ssl?: bool, * timeout?: int|float, * verify_batch_events_request?: bool, diff --git a/test/ConsumerTransportTestCase.php b/test/ConsumerTransportTestCase.php index 2dc8c9e8..4197421d 100644 --- a/test/ConsumerTransportTestCase.php +++ b/test/ConsumerTransportTestCase.php @@ -81,40 +81,31 @@ public function testAlias(bool $compressed): void self::assertSame('user-id', $event['properties']['distinct_id']); } - public static function libraryMetadataCases(): array + public function testConstructorOptionsCannotOverrideBatchUserAgent(): void { - return [ - 'configured metadata' => [ - ['library_name' => 'posthog-laravel', 'library_version' => '0.1.0'], 'posthog-laravel/0.1.0', - ], - 'normalized metadata' => [ - ['library_name' => " posthog-laravel\t", 'library_version' => ' 0.1.0 '], 'posthog-laravel/0.1.0', - ], - 'empty metadata' => [ - ['library_name' => " \t", 'library_version' => null], PostHog::LIBRARY . '/' . PostHog::VERSION, - ], - 'library name only' => [['library_name' => 'posthog-laravel'], 'posthog-laravel/' . PostHog::VERSION], - 'library version only' => [['library_version' => '0.1.0'], PostHog::LIBRARY . '/0.1.0'], - 'quoted metadata' => [ - ['library_name' => "posthog'laravel", 'library_version' => '0.1.0'], "posthog'laravel/0.1.0", - ], - 'line break in name' => [ - ['library_name' => "posthog\r\nX-Injected: yes", 'library_version' => '0.1.0'], - PostHog::LIBRARY . '/0.1.0', - ], - 'null byte in version' => [ - ['library_name' => 'posthog-laravel', 'library_version' => "0.1\0.0"], - 'posthog-laravel/' . PostHog::VERSION, - ], - ]; + $client = $this->client(['library_name' => 'custom', 'library_version' => '9.9.9']); + self::assertTrue($client->capture(['distinctId' => 'user', 'event' => 'ignored-options'])); + $this->flushEvent($client); } - #[DataProvider('libraryMetadataCases')] - public function testBatchUserAgentMatchesConfiguredLibrary(array $options, string $expectedUserAgent): void + public function testBatchUserAgentMatchesIntegrationLibrary(): void { - $client = $this->client($options); - self::assertTrue($client->capture(['distinctId' => 'user', 'event' => 'configured-library'])); - self::assertTrue($client->flush()); + // The quote covers shell escaping in the fork_curl consumer. + $expectedUserAgent = "posthog'laravel/0.1.0"; + IntegrationClient::$name = "posthog'laravel"; + try { + $client = new IntegrationClient('test-key', [ + 'consumer' => $this->consumer(), + 'host' => $this->server->address(), + 'ssl' => false, + 'debug' => true, + 'maximum_backoff_duration' => 100, + ], null, null, false); + self::assertTrue($client->capture(['distinctId' => 'user', 'event' => 'integration-library'])); + self::assertTrue($client->flush()); + } finally { + IntegrationClient::reset(); + } $requests = $this->server->requests(); self::assertCount(1, $requests); self::assertSame($expectedUserAgent, $requests[0]['headers']['user-agent']); diff --git a/test/FrameworkIntegrationTest.php b/test/FrameworkIntegrationTest.php index 8d48b48e..0813818a 100644 --- a/test/FrameworkIntegrationTest.php +++ b/test/FrameworkIntegrationTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use PostHog\Client; +use PostHog\PostHog; use RuntimeException; class FrameworkIntegrationTest extends TestCase @@ -205,11 +206,21 @@ public function testManualExceptionsStayHandledByDefault(): void self::assertSame('generic', $event['properties']['$exception_list'][0]['mechanism']['type']); } - public function testConfiguredLibraryAndReleaseApplyAcrossEventTypes(): void + private function integrationClient(?string $release = null): Client { - $client = $this->client([ - 'library_name' => 'posthog-laravel', 'library_version' => '0.1.0', 'release_id' => 'release', - ]); + $this->http = new MockedHttpClient('localhost'); + IntegrationClient::$release = $release; + return new IntegrationClient('test-key', [], $this->http, loadFeatureFlags: false); + } + + protected function tearDown(): void + { + IntegrationClient::reset(); + } + + public function testIntegrationLibraryAndReleaseApplyAcrossEventTypes(): void + { + $client = $this->integrationClient('release'); $client->capture(['event' => 'purchase']); $client->identify(['distinctId' => 'user', 'properties' => ['plan' => 'pro']]); $client->alias(['distinctId' => 'user', 'alias' => 'old-user']); @@ -224,34 +235,55 @@ public function testConfiguredLibraryAndReleaseApplyAcrossEventTypes(): void } } - public function testConfiguredLibraryIsUsedForFlagHttpRequests(): void + public function testIntegrationLibraryIsUsedForFlagHttpRequests(): void { - $client = $this->client(['library_name' => 'posthog-laravel', 'library_version' => '0.1.0']); + $client = $this->integrationClient(); $client->evaluateFlags('user'); self::assertContains('User-Agent: posthog-laravel/0.1.0', $this->http->calls[0]['extraHeaders']); } - public function testReleaseOptionOverridesEnvironmentAndNullDisablesIt(): void + public function testIntegrationReleaseOverridesEnvironment(): void { $previous = getenv('POSTHOG_RELEASE_ID'); putenv('POSTHOG_RELEASE_ID=environment-release'); try { - $client = $this->client(['release_id' => 'configured-release']); + $client = $this->integrationClient('configured-release'); $client->capture(['event' => 'configured']); self::assertSame('configured-release', $this->events($client)[0]['properties']['$release_id']); - $client = $this->client(['release_id' => null]); - $client->capture(['event' => 'disabled']); - self::assertArrayNotHasKey('$release_id', $this->events($client)[0]['properties']); + $client = $this->integrationClient(); + $client->capture(['event' => 'fallback']); + self::assertSame('environment-release', $this->events($client)[0]['properties']['$release_id']); } finally { putenv($previous === false ? 'POSTHOG_RELEASE_ID' : 'POSTHOG_RELEASE_ID=' . $previous); } } - public function testExplicitEventMetadataWinsOverClientOptions(): void + public function testConstructorOptionsCannotOverrideLibraryOrRelease(): void { - $client = $this->client([ - 'library_name' => 'integration', 'library_version' => '0.1.0', 'release_id' => 'default', - ]); + $previous = getenv('POSTHOG_RELEASE_ID'); + putenv('POSTHOG_RELEASE_ID'); + try { + $client = $this->client([ + 'library_name' => 'custom', 'library_version' => '9.9.9', 'release_id' => 'configured', + ]); + $client->capture(['event' => 'ignored-options']); + $client->evaluateFlags('user'); + $properties = $this->events($client)[0]['properties']; + self::assertSame(PostHog::LIBRARY, $properties['$lib']); + self::assertSame(PostHog::VERSION, $properties['$lib_version']); + self::assertArrayNotHasKey('$release_id', $properties); + self::assertContains( + 'User-Agent: ' . PostHog::LIBRARY . '/' . PostHog::VERSION, + $this->http->calls[0]['extraHeaders'] + ); + } finally { + putenv($previous === false ? 'POSTHOG_RELEASE_ID' : 'POSTHOG_RELEASE_ID=' . $previous); + } + } + + public function testExplicitEventMetadataWinsOverIntegrationDefaults(): void + { + $client = $this->integrationClient('default'); $client->capture(['event' => 'custom', 'properties' => [ '$lib' => 'custom', '$lib_version' => '2.0.0', '$release_id' => 'explicit', ]]); diff --git a/test/IntegrationClient.php b/test/IntegrationClient.php new file mode 100644 index 00000000..fc8cab7c --- /dev/null +++ b/test/IntegrationClient.php @@ -0,0 +1,37 @@ + Date: Thu, 29 Oct 2026 13:05:59 +0100 Subject: [PATCH 23/32] fix(laravel): use internal client overrides for library metadata and release id --- packages/laravel/src/LaravelClient.php | 33 ++++++++++++++++++++++++++ packages/laravel/src/PostHog.php | 11 +++------ packages/laravel/tests/RuntimeTest.php | 2 ++ 3 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 packages/laravel/src/LaravelClient.php diff --git a/packages/laravel/src/LaravelClient.php b/packages/laravel/src/LaravelClient.php new file mode 100644 index 00000000..3d66ad12 --- /dev/null +++ b/packages/laravel/src/LaravelClient.php @@ -0,0 +1,33 @@ +configuredReleaseId ?? parent::releaseId(); + } +} diff --git a/packages/laravel/src/PostHog.php b/packages/laravel/src/PostHog.php index 8cfae735..526811fa 100644 --- a/packages/laravel/src/PostHog.php +++ b/packages/laravel/src/PostHog.php @@ -38,11 +38,6 @@ public function client(): Client $options = $config['sdk'] ?? []; $options += ['disableGeoip' => true]; $options['host'] = $config['host'] ?? 'https://us.i.posthog.com'; - $options['library_name'] = 'posthog-laravel'; - $options['library_version'] = self::VERSION; - if (($config['release_id'] ?? null) !== null) { - $options['release_id'] = $config['release_id']; - } // Laravel owns reporting and process-wide PHP handlers. $options['error_tracking'] = array_replace($options['error_tracking'] ?? [], [ 'enabled' => false, @@ -58,11 +53,11 @@ public function client(): Client if (!isset($options['flag_definition_cache_provider']) && ($config['flag_cache']['enabled'] ?? false)) { $options['flag_definition_cache_provider'] = $this->app->make(FlagDefinitionCacheProvider::class); } - return $this->sdk = new Client( + return $this->sdk = new LaravelClient( $config['api_key'], $options, - loadFeatureFlags: false, - secretKey: $config['secret_key'] ?? null, + $config['release_id'] ?? null, + $config['secret_key'] ?? null, ); } diff --git a/packages/laravel/tests/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php index d518f10f..c450c67b 100644 --- a/packages/laravel/tests/RuntimeTest.php +++ b/packages/laravel/tests/RuntimeTest.php @@ -461,6 +461,8 @@ public function testRealClientMetadataGroupsAndCanonicalExceptionAreForwarded(): $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']]); From b63cd37cb1bfbde8dbe49e37584bbf5a3393a135 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 13:16:43 +0100 Subject: [PATCH 24/32] fix(laravel): release model buffer on commit failure retry --- .../src/Autocapture/DeferredModelEvents.php | 19 ++++++++++ packages/laravel/tests/ModelEventsTest.php | 36 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/packages/laravel/src/Autocapture/DeferredModelEvents.php b/packages/laravel/src/Autocapture/DeferredModelEvents.php index f14c1b8e..018f821b 100644 --- a/packages/laravel/src/Autocapture/DeferredModelEvents.php +++ b/packages/laravel/src/Autocapture/DeferredModelEvents.php @@ -91,6 +91,7 @@ public function defer(Connection $connection, string $event, array $properties, public function beginning(TransactionBeginning $event): void { + $this->prune(); $connection = $event->connection; if (!isset($this->connections[$connection])) { return; @@ -104,6 +105,24 @@ public function beginning(TransactionBeginning $event): void while ($root->parent !== null && $root->parent->level < $root->level) { $root = $root->parent; } + if ($connection->transactionLevel() === 1) { + $pending = $this->transactions->getPendingTransactions(); + $committed = $this->transactions->getCommittedTransactions(); + foreach ($this->pending as $key => $entry) { + $olderRoot = $entry['root']->get(); + if ( + $entry['connection']->get() === $connection && $olderRoot !== $root + && ($pending->containsStrict($olderRoot) || $committed->containsStrict($olderRoot)) + ) { + // Laravel 12 commit retries can leave rolled-back roots managed on the same PDO. + // Unmanaged roots may still be delivering successful commit callbacks; keep them. + $this->remove($key); + } + } + } + if (!isset($this->connections[$connection])) { + return; + } // A callback may start/roll back a new root while an older committed root is awaiting its callback. $state = $this->connections[$connection]; $state['root'] = WeakReference::create($root); diff --git a/packages/laravel/tests/ModelEventsTest.php b/packages/laravel/tests/ModelEventsTest.php index 7a306ed9..697c9907 100644 --- a/packages/laravel/tests/ModelEventsTest.php +++ b/packages/laravel/tests/ModelEventsTest.php @@ -2,6 +2,7 @@ namespace PostHog\Laravel\Tests; +use Illuminate\Database\Events\TransactionCommitting; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; use Orchestra\Testbench\TestCase; @@ -289,6 +290,41 @@ public function testByteAdmissionIncludesContextAndDropsOversizedApprovedAttribu $this->assertSame([$state, 'fits after commit'], $this->states()); } + public function testCommitFailureRetryDiscardsFailedAttemptBeforeReadmittingEvents(): void + { + $commits = 0; + $this->app['events']->listen(TransactionCommitting::class, function ($event) use (&$commits) { + if ($event->connectionName === 'a' && ++$commits === 1) { + throw new RuntimeException('database is locked'); + } + }); + $pdo = DB::connection('a')->getPdo(); + $attempt = 0; + $usageOnRetry = null; + DB::transaction(function () use (&$attempt, &$usageOnRetry) { + if (++$attempt === 1) { + for ($i = 0; $i < 1000; $i++) { + $this->record('failed attempt'); + } + $this->assertUsage(1000, 1000 * $this->encodedSize('failed attempt')); + } else { + $usageOnRetry = $this->buffer()->pendingUsage(); + $this->record('committed retry'); + } + PostHog::assertNothingCaptured(); + }, 2); + $this->assertSame(2, $attempt); + $this->assertSame($pdo, DB::connection('a')->getPdo()); + $this->assertSame(['count' => 0, 'bytes' => 0], $usageOnRetry); + $this->assertSame(['committed retry'], $this->states()); + $this->assertSame(1, CollectedRecord::count()); + $this->assertUsage(0, 0); + $this->assertTransactionCallbackCount(0); + DB::transaction(fn () => $this->record('next transaction')); + $this->assertSame(['committed retry', 'next transaction'], $this->states()); + $this->assertUsage(0, 0); + } + public function testRetryAndRepeatedSavepointRollbackReleaseCapacityWithoutCallbackAccumulation(): void { $attempt = 0; From dc28ca9bb1e36de1c377017243391d41aac246a2 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 13:23:08 +0100 Subject: [PATCH 25/32] docs: explain when to use withContext versus enterContext --- lib/Client.php | 29 ++++++++++++++++++++++++++--- lib/PostHog.php | 30 +++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index a185de8a..e08e7089 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -2447,6 +2447,9 @@ private function formatDateTime(\DateTimeInterface $date): string /** * Run a callback with request context applied to all captures in the callback. * + * Prefer this over enterContext() whenever the work can be wrapped in a callback: the + * previous context is restored when the callback returns or throws. + * * @param array{ * distinctId?: string, * distinct_id?: string, @@ -2468,10 +2471,30 @@ public function withContext(array $data, callable $fn, array $options = []): mix } /** - * Enter context across framework lifecycle callbacks. Close the returned scope in a finally block. + * Apply request context until the returned scope is closed. * - * @param array $data - * @param array{fresh?: bool} $options + * Use this only when the work starts and ends in separate callbacks, such as framework + * lifecycle events or before/after middleware hooks; otherwise prefer withContext(). + * Close the scope on every exit path, including failures. An unclosed scope leaks its context + * into later work until the scope is garbage-collected. Closing a scope is idempotent. + * + * ```php + * // Job started callback + * $this->scope = $client->enterContext(['distinctId' => $userId]); + * + * // Job finished or failed callback + * $this->scope?->close(); + * $this->scope = null; + * ``` + * + * @param array{ + * distinctId?: string, + * distinct_id?: string, + * sessionId?: string, + * session_id?: string, + * properties?: array + * } $data + * @param array{fresh?: bool} $options Use `fresh => true` to avoid inheriting the current context. */ public function enterContext(array $data, array $options = []): ContextScope { diff --git a/lib/PostHog.php b/lib/PostHog.php index d852e397..017f8413 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -470,6 +470,9 @@ public static function alias(array $message) /** * Run a callback with request context applied to all captures in the callback. * + * Prefer this over enterContext() whenever the work can be wrapped in a callback: the + * previous context is restored when the callback returns or throws. + * * @param array{ * distinctId?: string, * distinct_id?: string, @@ -490,10 +493,31 @@ public static function withContext(array $data, callable $fn, array $options = [ } /** - * Enter framework context until the returned scope is closed. + * Apply request context until the returned scope is closed. + * + * Use this only when the work starts and ends in separate callbacks, such as framework + * lifecycle events or before/after middleware hooks; otherwise prefer withContext(). + * Close the scope on every exit path, including failures. An unclosed scope leaks its context + * into later work until the scope is garbage-collected. Closing a scope is idempotent. + * + * ```php + * // Job started callback + * $this->scope = PostHog::enterContext(['distinctId' => $userId]); + * + * // Job finished or failed callback + * $this->scope?->close(); + * $this->scope = null; + * ``` * - * @param array $data - * @param array{fresh?: bool} $options + * @param array{ + * distinctId?: string, + * distinct_id?: string, + * sessionId?: string, + * session_id?: string, + * properties?: array + * } $data + * @param array{fresh?: bool} $options Use `fresh => true` to avoid inheriting the current context. + * @throws Exception When the client has not been initialized. */ public static function enterContext(array $data, array $options = []): ContextScope { From e23f1fcddec304ef12635ee8f5b7b058cb1a5cbe Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 14:37:49 +0100 Subject: [PATCH 26/32] feat(laravel): isolate direct child command calls with opt-in trait --- packages/laravel/README.md | 33 ++- .../src/Concerns/IsolatesCommandContext.php | 18 ++ packages/laravel/tests/CommandContextTest.php | 239 ++++++++++++++++++ .../laravel/tests/Fixtures/ScopedCommand.php | 23 ++ 4 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 packages/laravel/src/Concerns/IsolatesCommandContext.php create mode 100644 packages/laravel/tests/CommandContextTest.php create mode 100644 packages/laravel/tests/Fixtures/ScopedCommand.php diff --git a/packages/laravel/README.md b/packages/laravel/README.md index cff4a8cf..405b7eca 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -111,11 +111,34 @@ nested `Artisan::call()` executions restore their caller in `finally`, while the command stays active through kernel termination. 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()` execution bypasses those kernel -boundaries and does **not** automatically restore context. Wrap such calls in an -explicit `PostHog::withContext([], fn () => $this->call(...))` scope when the child -can mutate analytics context; automatic direct child-command isolation remains -unsupported. +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. diff --git a/packages/laravel/src/Concerns/IsolatesCommandContext.php b/packages/laravel/src/Concerns/IsolatesCommandContext.php new file mode 100644 index 00000000..cabdde6e --- /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/tests/CommandContextTest.php b/packages/laravel/tests/CommandContextTest.php new file mode 100644 index 00000000..f9c9847c --- /dev/null +++ b/packages/laravel/tests/CommandContextTest.php @@ -0,0 +1,239 @@ +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']); + } + + 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/ScopedCommand.php b/packages/laravel/tests/Fixtures/ScopedCommand.php new file mode 100644 index 00000000..317c260f --- /dev/null +++ b/packages/laravel/tests/Fixtures/ScopedCommand.php @@ -0,0 +1,23 @@ +signature = $signature; + parent::__construct(); + } + + public function handle(): int + { + return ($this->action)($this); + } +} From 3321cfbb41b1e62db7a9890c95f51eefc162867a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 14:49:38 +0100 Subject: [PATCH 27/32] fix(laravel): clear Pennant cache across direct command scopes --- packages/laravel/PENNANT.md | 5 ++- packages/laravel/src/ConsoleLifecycle.php | 2 + packages/laravel/tests/PennantTest.php | 53 ++++++++++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/laravel/PENNANT.md b/packages/laravel/PENNANT.md index 29905103..5ddca119 100644 --- a/packages/laravel/PENNANT.md +++ b/packages/laravel/PENNANT.md @@ -64,7 +64,10 @@ The SDK reuses snapshots for identical complete evaluation inputs. Pennant also has its own feature/scope cache. The integration clears PostHog stores around HTTP, queue and command boundaries, including failed jobs. Pennant's own Octane lifecycle integration remains active, and the driver resolves the current application rather -than retaining a request manager in a singleton. +than retaining a request manager in a singleton. Calling commands that opt into +`IsolatesCommandContext` also clear Pennant's cache on direct child-call entry and +after restoring the caller, including failures. Uninstrumented direct calls are +not intercepted; explicit context scopes alone do not clear Pennant's cache. If targeting properties/groups change for the **same scope within one request**, call `Feature::flushCache()` before another Pennant read. Alternatively use native diff --git a/packages/laravel/src/ConsoleLifecycle.php b/packages/laravel/src/ConsoleLifecycle.php index 2e07b629..3c15de55 100644 --- a/packages/laravel/src/ConsoleLifecycle.php +++ b/packages/laravel/src/ConsoleLifecycle.php @@ -65,6 +65,7 @@ private function enter(): void $data = $context->snapshot(); $context->enter(); $context->begin($data); + Features\PennantCache::flush(); } private function remember(Throwable $exception): void @@ -82,6 +83,7 @@ private function leave(): void } catch (Throwable) { } finally { $this->app->make(Context::class)->leave(); + Features\PennantCache::flush(); } } } diff --git a/packages/laravel/tests/PennantTest.php b/packages/laravel/tests/PennantTest.php index a22dedfc..24aa2a4f 100644 --- a/packages/laravel/tests/PennantTest.php +++ b/packages/laravel/tests/PennantTest.php @@ -2,6 +2,8 @@ namespace PostHog\Laravel\Tests; +use Illuminate\Console\Command; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Route; use Laravel\Pennant\Contracts\FeatureScopeable; @@ -20,6 +22,7 @@ use PostHog\Laravel\Tests\Fixtures\CaptureJob; use PostHog\Laravel\Tests\Fixtures\FailingCaptureJob; use PostHog\Laravel\Tests\Fixtures\PennantEndEventProvider; +use PostHog\Laravel\Tests\Fixtures\ScopedCommand; use RuntimeException; class PennantTest extends TestCase @@ -167,7 +170,7 @@ public function testDisabledBulkReadsAreEmptyAndDoNotPerformIo(): void $this->assertFalse(Feature::for('explicit-b')->value('unknown')); } - public static function queueCallers(): array + public static function scopedCallers(): array { return [ 'anonymous caller, success' => [null, false], @@ -177,7 +180,7 @@ public static function queueCallers(): array ]; } - #[DataProvider('queueCallers')] + #[DataProvider('scopedCallers')] public function testQueueCompletionConsumersCannotPolluteTheRestoredCallersCache( ?string $parentId, bool $fail, @@ -205,6 +208,52 @@ public function testQueueCompletionConsumersCannotPolluteTheRestoredCallersCache } } + #[DataProvider('scopedCallers')] + public function testDirectCommandCallsClearPennantCacheOnEntryAndAfterCallerRestoration( + ?string $parentId, + bool $fail, + ): void { + $events = []; + $this->sdk($parentId === null ? 1 : 2, $events); + $context = $this->app->make(Context::class); + $observed = []; + Artisan::command('posthog:child', function () use ($context, $fail, &$observed) { + $context->begin(['distinctId' => 'child-owner']); + $observed['child'] = Feature::value('layout'); + if ($fail) { + throw new RuntimeException('child failed'); + } + return 0; + }); + Artisan::registerCommand(new ScopedCommand('posthog:parent', function (Command $command) use ( + $context, + $parentId, + &$observed, + ) { + $context->begin($parentId === null ? [] : ['distinctId' => $parentId]); + $observed['before'] = Feature::value('layout'); + $observed['beforeSnapshot'] = $this->app->make(PostHog::class)->evaluateFlags(); + $observed['exception'] = null; + try { + $command->call('posthog:child'); + } catch (RuntimeException $exception) { + $observed['exception'] = $exception->getMessage(); + } + $observed['after'] = Feature::value('layout'); + $observed['afterSnapshot'] = $this->app->make(PostHog::class)->evaluateFlags(); + return 0; + })); + + $this->assertSame(0, Artisan::call('posthog:parent')); + $this->assertSame($parentId ?? false, $observed['before']); + $this->assertSame('child-owner', $observed['child']); + $this->assertSame($parentId ?? false, $observed['after']); + $this->assertSame($fail ? 'child failed' : null, $observed['exception']); + if ($parentId !== null) { + $this->assertSame($observed['beforeSnapshot'], $observed['afterSnapshot']); + } + } + public function testAnonymousPennantScopeDoesNotCacheAnotherRequestsIdentity(): void { $events = []; From 858c20496e1df664ff275417875e05b34a8fca14 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 17:04:26 +0100 Subject: [PATCH 28/32] fix: address framework primitive review feedback --- lib/Client.php | 30 +++++++- lib/ContextScope.php | 6 ++ lib/PostHog.php | 7 +- test/FrameworkIntegrationTest.php | 117 ++++++++++++++++++++++++++++++ test/PostHogTest.php | 20 +++++ 5 files changed, 176 insertions(+), 4 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index e08e7089..c81eb6df 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -663,8 +663,11 @@ private function shouldSendMinimalFlagCalledEvent(array $message): bool * @param \Throwable|string $exception The exception to capture or a plain string message * @param string|null $distinctId User ID; a random UUID is used when omitted (no person profile created) * @param array $additionalProperties Extra properties merged into the event - * @param array{handled?: bool, mechanism?: string} $options Framework boundary metadata. + * @param array{handled?: bool, mechanism?: string, source?: string} $options Framework boundary metadata. * Manual capture defaults to handled; integrations may explicitly mark a failed boundary. + * Mechanism names the semantic category (e.g. middleware); source names the concrete hook + * (e.g. laravel.middleware). They are independent. A non-empty string source is emitted as + * $exception_source; otherwise it is omitted. Property bags cannot supply this reserved field. * @return bool whether the capture call succeeded */ public function captureException( @@ -683,6 +686,8 @@ public function captureException( $mechanism = $options['mechanism'] ?? null; $handled = $options['handled'] ?? null; + $source = $options['source'] ?? null; + $source = is_string($source) && trim($source) !== '' ? $source : null; $exceptionList = ExceptionPayloadBuilder::overridePrimaryMechanism($exceptionList, [ 'type' => is_string($mechanism) && trim($mechanism) !== '' ? $mechanism : 'generic', 'handled' => is_bool($handled) ? $handled : true, @@ -700,6 +705,10 @@ public function captureException( '$exception_handled' => ExceptionPayloadBuilder::getPrimaryHandled($exceptionList), ] ); + unset($properties['$exception_source']); + if ($source !== null) { + $properties['$exception_source'] = $source; + } $message = [ 'event' => '$exception', @@ -710,6 +719,18 @@ public function captureException( $message['distinctId'] = $distinctId; } + $context = $this->getContext(); + if ($source === null && array_key_exists('$exception_source', $context['properties'] ?? [])) { + unset($context['properties']['$exception_source']); + // Filter ambient provenance without re-enriching the caller's active span. + return RequestContext::withContext( + $context, + fn () => $this->capture($message), + ['fresh' => true], + $this->contextKey() + ); + } + return $this->capture($message); } @@ -722,10 +743,12 @@ public function captureException( public function groupIdentify(array $message): bool { if (empty($message['groupType'])) { - throw new Exception('PostHog::groupIdentify() expects a groupType'); + $this->logWarning('groupIdentify() expects a groupType'); + return false; } if (empty($message['groupKey'])) { - throw new Exception('PostHog::groupIdentify() expects a groupKey'); + $this->logWarning('groupIdentify() expects a groupKey'); + return false; } $distinctId = "\${$message['groupType']}_{$message['groupKey']}"; @@ -2475,6 +2498,7 @@ public function withContext(array $data, callable $fn, array $options = []): mix * * Use this only when the work starts and ends in separate callbacks, such as framework * lifecycle events or before/after middleware hooks; otherwise prefer withContext(). + * Retain the returned scope: discarding it immediately closes the context. * Close the scope on every exit path, including failures. An unclosed scope leaks its context * into later work until the scope is garbage-collected. Closing a scope is idempotent. * diff --git a/lib/ContextScope.php b/lib/ContextScope.php index c6e41901..f6ed7a57 100644 --- a/lib/ContextScope.php +++ b/lib/ContextScope.php @@ -4,6 +4,11 @@ use Closure; +/** + * Owns an entered context until close() or destruction. + * + * Retain the object returned by enterContext(): discarding it immediately closes the scope. + */ final class ContextScope { /** @internal Obtain scopes through Client::enterContext(). */ @@ -11,6 +16,7 @@ public function __construct(private ?Closure $onClose) { } + /** Release this scope once; repeated calls are harmless and younger scopes remain active. */ public function close(): void { $close = $this->onClose; diff --git a/lib/PostHog.php b/lib/PostHog.php index 017f8413..2148f55c 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -124,7 +124,9 @@ public static function init( * @param \Throwable|string $exception The exception to capture or a plain string message. * @param string|null $distinctId User ID; a random UUID is used when omitted (no person profile created). * @param array $additionalProperties Extra properties merged into the event. - * @param array{handled?: bool, mechanism?: string} $options Framework boundary metadata. + * @param array{handled?: bool, mechanism?: string, source?: string} $options Framework boundary metadata. + * Source is the concrete integration hook (e.g. laravel.middleware), not a mechanism category. + * Only a non-empty string source is emitted; property bags cannot supply $exception_source. * @return bool Whether the capture call succeeded. * @throws Exception */ @@ -217,6 +219,8 @@ public static function identify(array $message) public static function groupIdentify(array $message) { self::checkClient(); + self::assert(!empty($message['groupType']), 'PostHog::groupIdentify() expects a groupType'); + self::assert(!empty($message['groupKey']), 'PostHog::groupIdentify() expects a groupKey'); return self::$client->groupIdentify($message); } @@ -497,6 +501,7 @@ public static function withContext(array $data, callable $fn, array $options = [ * * Use this only when the work starts and ends in separate callbacks, such as framework * lifecycle events or before/after middleware hooks; otherwise prefer withContext(). + * Retain the returned scope: discarding it immediately closes the context. * Close the scope on every exit path, including failures. An unclosed scope leaks its context * into later work until the scope is garbage-collected. Closing a scope is idempotent. * diff --git a/test/FrameworkIntegrationTest.php b/test/FrameworkIntegrationTest.php index 0813818a..b66ecbd0 100644 --- a/test/FrameworkIntegrationTest.php +++ b/test/FrameworkIntegrationTest.php @@ -50,6 +50,33 @@ function () use ($client) { self::assertArrayNotHasKey('secret', $event['properties']); } + public static function invalidGroupMessages(): array + { + return [ + 'missing identity' => [[], 'groupType'], + 'missing type' => [['groupKey' => 'company'], 'groupType'], + 'empty type' => [['groupType' => '', 'groupKey' => 'company'], 'groupType'], + 'missing key' => [['groupType' => 'company'], 'groupKey'], + 'empty key' => [['groupType' => 'company', 'groupKey' => ''], 'groupKey'], + 'null key' => [['groupType' => 'company', 'groupKey' => null], 'groupKey'], + ]; + } + + #[DataProvider('invalidGroupMessages')] + public function testInvalidInstanceGroupIdentityWarnsAndDropsWithoutThrowing(array $message, string $field): void + { + global $errorMessages; + $client = $this->client(); + $errorMessages = []; + self::assertFalse($client->groupIdentify($message)); + self::assertSame( + ['[PostHog][Client] groupIdentify() expects a ' . $field], + $errorMessages + ); + self::assertTrue($client->capture(['event' => 'still usable', 'distinctId' => 'user'])); + self::assertSame(['still usable'], array_column($this->events($client), 'event')); + } + public function testDetachedScopesRestoreOuterContextAndCloseOnlyOnce(): void { $client = $this->client(); @@ -100,6 +127,16 @@ public function testScopeDestructionClosesItsContext(): void self::assertNull($client->getContext()); } + public function testDiscardingAnEnteredScopeImmediatelyRestoresItsParent(): void + { + $client = $this->client(); + $parent = $client->enterContext(['distinctId' => 'parent']); + $client->enterContext(['distinctId' => 'discarded']); + self::assertSame('parent', $client->getContext()['distinctId']); + $parent->close(); + self::assertNull($client->getContext()); + } + public function testAbandonedFiberRunsContextCleanupWithoutCyclicCollection(): void { $client = $this->client(); @@ -147,6 +184,84 @@ public function testDetachedScopeDoesNotLeakIntoAReplacementFiber(): void } } + public static function exceptionSourceCases(): array + { + $cases = [ + 'manual capture' => [[], null, 'generic', true], + 'framework boundary' => [[ + 'source' => 'laravel.middleware', 'mechanism' => 'middleware', 'handled' => false, + ], 'laravel.middleware', 'middleware', false], + 'source does not imply mechanism' => [[ + 'source' => 'laravel.queue_job', + ], 'laravel.queue_job', 'generic', true], + 'new integration' => [[ + 'source' => 'custom_framework.worker', 'mechanism' => 'task', 'handled' => false, + ], 'custom_framework.worker', 'task', false], + ]; + foreach ( + [ + 'null' => null, + 'empty' => '', + 'whitespace' => " \t", + 'number' => 123, + 'array' => [], + 'bool' => false, + ] as $name => $source + ) { + $cases[$name] = [[ + 'source' => $source, 'mechanism' => 'task', 'handled' => false, + ], null, 'task', false]; + } + return $cases; + } + + #[DataProvider('exceptionSourceCases')] + public function testExceptionSourceUsesOnlyTypedMetadataAndPreservesCallerContext( + array $options, + ?string $expectedSource, + string $expectedMechanism, + bool $expectedHandled + ): void { + $seen = []; + $client = $this->client(['before_send' => function ($event) use (&$seen) { + $seen[] = $event; + return $event; + }]); + $client->withContext([ + 'distinctId' => 'request-user', 'sessionId' => 'request-session', + 'properties' => ['$exception_source' => 'spoofed.context', 'contextOnly' => true], + ], function () use ($client, $options) { + $before = $client->getContext(); + $exception = new RuntimeException('outer', previous: new RuntimeException('root')); + self::assertTrue($client->captureException( + $exception, + null, + ['$exception_source' => 'spoofed.properties', 'custom' => 'kept', 'source' => 'ordinary property'], + $options + )); + self::assertSame($before, $client->getContext()); + }); + $event = $this->events($client)[0]; + $properties = $event['properties']; + foreach ([$properties, $seen[0]['properties']] as $captured) { + if ($expectedSource === null) { + self::assertArrayNotHasKey('$exception_source', $captured); + } else { + self::assertSame($expectedSource, $captured['$exception_source']); + } + } + self::assertSame('request-user', $event['distinct_id']); + self::assertSame('request-session', $properties['$session_id']); + self::assertTrue($properties['contextOnly']); + self::assertSame('kept', $properties['custom']); + self::assertSame('ordinary property', $properties['source']); + self::assertSame($expectedMechanism, $properties['$exception_list'][0]['mechanism']['type']); + self::assertSame($expectedHandled, $properties['$exception_list'][0]['mechanism']['handled']); + self::assertArrayNotHasKey('source', $properties['$exception_list'][0]['mechanism']); + self::assertSame('root', $properties['$exception_list'][1]['value']); + self::assertNull($client->getContext()); + } + public static function exceptionMetadataCases(): array { return [ @@ -233,6 +348,8 @@ public function testIntegrationLibraryAndReleaseApplyAcrossEventTypes(): void self::assertSame('0.1.0', $event['properties']['$lib_version']); self::assertSame('release', $event['properties']['$release_id']); } + self::assertSame('$exception', $events[4]['event']); + self::assertArrayNotHasKey('$exception_source', $events[4]['properties']); } public function testIntegrationLibraryIsUsedForFlagHttpRequests(): void diff --git a/test/PostHogTest.php b/test/PostHogTest.php index ce60ecaa..202222da 100644 --- a/test/PostHogTest.php +++ b/test/PostHogTest.php @@ -1575,6 +1575,26 @@ public function testGroupIdentify(): void ); } + public function testCaptureExceptionForwardsTypedIntegrationSourceWithoutAmbientContext(): void + { + self::assertTrue(PostHog::captureException(new RuntimeException('boundary'), 'user', [ + '$exception_source' => 'spoofed.properties', + ], ['mechanism' => 'middleware', 'source' => 'laravel.middleware', 'handled' => false])); + self::assertTrue(PostHog::flush()); + $properties = $this->firstBatchEvent()['properties']; + self::assertSame('laravel.middleware', $properties['$exception_source']); + self::assertSame('middleware', $properties['$exception_list'][0]['mechanism']['type']); + self::assertFalse($properties['$exception_list'][0]['mechanism']['handled']); + self::assertArrayNotHasKey('source', $properties['$exception_list'][0]['mechanism']); + } + + public function testGroupIdentifyKeyValidation(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('PostHog::groupIdentify() expects a groupKey'); + PostHog::groupIdentify(['groupType' => 'company']); + } + public function testGroupIdentifyValidation(): void { $this->expectException(Exception::class); From 370eb1a9c063f2f4f33a11fa783fd732429eb20d Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 30 Oct 2026 09:44:23 +0100 Subject: [PATCH 29/32] chore: drop changeset already released in posthog-php 4.20.0 --- .changeset/young-onions-hide.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/young-onions-hide.md diff --git a/.changeset/young-onions-hide.md b/.changeset/young-onions-hide.md deleted file mode 100644 index dd1352a2..00000000 --- a/.changeset/young-onions-hide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"posthog-php": minor ---- - -Add instance group identify, lifecycle context scopes, and framework exception metadata. From fbd861a4e910d7e5b289c1ed9051962826b2f250 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 2 Nov 2026 15:10:41 +0100 Subject: [PATCH 30/32] chore: remove stale Laravel runtime release intent --- .laravel-changes/413684c92c1a43b38f4ea69024be861c.json | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .laravel-changes/413684c92c1a43b38f4ea69024be861c.json diff --git a/.laravel-changes/413684c92c1a43b38f4ea69024be861c.json b/.laravel-changes/413684c92c1a43b38f4ea69024be861c.json deleted file mode 100644 index c17b3645..00000000 --- a/.laravel-changes/413684c92c1a43b38f4ea69024be861c.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "package": "posthog-laravel", - "bump": "initial", - "summary": "Add the Laravel package with request and worker context, exception reporting, flags, and test fakes." -} From 1f4138edcdf8a65438ea3eae7cb75c362f7d594e Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 2 Nov 2026 15:12:01 +0100 Subject: [PATCH 31/32] fix(laravel): keep Pennant implementation classes internal --- api/laravel-public-api.json | 285 ------------------ .../src/Features/PennantMiddleware.php | 1 + .../src/Features/PennantServiceProvider.php | 1 + .../laravel/src/Features/PostHogDriver.php | 1 + packages/laravel/tests/ApiSurfaceTest.php | 4 + 5 files changed, 7 insertions(+), 285 deletions(-) diff --git a/api/laravel-public-api.json b/api/laravel-public-api.json index 07a026f6..cab00333 100644 --- a/api/laravel-public-api.json +++ b/api/laravel-public-api.json @@ -129,291 +129,6 @@ } } }, - "PostHog\\Laravel\\Features\\PennantMiddleware": { - "type": "class", - "abstract": false, - "final": true, - "readonly": false, - "extends": null, - "implements": [], - "constants": [], - "properties": [], - "methods": { - "handle": { - "static": false, - "abstract": false, - "final": false, - "returnType": "mixed", - "parameters": [ - { - "name": "request", - "type": null, - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - }, - { - "name": "next", - "type": "Closure", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] - } - } - }, - "PostHog\\Laravel\\Features\\PennantServiceProvider": { - "type": "class", - "abstract": false, - "final": true, - "readonly": false, - "extends": "Illuminate\\Support\\ServiceProvider", - "implements": [], - "constants": [], - "properties": [], - "methods": { - "boot": { - "static": false, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [] - } - } - }, - "PostHog\\Laravel\\Features\\PostHogDriver": { - "type": "class", - "abstract": false, - "final": true, - "readonly": false, - "extends": null, - "implements": [ - "Laravel\\Pennant\\Contracts\\DefinesFeaturesExternally", - "Laravel\\Pennant\\Contracts\\Driver" - ], - "constants": [], - "properties": [], - "methods": { - "define": { - "static": false, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [ - { - "name": "feature", - "type": "string", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - }, - { - "name": "resolver", - "type": "callable", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] - }, - "defined": { - "static": false, - "abstract": false, - "final": false, - "returnType": "array", - "parameters": [] - }, - "definedFeaturesForScope": { - "static": false, - "abstract": false, - "final": false, - "returnType": "array", - "parameters": [ - { - "name": "scope", - "type": "mixed", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] - }, - "delete": { - "static": false, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [ - { - "name": "feature", - "type": "string", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - }, - { - "name": "scope", - "type": "mixed", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] - }, - "get": { - "static": false, - "abstract": false, - "final": false, - "returnType": "mixed", - "parameters": [ - { - "name": "feature", - "type": "string", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - }, - { - "name": "scope", - "type": "mixed", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] - }, - "getAll": { - "static": false, - "abstract": false, - "final": false, - "returnType": "array", - "parameters": [ - { - "name": "features", - "type": "array", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] - }, - "purge": { - "static": false, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [ - { - "name": "features", - "type": "?array", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] - }, - "set": { - "static": false, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [ - { - "name": "feature", - "type": "string", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - }, - { - "name": "scope", - "type": "mixed", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - }, - { - "name": "value", - "type": "mixed", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] - }, - "setForAllScopes": { - "static": false, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [ - { - "name": "feature", - "type": "string", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - }, - { - "name": "value", - "type": "mixed", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] - } - } - }, "PostHog\\Laravel\\FlagDefinitionCacheProvider": { "type": "class", "abstract": false, diff --git a/packages/laravel/src/Features/PennantMiddleware.php b/packages/laravel/src/Features/PennantMiddleware.php index ff6b7d6f..fdbac19f 100644 --- a/packages/laravel/src/Features/PennantMiddleware.php +++ b/packages/laravel/src/Features/PennantMiddleware.php @@ -4,6 +4,7 @@ use Closure; +/** @internal */ final class PennantMiddleware { public function handle($request, Closure $next): mixed diff --git a/packages/laravel/src/Features/PennantServiceProvider.php b/packages/laravel/src/Features/PennantServiceProvider.php index 94280172..de7b3370 100644 --- a/packages/laravel/src/Features/PennantServiceProvider.php +++ b/packages/laravel/src/Features/PennantServiceProvider.php @@ -6,6 +6,7 @@ use Illuminate\Support\ServiceProvider; use Laravel\Pennant\FeatureManager; +/** @internal */ final class PennantServiceProvider extends ServiceProvider { public function boot(): void diff --git a/packages/laravel/src/Features/PostHogDriver.php b/packages/laravel/src/Features/PostHogDriver.php index 3d133449..2ac05dc8 100644 --- a/packages/laravel/src/Features/PostHogDriver.php +++ b/packages/laravel/src/Features/PostHogDriver.php @@ -11,6 +11,7 @@ use LogicException; use PostHog\Laravel\PostHog; +/** @internal */ final class PostHogDriver implements Driver, DefinesFeaturesExternally { public function get(string $feature, mixed $scope): mixed diff --git a/packages/laravel/tests/ApiSurfaceTest.php b/packages/laravel/tests/ApiSurfaceTest.php index 153b0c5a..4decdb08 100644 --- a/packages/laravel/tests/ApiSurfaceTest.php +++ b/packages/laravel/tests/ApiSurfaceTest.php @@ -193,6 +193,10 @@ public function testPublicApiMatchesItsSnapshot(): void \PostHog\Laravel\Autocapture\ModelEvents::class, \PostHog\Laravel\Autocapture\Outcomes::class, \PostHog\Laravel\Autocapture\RequestSummary::class, + \PostHog\Laravel\Features\PennantCache::class, + \PostHog\Laravel\Features\PennantMiddleware::class, + \PostHog\Laravel\Features\PennantServiceProvider::class, + \PostHog\Laravel\Features\PostHogDriver::class, ] as $internal ) { $this->assertArrayNotHasKey($internal, $api['symbols']); From 0dca396407ff2e8c94da286e2d1aea0b719fe7c3 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 2 Nov 2026 15:13:26 +0100 Subject: [PATCH 32/32] chore: defer Laravel release intents --- .gitignore | 1 + .laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json | 5 ----- 2 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 .laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json diff --git a/.gitignore b/.gitignore index d4565274..56f19768 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ xdebug.log .DS_Store .env node_modules/ +/.laravel-changes/ diff --git a/.laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json b/.laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json deleted file mode 100644 index 830e270f..00000000 --- a/.laravel-changes/482cca29796b4cc3bf4b96f0b476e65d.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "package": "posthog-laravel", - "bump": "initial", - "summary": "Add an optional read-only Laravel Pennant driver for PostHog feature flags." -}