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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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 3b2ad5a8904fe7baa5d3e849dbce41bb3509d00a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:33:41 +0100 Subject: [PATCH 09/27] 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 10/27] 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 0e3243ee46def0be86f153169bc24d31876936d4 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 11:01:17 +0100 Subject: [PATCH 11/27] 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 12/27] 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 ecbc8cc73ff96ce71f7d485844b3c11e9143f177 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:29:35 +0100 Subject: [PATCH 13/27] 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 14/27] 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 8526b3285e78d06b33ae1895fa72f86d353b8f8c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 12:45:29 +0100 Subject: [PATCH 15/27] 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 16/27] 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 17/27] 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 18/27] 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 19/27] 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 20/27] 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 21/27] 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 22/27] 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 858c20496e1df664ff275417875e05b34a8fca14 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 17:04:26 +0100 Subject: [PATCH 23/27] 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 a1083bedf633dc7b3a0e8dce9795da818d9a0bc5 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 2 Nov 2026 12:43:19 +0100 Subject: [PATCH 24/27] chore: remove stale release intents from Laravel autocapture --- .changeset/young-onions-hide.md | 5 ----- .laravel-changes/413684c92c1a43b38f4ea69024be861c.json | 5 ----- 2 files changed, 10 deletions(-) delete mode 100644 .changeset/young-onions-hide.md delete mode 100644 .laravel-changes/413684c92c1a43b38f4ea69024be861c.json 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. 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 94174153b05c085ed2c72e11d34498e3adff85e4 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 2 Nov 2026 13:13:22 +0100 Subject: [PATCH 25/27] fix(laravel): keep collector plumbing internal and bypass disabled requests --- api/laravel-public-api.json | 186 ------------------ packages/laravel/AUTOCAPTURE.md | 4 + .../laravel/src/Autocapture/AuthEvents.php | 1 + .../laravel/src/Autocapture/DomainEvents.php | 1 + .../laravel/src/Autocapture/ModelEvents.php | 1 + .../src/Autocapture/RequestSummary.php | 4 + .../src/AutocaptureServiceProvider.php | 1 + packages/laravel/tests/ApiSurfaceTest.php | 14 ++ packages/laravel/tests/AutocaptureTest.php | 31 +++ 9 files changed, 57 insertions(+), 186 deletions(-) diff --git a/api/laravel-public-api.json b/api/laravel-public-api.json index 118f5a22..cab00333 100644 --- a/api/laravel-public-api.json +++ b/api/laravel-public-api.json @@ -2,192 +2,6 @@ "schemaVersion": 1, "namespace": "PostHog\\Laravel\\", "symbols": { - "PostHog\\Laravel\\AutocaptureServiceProvider": { - "type": "class", - "abstract": false, - "final": false, - "readonly": false, - "extends": "Illuminate\\Support\\ServiceProvider", - "implements": [], - "constants": [], - "properties": [], - "methods": { - "boot": { - "static": false, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [] - }, - "register": { - "static": false, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [] - } - } - }, - "PostHog\\Laravel\\Autocapture\\AuthEvents": { - "type": "class", - "abstract": false, - "final": false, - "readonly": false, - "extends": null, - "implements": [], - "constants": [], - "properties": [], - "methods": { - "collect": { - "static": false, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [ - { - "name": "name", - "type": "string", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - }, - { - "name": "event", - "type": "object", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] - } - } - }, - "PostHog\\Laravel\\Autocapture\\DomainEvents": { - "type": "class", - "abstract": false, - "final": false, - "readonly": false, - "extends": null, - "implements": [], - "constants": [], - "properties": [], - "methods": { - "collect": { - "static": false, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [ - { - "name": "event", - "type": "object", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - }, - { - "name": "mapping", - "type": "array", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] - } - } - }, - "PostHog\\Laravel\\Autocapture\\ModelEvents": { - "type": "class", - "abstract": false, - "final": false, - "readonly": false, - "extends": null, - "implements": [], - "constants": [], - "properties": [], - "methods": { - "collect": { - "static": false, - "abstract": false, - "final": false, - "returnType": "void", - "parameters": [ - { - "name": "operation", - "type": "string", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - }, - { - "name": "model", - "type": "Illuminate\\Database\\Eloquent\\Model", - "byReference": false, - "variadic": false, - "optional": false, - "default": null, - "defaultConstant": null, - "hasDefault": false - } - ] - } - } - }, - "PostHog\\Laravel\\Autocapture\\RequestSummary": { - "type": "class", - "abstract": false, - "final": false, - "readonly": false, - "extends": null, - "implements": [], - "constants": [], - "properties": [], - "methods": { - "handle": { - "static": false, - "abstract": false, - "final": false, - "returnType": "mixed", - "parameters": [ - { - "name": "request", - "type": "Illuminate\\Http\\Request", - "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\\Concerns\\IsolatesCommandContext": { "type": "trait", "abstract": false, diff --git a/packages/laravel/AUTOCAPTURE.md b/packages/laravel/AUTOCAPTURE.md index be3f625d..7f096cc1 100644 --- a/packages/laravel/AUTOCAPTURE.md +++ b/packages/laravel/AUTOCAPTURE.md @@ -6,6 +6,10 @@ 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. +The collector classes and their service provider are internal implementation details, +not supported extension points. The documented configuration, publish tag and +`@posthogFeature` Blade conditional are the supported integration surface. + ## Configuration Publish the optional configuration: diff --git a/packages/laravel/src/Autocapture/AuthEvents.php b/packages/laravel/src/Autocapture/AuthEvents.php index 0ba5300a..86de2ab5 100644 --- a/packages/laravel/src/Autocapture/AuthEvents.php +++ b/packages/laravel/src/Autocapture/AuthEvents.php @@ -5,6 +5,7 @@ use Illuminate\Container\Container; use Throwable; +/** @internal */ class AuthEvents { public function collect(string $name, object $event): void diff --git a/packages/laravel/src/Autocapture/DomainEvents.php b/packages/laravel/src/Autocapture/DomainEvents.php index e75405df..916d60a2 100644 --- a/packages/laravel/src/Autocapture/DomainEvents.php +++ b/packages/laravel/src/Autocapture/DomainEvents.php @@ -4,6 +4,7 @@ use Throwable; +/** @internal */ class DomainEvents { public function collect(object $event, array $mapping): void diff --git a/packages/laravel/src/Autocapture/ModelEvents.php b/packages/laravel/src/Autocapture/ModelEvents.php index ffb503ae..b11026ec 100644 --- a/packages/laravel/src/Autocapture/ModelEvents.php +++ b/packages/laravel/src/Autocapture/ModelEvents.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Throwable; +/** @internal */ class ModelEvents { public function collect(string $operation, Model $model): void diff --git a/packages/laravel/src/Autocapture/RequestSummary.php b/packages/laravel/src/Autocapture/RequestSummary.php index d8fe8ca7..162be4b1 100644 --- a/packages/laravel/src/Autocapture/RequestSummary.php +++ b/packages/laravel/src/Autocapture/RequestSummary.php @@ -9,10 +9,14 @@ use Illuminate\Support\Str; use Throwable; +/** @internal */ class RequestSummary { public function handle(Request $request, Closure $next): mixed { + if (!Collector::enabled('requests') && !Collector::enabled('pageviews')) { + return $next($request); + } $started = hrtime(true); $response = null; try { diff --git a/packages/laravel/src/AutocaptureServiceProvider.php b/packages/laravel/src/AutocaptureServiceProvider.php index a17392f5..3fd1ef3e 100644 --- a/packages/laravel/src/AutocaptureServiceProvider.php +++ b/packages/laravel/src/AutocaptureServiceProvider.php @@ -15,6 +15,7 @@ use PostHog\Laravel\Autocapture\RequestSummary; use Throwable; +/** @internal */ class AutocaptureServiceProvider extends ServiceProvider { public function register(): void diff --git a/packages/laravel/tests/ApiSurfaceTest.php b/packages/laravel/tests/ApiSurfaceTest.php index 39740513..153b0c5a 100644 --- a/packages/laravel/tests/ApiSurfaceTest.php +++ b/packages/laravel/tests/ApiSurfaceTest.php @@ -183,6 +183,20 @@ public function testPublicApiMatchesItsSnapshot(): void require_once $root . '/scripts/PublicApiSnapshot.php'; $builder = new ReflectionMethod(PublicApiSnapshot::class, 'buildPublicApi'); $api = $builder->invoke(null, dirname(__DIR__) . '/src', 'PostHog\\Laravel\\', true); + foreach ( + [ + \PostHog\Laravel\AutocaptureServiceProvider::class, + \PostHog\Laravel\Autocapture\AuthEvents::class, + \PostHog\Laravel\Autocapture\Collector::class, + \PostHog\Laravel\Autocapture\DeferredModelEvents::class, + \PostHog\Laravel\Autocapture\DomainEvents::class, + \PostHog\Laravel\Autocapture\ModelEvents::class, + \PostHog\Laravel\Autocapture\Outcomes::class, + \PostHog\Laravel\Autocapture\RequestSummary::class, + ] as $internal + ) { + $this->assertArrayNotHasKey($internal, $api['symbols']); + } $this->assertArrayNotHasKey(\PostHog\Laravel\ExceptionReporter::class, $api['symbols']); $this->assertArrayNotHasKey('fromRequest', $api['symbols'][Context::class]['methods']); $this->assertArrayHasKey('getFeatureFlagResult', $api['symbols'][PostHog::class]['methods']); diff --git a/packages/laravel/tests/AutocaptureTest.php b/packages/laravel/tests/AutocaptureTest.php index 9ffc518c..b19bd88e 100644 --- a/packages/laravel/tests/AutocaptureTest.php +++ b/packages/laravel/tests/AutocaptureTest.php @@ -99,6 +99,37 @@ public function testDefaultCollectorsAreOffAndProviderRegistrationIsIdempotent() $this->assertSame('off', trim(Blade::render("@posthogFeature('flag')\n on\n@else\n off\n@endposthogFeature"))); } + public function testDisabledRequestCollectionBypassesPostResponseChecks(): void + { + $request = Request::create('/off'); + $response = response('ok'); + $original = $this->app['config']; + $nextCalled = false; + $checksAfterNext = []; + $config = $this->createMock(\Illuminate\Config\Repository::class); + $config->expects($this->exactly(2))->method('get')->willReturnCallback( + function () use (&$nextCalled, &$checksAfterNext) { + $checksAfterNext[] = $nextCalled; + return false; + }, + ); + $this->app->instance('config', $config); + try { + $this->assertSame($response, (new RequestSummary())->handle( + $request, + function ($actual) use ($request, $response, &$nextCalled) { + $this->assertSame($request, $actual); + $nextCalled = true; + return $response; + }, + )); + } finally { + $this->app->instance('config', $original); + } + $this->assertTrue($nextCalled); + $this->assertSame([false, false], $checksAfterNext, 'Disabled collection must bypass post-response work.'); + } + public function testPublishedCollectorConfigAndNestedOverridesAreHonored(): void { $path = $this->app->configPath('posthog-autocapture.php'); From ea2bc2aa1daa47f02a78504425816a9feeea5286 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 2 Nov 2026 13:25:45 +0100 Subject: [PATCH 26/27] fix(laravel): index managed transactions before pruning model events --- .../src/Autocapture/DeferredModelEvents.php | 33 +++++++++---- .../Fixtures/CountedTransactionCollection.php | 29 ++++++++++++ packages/laravel/tests/ModelEventsTest.php | 47 +++++++++++++++++++ 3 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 packages/laravel/tests/Fixtures/CountedTransactionCollection.php diff --git a/packages/laravel/src/Autocapture/DeferredModelEvents.php b/packages/laravel/src/Autocapture/DeferredModelEvents.php index 018f821b..16a8f8f5 100644 --- a/packages/laravel/src/Autocapture/DeferredModelEvents.php +++ b/packages/laravel/src/Autocapture/DeferredModelEvents.php @@ -106,13 +106,12 @@ public function beginning(TransactionBeginning $event): void $root = $root->parent; } if ($connection->transactionLevel() === 1) { - $pending = $this->transactions->getPendingTransactions(); - $committed = $this->transactions->getCommittedTransactions(); + $managed = $this->managedTransactions(); foreach ($this->pending as $key => $entry) { $olderRoot = $entry['root']->get(); if ( $entry['connection']->get() === $connection && $olderRoot !== $root - && ($pending->containsStrict($olderRoot) || $committed->containsStrict($olderRoot)) + && $olderRoot !== null && isset($managed[$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. @@ -163,11 +162,25 @@ private function flush(DatabaseTransactionRecord $root): void } } + private function managedTransactions(): WeakMap + { + $managed = new WeakMap(); + foreach ( + [$this->transactions->getPendingTransactions(), $this->transactions->getCommittedTransactions()] as $set + ) { + foreach ($set as $record) { + $managed[$record] = true; + } + } + return $managed; + } + 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); + if ($this->pending === [] && count($this->records) === 0) { + return; + } + $managed = $this->managedTransactions(); foreach ($this->pending as $key => $entry) { $connection = $entry['connection']->get(); $record = $entry['transaction']->get(); @@ -176,9 +189,9 @@ private function prune(?DatabaseTransactionRecord $rolledBackRoot = null): void $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)) + || ($rolledBackRoot !== null && $root === $rolledBackRoot && !isset($managed[$record])) + || (!isset($managed[$record]) && isset($managed[$root])) + || ($connection->transactionLevel() === 0 && isset($managed[$record])) ) { $this->remove($key); } @@ -187,7 +200,7 @@ private function prune(?DatabaseTransactionRecord $rolledBackRoot = null): void } // Keep zero-payload registrations counted until Laravel releases their records. foreach ($this->records as $record => $count) { - if ($count === 0 && !$managed($record)) { + if ($count === 0 && !isset($managed[$record])) { unset($this->records[$record]); } } diff --git a/packages/laravel/tests/Fixtures/CountedTransactionCollection.php b/packages/laravel/tests/Fixtures/CountedTransactionCollection.php new file mode 100644 index 00000000..e2fb06af --- /dev/null +++ b/packages/laravel/tests/Fixtures/CountedTransactionCollection.php @@ -0,0 +1,29 @@ +items as $key => $record) { + $this->inspections++; + yield $key => $record; + } + } +} diff --git a/packages/laravel/tests/ModelEventsTest.php b/packages/laravel/tests/ModelEventsTest.php index 697c9907..54609772 100644 --- a/packages/laravel/tests/ModelEventsTest.php +++ b/packages/laravel/tests/ModelEventsTest.php @@ -2,8 +2,12 @@ namespace PostHog\Laravel\Tests; +use Illuminate\Database\DatabaseTransactionRecord; +use Illuminate\Database\DatabaseTransactionsManager; +use Illuminate\Database\Events\TransactionBeginning; use Illuminate\Database\Events\TransactionCommitting; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\SQLiteConnection; use Illuminate\Support\Facades\DB; use Orchestra\Testbench\TestCase; use PostHog\Laravel\Autocapture\DeferredModelEvents; @@ -11,6 +15,7 @@ use PostHog\Laravel\Facades\PostHog; use PostHog\Laravel\PostHogServiceProvider; use PostHog\Laravel\Tests\Fixtures\CollectedRecord; +use PostHog\Laravel\Tests\Fixtures\CountedTransactionCollection; use RuntimeException; class ModelEventsTest extends TestCase @@ -244,6 +249,48 @@ public function testDistinctTransactionRecordsCannotGrowCallbacksBeyondAdmission $this->assertUsage(0, 0); } + public function testTransactionMembershipWorkStaysLinearAtCapacity(): void + { + $root = new DatabaseTransactionRecord('a', 1); + $pending = new CountedTransactionCollection([$root]); + $committed = new CountedTransactionCollection(); + $transactions = $this->createStub(DatabaseTransactionsManager::class); + $transactions->method('getPendingTransactions')->willReturn($pending); + $transactions->method('getCommittedTransactions')->willReturn($committed); + $connection = new SQLiteConnection(new \PDO('sqlite::memory:'), '', '', ['name' => 'a']); + $connection->beginTransaction(); + $connection->beginTransaction(); + $buffer = new DeferredModelEvents($transactions); + $buffer->committing(new TransactionCommitting($connection)); + $this->assertSame(0, $pending->inspections + $committed->inspections); + for ($i = 0; $i < 1000; $i++) { + $pending->push(new DatabaseTransactionRecord('a', 2, $root)); + $buffer->defer($connection, 'model changed', [], ['distinctId' => 'actor']); + $committed->push($pending->pop()); + } + $this->assertSame(1000, $buffer->pendingUsage()['count']); + foreach (['overflow', 'commit', 'begin'] as $operation) { + $pending->inspections = $committed->inspections = 0; + if ($operation === 'overflow') { + $buffer->defer($connection, 'drop newest', [], ['distinctId' => 'actor']); + } elseif ($operation === 'commit') { + $buffer->committing(new TransactionCommitting($connection)); + } else { + $pending->push(new DatabaseTransactionRecord('a', 1)); + $connection->rollBack(0); + $connection->beginTransaction(); + $buffer->beginning(new TransactionBeginning($connection)); + } + $this->assertLessThanOrEqual( + 4 * ($pending->count() + $committed->count()), + $pending->inspections + $committed->inspections, + $operation . ' must not rescan transaction collections for each buffered event.', + ); + $this->assertSame($operation === 'begin' ? 0 : 1000, $buffer->pendingUsage()['count']); + } + PostHog::assertNothingCaptured(); + } + public function testConnectionResetsCannotAccumulateUnboundedOrphanedCallbacks(): void { $connection = DB::connection('a'); From 36ac51ec0a4a15452b6e227ec271e0ddacfe1a2b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 2 Nov 2026 14:39:50 +0100 Subject: [PATCH 27/27] fix(laravel): gate collectors and honor transactional test callbacks --- packages/laravel/AUTOCAPTURE.md | 13 ++ .../laravel/src/Autocapture/AuthEvents.php | 3 +- .../laravel/src/Autocapture/Collector.php | 34 ++++- .../src/Autocapture/DeferredModelEvents.php | 19 ++- .../laravel/src/Autocapture/DomainEvents.php | 6 +- .../laravel/src/Autocapture/ModelEvents.php | 3 +- packages/laravel/src/Autocapture/Outcomes.php | 3 +- .../src/Autocapture/RequestSummary.php | 4 +- .../src/AutocaptureServiceProvider.php | 4 +- .../tests/CollectorDiagnosticsTest.php | 139 ++++++++++++++++++ .../laravel/tests/CollectorDisabledTest.php | 108 ++++++++++++++ packages/laravel/tests/ModelEventsTest.php | 1 + .../tests/TransactionalModelEventsTest.php | 126 ++++++++++++++++ 13 files changed, 444 insertions(+), 19 deletions(-) create mode 100644 packages/laravel/tests/CollectorDiagnosticsTest.php create mode 100644 packages/laravel/tests/CollectorDisabledTest.php create mode 100644 packages/laravel/tests/TransactionalModelEventsTest.php diff --git a/packages/laravel/AUTOCAPTURE.md b/packages/laravel/AUTOCAPTURE.md index 7f096cc1..da0f0ab7 100644 --- a/packages/laravel/AUTOCAPTURE.md +++ b/packages/laravel/AUTOCAPTURE.md @@ -10,6 +10,14 @@ The collector classes and their service provider are internal implementation det not supported extension points. The documented configuration, publish tag and `@posthogFeature` Blade conditional are the supported integration surface. +Collectors skip context resolution and model buffering when PostHog is disabled or +its API key is empty. `PostHog::fake()` still enables collection for assertions. + +With `posthog.sdk.debug=true`, collector failures log at most once per collector +site per Laravel container scope through Laravel's logger. Diagnostics include +only the collector site and exception class, not exception messages or event data. +Logger failures never escape into the application. + ## Configuration Publish the optional configuration: @@ -141,6 +149,11 @@ savepoint rollback) discards the corresponding pending events. No model or reque object is retained. Outside a transaction, collection is immediate, even when a different connection has an open transaction. +In tests using Laravel's `DatabaseTransactions` or `RefreshDatabase`, collection +follows Laravel's after-commit rules: the test-wrapper transaction alone does not +delay capture, while application-owned nested transactions still defer until +commit and discard rolled-back changes. + 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 diff --git a/packages/laravel/src/Autocapture/AuthEvents.php b/packages/laravel/src/Autocapture/AuthEvents.php index 86de2ab5..f7eeaa0d 100644 --- a/packages/laravel/src/Autocapture/AuthEvents.php +++ b/packages/laravel/src/Autocapture/AuthEvents.php @@ -28,7 +28,8 @@ public function collect(string $name, object $event): void } $properties = isset($event->guard) && is_string($event->guard) ? ['guard' => $event->guard] : []; Collector::capture('laravel_auth_' . $name, $properties, $snapshot); - } catch (Throwable) { + } catch (Throwable $exception) { + Collector::logFailure(__METHOD__, $exception); } } } diff --git a/packages/laravel/src/Autocapture/Collector.php b/packages/laravel/src/Autocapture/Collector.php index 4f237f28..c0e5389d 100644 --- a/packages/laravel/src/Autocapture/Collector.php +++ b/packages/laravel/src/Autocapture/Collector.php @@ -10,9 +10,13 @@ /** @internal Collectors retain correlation IDs and groups, never arbitrary context payloads. */ class Collector { - public static function enabled(string $section): bool + private array $loggedFailures = []; + + public static function enabled(?string $section = null): bool { - return (bool) Container::getInstance()['config']->get('posthog.autocapture.' . $section . '.enabled', false); + $app = Container::getInstance(); + return ($section === null || $app['config']->get('posthog.autocapture.' . $section . '.enabled', false)) + && $app->make(PostHog::class)->enabled(); } public static function snapshot(): array @@ -27,6 +31,9 @@ public static function capture(string $event, array $properties, ?array $snapsho $context = null; try { $app = Container::getInstance(); + if (!self::enabled()) { + return; + } $snapshot ??= self::snapshot(); $id = $snapshot['distinctId'] ?? null; if (!is_scalar($id) || (string) $id === '') { @@ -39,13 +46,32 @@ public static function capture(string $event, array $properties, ?array $snapsho 'event' => $event, 'properties' => $properties, 'distinctId' => (string) $id, 'groups' => $snapshot['groups'] ?? [], ]); - } catch (Throwable) { - // Analytics must never replace an application result or its original exception. + } catch (Throwable $exception) { + self::logFailure(__METHOD__, $exception); } finally { $context?->leave(); } } + public static function logFailure(string $collector, Throwable $exception): void + { + try { + $app = Container::getInstance(); + if (!$app['config']->get('posthog.sdk.debug', false)) { + return; + } + $instance = $app->make(self::class); + if (isset($instance->loggedFailures[$collector])) { + return; + } + $instance->loggedFailures[$collector] = true; + $app->make('log')->debug('[PostHog] Automatic event collection failed.', [ + 'collector' => $collector, 'exception_class' => $exception::class, + ]); + } catch (Throwable) { + } + } + public static function jsonSafe(mixed $value, int $depth = 0): bool { if ($depth > 16 || is_object($value) || is_resource($value)) { diff --git a/packages/laravel/src/Autocapture/DeferredModelEvents.php b/packages/laravel/src/Autocapture/DeferredModelEvents.php index 16a8f8f5..13cbba58 100644 --- a/packages/laravel/src/Autocapture/DeferredModelEvents.php +++ b/packages/laravel/src/Autocapture/DeferredModelEvents.php @@ -39,6 +39,17 @@ public function __construct(private DatabaseTransactionsManager $transactions) public function defer(Connection $connection, string $event, array $properties, array $snapshot): void { $this->prune(); + // Connection::afterCommit forwards to a shared manager without a connection name. + $matches = fn ($record) => $record->connection === $connection->getName() + && $record->level === $connection->transactionLevel(); + $record = $this->transactions->callbackApplicableTransactions()->last($matches); + if ($record === null) { + // Laravel's testing manager excludes test-wrapper transactions from commit callbacks. + if ($this->transactions->getPendingTransactions()->contains($matches)) { + Collector::capture($event, $properties, $snapshot); + } + return; + } if (count($this->pending) >= self::MAX_EVENTS) { return; } @@ -46,14 +57,6 @@ public function defer(Connection $connection, string $event, array $properties, 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; diff --git a/packages/laravel/src/Autocapture/DomainEvents.php b/packages/laravel/src/Autocapture/DomainEvents.php index 916d60a2..6283ace4 100644 --- a/packages/laravel/src/Autocapture/DomainEvents.php +++ b/packages/laravel/src/Autocapture/DomainEvents.php @@ -10,6 +10,9 @@ class DomainEvents public function collect(object $event, array $mapping): void { try { + if (!Collector::enabled()) { + return; + } if (!isset($mapping['event']) || !is_string($mapping['event']) || $mapping['event'] === '') { return; } @@ -32,7 +35,8 @@ public function collect(object $event, array $mapping): void } } Collector::capture($mapping['event'], $properties); - } catch (Throwable) { + } catch (Throwable $exception) { + Collector::logFailure(__METHOD__, $exception); } } } diff --git a/packages/laravel/src/Autocapture/ModelEvents.php b/packages/laravel/src/Autocapture/ModelEvents.php index b11026ec..68246446 100644 --- a/packages/laravel/src/Autocapture/ModelEvents.php +++ b/packages/laravel/src/Autocapture/ModelEvents.php @@ -47,7 +47,8 @@ public function collect(string $operation, Model $model): void $properties, $snapshot, ); - } catch (Throwable) { + } catch (Throwable $exception) { + Collector::logFailure(__METHOD__, $exception); } } } diff --git a/packages/laravel/src/Autocapture/Outcomes.php b/packages/laravel/src/Autocapture/Outcomes.php index 7e0ea60d..2684d8dc 100644 --- a/packages/laravel/src/Autocapture/Outcomes.php +++ b/packages/laravel/src/Autocapture/Outcomes.php @@ -88,7 +88,8 @@ public function finish(string $section, object $key, callable $metadata): void 'jobs' => 'laravel_job', 'commands' => 'laravel_command', 'scheduler' => 'laravel_scheduled_task', ][$section]; Collector::capture($event, $properties); - } catch (Throwable) { + } catch (Throwable $exception) { + Collector::logFailure(__METHOD__, $exception); } } } diff --git a/packages/laravel/src/Autocapture/RequestSummary.php b/packages/laravel/src/Autocapture/RequestSummary.php index 162be4b1..72c33dae 100644 --- a/packages/laravel/src/Autocapture/RequestSummary.php +++ b/packages/laravel/src/Autocapture/RequestSummary.php @@ -24,8 +24,8 @@ public function handle(Request $request, Closure $next): mixed } finally { try { $this->collect($request, $response, $started); - } catch (Throwable) { - // Never mask an application failure, including a failing collector callback. + } catch (Throwable $exception) { + Collector::logFailure(__METHOD__, $exception); } } } diff --git a/packages/laravel/src/AutocaptureServiceProvider.php b/packages/laravel/src/AutocaptureServiceProvider.php index 3fd1ef3e..7e2312af 100644 --- a/packages/laravel/src/AutocaptureServiceProvider.php +++ b/packages/laravel/src/AutocaptureServiceProvider.php @@ -21,6 +21,7 @@ class AutocaptureServiceProvider extends ServiceProvider public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/autocapture.php', 'posthog-autocapture'); + $this->app->scoped(Collector::class); $config = $this->app['config']; $settings = $config->get('posthog-autocapture', []); foreach ($config->get('posthog.autocapture', []) as $section => $values) { @@ -77,7 +78,8 @@ public function boot(): void } try { return Container::getInstance()->make(PostHog::class)->evaluateFlags()->isEnabled($key); - } catch (Throwable) { + } catch (Throwable $exception) { + Collector::logFailure(self::class . '::posthogFeature', $exception); return false; } }); diff --git a/packages/laravel/tests/CollectorDiagnosticsTest.php b/packages/laravel/tests/CollectorDiagnosticsTest.php new file mode 100644 index 00000000..a2134ee7 --- /dev/null +++ b/packages/laravel/tests/CollectorDiagnosticsTest.php @@ -0,0 +1,139 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + foreach (['auth', 'models', 'jobs', 'requests', 'blade'] as $section) { + $app['config']->set('posthog.autocapture.' . $section . '.enabled', true); + } + } + + private function failingManager(TypeError $failure): PostHog + { + $manager = $this->getMockBuilder(PostHog::class) + ->setConstructorArgs([$this->app]) + ->onlyMethods(['capture', 'evaluateFlags']) + ->getMock(); + $manager->expects($this->atLeastOnce())->method('capture')->willThrowException($failure); + $manager->method('evaluateFlags')->willThrowException($failure); + $this->app->instance(PostHog::class, $manager); + return $manager; + } + + public static function diagnosticModes(): array + { + return [ + 'quiet by default' => [false, false], + 'debug' => [true, false], + 'broken logger' => [true, true], + ]; + } + + #[DataProvider('diagnosticModes')] + public function testCollectorFailuresAreDeduplicatedRedactedAndNeverEscape(bool $debug, bool $loggerThrows): void + { + $this->app['config']->set('posthog.sdk.debug', $debug); + $failure = new TypeError('private credentials and event data'); + $this->failingManager($failure); + $this->app->make(Context::class)->begin(['distinctId' => 'private actor']); + $logs = []; + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly($debug ? 7 : 0))->method('debug')->willReturnCallback( + function ($message, $context) use (&$logs, $loggerThrows) { + $logs[] = [$message, $context]; + $this->assertSame(['collector', 'exception_class'], array_keys($context)); + $this->assertSame(TypeError::class, $context['exception_class']); + if ($loggerThrows) { + throw new RuntimeException('logger failed'); + } + }, + ); + $this->app->instance('log', $logger); + $model = $this->createStub(CollectedRecord::class); + $model->method('getAttributes')->willThrowException($failure); + $this->app['config']->set('posthog.autocapture.models.allow', [$model::class => ['state']]); + $user = new class (['id' => 1]) extends GenericUser { + public function getAuthIdentifier() + { + throw new TypeError('private user data'); + } + }; + $request = Request::create('/private'); + $request->setRouteResolver(fn () => throw $failure); + $applicationFailure = new RuntimeException('original application failure'); + for ($i = 0; $i < 2; $i++) { + Collector::capture('private event', ['private' => 'data']); + (new AuthEvents())->collect('login', (object) ['user' => $user, 'guard' => 'web']); + (new DomainEvents())->collect(new \stdClass(), [ + 'event' => 'private event', + 'properties' => (function () use ($failure) { + throw $failure; + yield; + })(), + ]); + (new ModelEvents())->collect('created', $model); + $outcomes = new Outcomes(); + $job = new \stdClass(); + $outcomes->start('jobs', $job); + $outcomes->finish('jobs', $job, fn () => throw $failure); + try { + (new RequestSummary())->handle($request, fn () => throw $applicationFailure); + $this->fail('The application exception must escape unchanged.'); + } catch (RuntimeException $caught) { + $this->assertSame($applicationFailure, $caught); + } + $this->assertSame('off', trim(Blade::render("@posthogFeature('flag') on @else off @endposthogFeature"))); + } + $this->assertCount($debug ? 7 : 0, $logs); + $this->assertStringNotContainsString('private', json_encode($logs)); + if ($debug) { + $this->assertCount(7, array_unique(array_column(array_column($logs, 1), 'collector'))); + } + $this->assertSame('private actor', $this->app->make(Context::class)->data()['distinctId']); + } + + public function testDiagnosticDeduplicationResetsWithTheApplicationScope(): void + { + $this->app['config']->set('posthog.sdk.debug', true); + $manager = $this->failingManager(new TypeError('private')); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(2))->method('debug'); + $this->app->instance('log', $logger); + for ($scope = 0; $scope < 2; $scope++) { + for ($i = 0; $i < 2; $i++) { + Collector::capture('event', [], ['distinctId' => 'actor']); + } + $this->app->forgetScopedInstances(); + $this->app->instance(PostHog::class, $manager); + } + } +} diff --git a/packages/laravel/tests/CollectorDisabledTest.php b/packages/laravel/tests/CollectorDisabledTest.php new file mode 100644 index 00000000..dba70b5a --- /dev/null +++ b/packages/laravel/tests/CollectorDisabledTest.php @@ -0,0 +1,108 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', ['driver' => 'sqlite', 'database' => ':memory:']); + foreach (['requests', 'auth', 'models', 'jobs', 'blade'] as $section) { + $app['config']->set('posthog.autocapture.' . $section . '.enabled', true); + } + $app['config']->set('posthog.autocapture.models.allow', [CollectedRecord::class => ['state']]); + $app['config']->set('posthog.autocapture.domain_events', [OrderCompleted::class => [ + 'event' => 'order_completed', 'properties' => ['amount' => 'amount'], + ]]); + } + + public static function disabledConfigurations(): array + { + return [ + 'disabled' => [false, 'project-key'], + 'empty key' => [true, ''], + 'whitespace key' => [true, ' '], + ]; + } + + #[DataProvider('disabledConfigurations')] + public function testDisabledCollectorsDoNotResolveContextOrBufferModels(bool $enabled, string $key): void + { + $this->app['config']->set('posthog.enabled', $enabled); + $this->app['config']->set('posthog.api_key', $key); + $calls = ['identity' => 0, 'groups' => 0, 'user' => 0]; + foreach (['identity' => 'distinct_id', 'groups' => 'groups'] as $name => $option) { + $this->app->instance('collector.' . $name, function () use (&$calls, $name) { + $calls[$name]++; + return $name === 'groups' ? [] : null; + }); + $this->app['config']->set('posthog.' . $option, 'collector.' . $name); + } + $request = Request::create('/disabled'); + $request->setUserResolver(function () use (&$calls) { + $calls['user']++; + return new GenericUser(['id' => 'actor']); + }); + $this->app->make(Context::class)->fromRequest($request); + DB::getSchemaBuilder()->create('collected_records', function (Blueprint $table) { + $table->id(); + $table->string('state'); + $table->string('email'); + }); + DB::beginTransaction(); + try { + for ($i = 0; $i < 50; $i++) { + CollectedRecord::create(['state' => 'new', 'email' => 'private']); + } + $this->assertSame( + ['count' => 0, 'bytes' => 0], + $this->app->make(DeferredModelEvents::class)->pendingUsage(), + ); + $this->app['events']->dispatch(new OrderCompleted()); + $this->app['events']->dispatch(new \Illuminate\Auth\Events\Login( + 'web', + new GenericUser(['id' => 1]), + false, + )); + $response = response('ok'); + $this->assertSame($response, (new RequestSummary())->handle($request, fn () => $response)); + Collector::capture('direct collector call', []); + $this->assertSame(['identity' => 0, 'groups' => 0, 'user' => 0], $calls); + } finally { + DB::rollBack(); + } + } + + public function testFakeStillCollectsWithDisabledConfigurationAndNoKey(): void + { + $this->app['config']->set('posthog.enabled', false); + $this->app['config']->set('posthog.api_key', ''); + $fake = PostHog::fake(); + $this->app->make(Context::class)->begin(['distinctId' => 'actor']); + $this->app['events']->dispatch(new OrderCompleted()); + $fake->assertCaptured('order_completed'); + } +} diff --git a/packages/laravel/tests/ModelEventsTest.php b/packages/laravel/tests/ModelEventsTest.php index 54609772..efa5b7a6 100644 --- a/packages/laravel/tests/ModelEventsTest.php +++ b/packages/laravel/tests/ModelEventsTest.php @@ -256,6 +256,7 @@ public function testTransactionMembershipWorkStaysLinearAtCapacity(): void $committed = new CountedTransactionCollection(); $transactions = $this->createStub(DatabaseTransactionsManager::class); $transactions->method('getPendingTransactions')->willReturn($pending); + $transactions->method('callbackApplicableTransactions')->willReturn($pending); $transactions->method('getCommittedTransactions')->willReturn($committed); $connection = new SQLiteConnection(new \PDO('sqlite::memory:'), '', '', ['name' => 'a']); $connection->beginTransaction(); diff --git a/packages/laravel/tests/TransactionalModelEventsTest.php b/packages/laravel/tests/TransactionalModelEventsTest.php new file mode 100644 index 00000000..2a85c639 --- /dev/null +++ b/packages/laravel/tests/TransactionalModelEventsTest.php @@ -0,0 +1,126 @@ +set('posthog.api_key', 'project-key'); + $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(); + $this->app->make(Context::class)->begin(['distinctId' => 'actor']); + foreach (['a', 'b'] as $name) { + DB::connection($name)->getSchemaBuilder()->create('collected_records', function (Blueprint $table) { + $table->id(); + $table->string('state'); + $table->string('email'); + }); + } + } + + private function record(string $state, string $connection = 'a'): void + { + CollectedRecord::on($connection)->create(['state' => $state, 'email' => 'private']); + } + + private function states(): array + { + return array_map( + fn ($call) => $call['message']['properties']['attributes']['state'], + PostHog::getFacadeRoot()->recorded('capture'), + ); + } + + public function testWrapperOnlyWritesCaptureImmediatelyLikeAfterCommit(): void + { + $afterCommitRan = false; + DB::afterCommit(function () use (&$afterCommitRan) { + $afterCommitRan = true; + }); + $this->assertTrue($afterCommitRan); + $this->record('wrapper only'); + $this->assertSame(['wrapper only'], $this->states()); + $this->assertSame(['count' => 0, 'bytes' => 0], $this->app->make(DeferredModelEvents::class)->pendingUsage()); + } + + public function testApplicationTransactionsStillDeferAndRemainConnectionCorrect(): void + { + DB::connection('a')->beginTransaction(); + $this->record('a committed'); + $this->record('b wrapper only', 'b'); + $this->assertSame(['b wrapper only'], $this->states()); + DB::connection('b')->beginTransaction(); + $this->record('b rolled back', 'b'); + DB::connection('a')->commit(); + $this->assertSame(['b wrapper only', 'a committed'], $this->states()); + DB::connection('b')->rollBack(); + $this->assertSame(['b wrapper only', 'a committed'], $this->states()); + $this->assertSame(['count' => 0, 'bytes' => 0], $this->app->make(DeferredModelEvents::class)->pendingUsage()); + } + + public function testWrapperOnlyWritesDoNotWaitForAnotherConnectionsFullBuffer(): void + { + DB::connection('a')->beginTransaction(); + for ($i = 0; $i < 1000; $i++) { + $this->record('pending a'); + } + $this->assertSame(1000, $this->app->make(DeferredModelEvents::class)->pendingUsage()['count']); + $this->record('b wrapper only', 'b'); + $this->assertSame(['b wrapper only'], $this->states()); + DB::connection('a')->rollBack(); + $this->assertSame(['b wrapper only'], $this->states()); + $this->assertSame(['count' => 0, 'bytes' => 0], $this->app->make(DeferredModelEvents::class)->pendingUsage()); + } + + public function testNestedCommitAndRollbackFollowApplicationTransactionBoundaries(): void + { + DB::beginTransaction(); + $this->record('outer'); + DB::beginTransaction(); + $this->record('discarded child'); + DB::rollBack(); + DB::beginTransaction(); + $this->record('committed child'); + DB::commit(); + PostHog::assertNothingCaptured(); + DB::commit(); + $this->assertSame(['outer', 'committed child'], $this->states()); + DB::beginTransaction(); + $this->record('discarded outer'); + DB::rollBack(); + $this->record('wrapper again'); + $this->assertSame(['outer', 'committed child', 'wrapper again'], $this->states()); + $this->assertSame(['count' => 0, 'bytes' => 0], $this->app->make(DeferredModelEvents::class)->pendingUsage()); + } +}