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/21] 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/21] 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/21] 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/21] 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 53cdbc56f11457a461dfe23987d2105abd317459 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:00:48 +0100 Subject: [PATCH 05/21] 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 06/21] 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 3b2ad5a8904fe7baa5d3e849dbce41bb3509d00a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 10:33:41 +0100 Subject: [PATCH 07/21] 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 8773c4377aace83707198b15581925138b740d31 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 11:42:44 +0100 Subject: [PATCH 08/21] 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 09/21] 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 10/21] 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 11/21] 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 a33384afc1a1babfb8c17007f48ca8a56c0db05c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 13:05:06 +0100 Subject: [PATCH 12/21] 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 13/21] 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 dc28ca9bb1e36de1c377017243391d41aac246a2 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 13:23:08 +0100 Subject: [PATCH 14/21] 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 15/21] 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 16/21] 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 0ca6fcf16b2cb7ffc41cb1fb8c770380562879b2 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 30 Oct 2026 12:45:30 +0100 Subject: [PATCH 17/21] fix(laravel): tighten public APIs and preserve exception provenance --- .github/workflows/php.yml | 11 + .../413684c92c1a43b38f4ea69024be861c.json | 5 - CONTRIBUTING.md | 9 + api/laravel-public-api.json | 1344 +++++++++++++++++ composer.json | 4 +- packages/laravel/LICENSE.md | 24 +- packages/laravel/README.md | 19 +- .../laravel/src/Commands/DoctorCommand.php | 3 +- .../laravel/src/Commands/InstallCommand.php | 3 +- packages/laravel/src/Commands/TestCommand.php | 3 +- packages/laravel/src/ConsoleLifecycle.php | 2 +- packages/laravel/src/Context.php | 2 + packages/laravel/src/ExceptionReporter.php | 19 +- packages/laravel/src/LaravelClient.php | 5 +- .../laravel/src/Middleware/RequestContext.php | 5 +- packages/laravel/src/PostHog.php | 121 +- .../laravel/src/PostHogServiceProvider.php | 14 +- packages/laravel/src/Testing/PostHogFake.php | 14 - packages/laravel/tests/ApiSurfaceTest.php | 154 ++ packages/laravel/tests/CommandContextTest.php | 1 + packages/laravel/tests/OctaneTest.php | 4 +- packages/laravel/tests/RuntimeContextTest.php | 82 + packages/laravel/tests/RuntimeTest.php | 24 +- scripts/PublicApiSnapshot.php | 63 +- test/PublicApiSnapshotTest.php | 41 + 25 files changed, 1858 insertions(+), 118 deletions(-) delete mode 100644 .laravel-changes/413684c92c1a43b38f4ea69024be861c.json create mode 100644 api/laravel-public-api.json mode change 100644 => 120000 packages/laravel/LICENSE.md create mode 100644 packages/laravel/tests/ApiSurfaceTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 4fb1eff3..be882bdb 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -14,6 +14,8 @@ on: - "phpcs.xml" - "phpunit.xml" - "api/public-api.json" + - "api/laravel-public-api.json" + - "packages/laravel/composer.json" - "scripts/**" - ".github/workflows/php.yml" @@ -70,6 +72,15 @@ jobs: - name: Check public API snapshot run: composer api:check + - name: Install Laravel dependencies + run: composer install --working-dir=packages/laravel --prefer-dist --no-progress + + - name: Check Laravel public API snapshot + run: composer api:check:laravel + + - name: Run Laravel tests + run: composer --working-dir=packages/laravel test + phpunit: needs: detect-markdown-only runs-on: ubuntu-latest diff --git a/.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." -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d22535a0..2601df64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,15 @@ This section is for external contributors. PostHog maintainers (members of the P `composer api:update` regenerates `api/public-api.json`, and CI runs `composer api:check` to catch an outdated snapshot. A diff in that file means your change touches public API. +The Laravel package has a separate snapshot in `api/laravel-public-api.json`. From the repository root: + +```bash +composer install --working-dir=packages/laravel --prefer-dist --no-progress +composer api:check:laravel +``` + +After agreeing on a Laravel API change, run `composer api:update:laravel` and inspect the generated diff. CI checks both snapshots. The Laravel snapshot excludes `@internal` classes and members; framework-required public methods do not automatically become supported customer APIs. Supported client forwarders are declared explicitly in the Laravel manager, so their signatures are tracked without exposing future core methods automatically. + ## Pull requests Please follow the existing project conventions and include tests when you change behavior. diff --git a/api/laravel-public-api.json b/api/laravel-public-api.json new file mode 100644 index 00000000..cab00333 --- /dev/null +++ b/api/laravel-public-api.json @@ -0,0 +1,1344 @@ +{ + "schemaVersion": 1, + "namespace": "PostHog\\Laravel\\", + "symbols": { + "PostHog\\Laravel\\Concerns\\IsolatesCommandContext": { + "type": "trait", + "abstract": false, + "final": false, + "readonly": false, + "extends": null, + "implements": [], + "constants": [], + "properties": [], + "methods": [] + }, + "PostHog\\Laravel\\Context": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": null, + "implements": [], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "app", + "type": "Illuminate\\Contracts\\Container\\Container", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "begin": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "data", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "data": { + "static": false, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [] + }, + "end": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + }, + "snapshot": { + "static": false, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [] + }, + "with": { + "static": false, + "abstract": false, + "final": false, + "returnType": "mixed", + "parameters": [ + { + "name": "data", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "callback", + "type": "callable", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + } + } + }, + "PostHog\\Laravel\\Facades\\PostHog": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": "Illuminate\\Support\\Facades\\Facade", + "implements": [], + "constants": [], + "properties": [], + "methods": { + "fake": { + "static": true, + "abstract": false, + "final": false, + "returnType": "PostHog\\Laravel\\Testing\\PostHogFake", + "parameters": [] + } + } + }, + "PostHog\\Laravel\\FlagDefinitionCacheProvider": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": null, + "implements": [ + "PostHog\\FlagDefinitionCacheProvider" + ], + "constants": [], + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "cache", + "type": "Illuminate\\Contracts\\Cache\\Repository", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "ttl", + "type": "int", + "byReference": false, + "variadic": false, + "optional": true, + "default": 300, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "refreshInterval", + "type": "int", + "byReference": false, + "variadic": false, + "optional": true, + "default": 30, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "getFlagDefinitions": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?array", + "parameters": [] + }, + "onFlagDefinitionsReceived": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "data", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "shouldFetchFlagDefinitions": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + }, + "shutdown": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + } + } + }, + "PostHog\\Laravel\\PostHog": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": null, + "implements": [], + "constants": { + "VERSION": { + "type": "string", + "value": "" + } + }, + "properties": [], + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "app", + "type": "Illuminate\\Contracts\\Container\\Container", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "alias": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [ + { + "name": "message", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "capture": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [ + { + "name": "event", + "type": "array|string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "properties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "captureException": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [ + { + "name": "exception", + "type": "Throwable|string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "properties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "client": { + "static": false, + "abstract": false, + "final": false, + "returnType": "PostHog\\Client", + "parameters": [] + }, + "contextFromHeaders": { + "static": false, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [ + { + "name": "headers", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "enabled": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + }, + "enterContext": { + "static": false, + "abstract": false, + "final": false, + "returnType": "PostHog\\ContextScope", + "parameters": [ + { + "name": "data", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "evaluateFlags": { + "static": false, + "abstract": false, + "final": false, + "returnType": "PostHog\\FeatureFlagEvaluations", + "parameters": [ + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "onlyEvaluateLocally", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "disableGeoip", + "type": "?bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "flagKeys", + "type": "?array", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "flush": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + }, + "getAllFlags": { + "static": false, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [ + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "onlyEvaluateLocally", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "getContext": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?array", + "parameters": [] + }, + "getFeatureFlag": { + "static": false, + "abstract": false, + "final": false, + "returnType": "string|bool|null", + "parameters": [ + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "onlyEvaluateLocally", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "sendFeatureFlagEvents", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": true, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "getFeatureFlagPayload": { + "static": false, + "abstract": false, + "final": false, + "returnType": "mixed", + "parameters": [ + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "getFeatureFlagResult": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?PostHog\\FeatureFlagResult", + "parameters": [ + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "onlyEvaluateLocally", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "sendFeatureFlagEvents", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": true, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "groupIdentify": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [ + { + "name": "message", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "identify": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [ + { + "name": "message", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "isFeatureEnabled": { + "static": false, + "abstract": false, + "final": false, + "returnType": "?bool", + "parameters": [ + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "onlyEvaluateLocally", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "sendFeatureFlagEvents", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": true, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "loadFlags": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + }, + "shutdown": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + }, + "withContext": { + "static": false, + "abstract": false, + "final": false, + "returnType": "mixed", + "parameters": [ + { + "name": "data", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "fn", + "type": "callable", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + } + } + }, + "PostHog\\Laravel\\PostHogServiceProvider": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": "Illuminate\\Support\\ServiceProvider", + "implements": [], + "constants": [], + "properties": [], + "methods": { + "boot": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + }, + "register": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + } + } + }, + "PostHog\\Laravel\\Testing\\PostHogFake": { + "type": "class", + "abstract": false, + "final": false, + "readonly": false, + "extends": "PostHog\\Laravel\\PostHog", + "implements": [], + "constants": [], + "properties": [], + "methods": { + "assertAliased": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "callback", + "type": "?callable", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "assertCaptured": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "event", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "callback", + "type": "?callable", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "assertCapturedCount": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "count", + "type": "int", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "assertExceptionCaptured": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "callback", + "type": "?callable", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "assertGroupIdentified": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "callback", + "type": "?callable", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "assertIdentified": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "callback", + "type": "?callable", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "assertNotCaptured": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [ + { + "name": "event", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "assertNothingCaptured": { + "static": false, + "abstract": false, + "final": false, + "returnType": "void", + "parameters": [] + }, + "captureException": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [ + { + "name": "exception", + "type": "Throwable|string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "properties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "options", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "client": { + "static": false, + "abstract": false, + "final": false, + "returnType": "PostHog\\Client", + "parameters": [] + }, + "enabled": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + }, + "evaluateFlags": { + "static": false, + "abstract": false, + "final": false, + "returnType": "PostHog\\FeatureFlagEvaluations", + "parameters": [ + { + "name": "distinctId", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groups", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "personProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "groupProperties", + "type": "array", + "byReference": false, + "variadic": false, + "optional": true, + "default": [], + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "onlyEvaluateLocally", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "disableGeoip", + "type": "?bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "flagKeys", + "type": "?array", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "flags": { + "static": false, + "abstract": false, + "final": false, + "returnType": "static", + "parameters": [ + { + "name": "snapshot", + "type": "PostHog\\FeatureFlagEvaluations", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "flush": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + }, + "recorded": { + "static": false, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [ + { + "name": "method", + "type": "?string", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "shutdown": { + "static": false, + "abstract": false, + "final": false, + "returnType": "bool", + "parameters": [] + } + } + } + } +} diff --git a/composer.json b/composer.json index 8f37263e..af79f0e8 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,9 @@ }, "scripts": { "api:check": "@php scripts/check-public-api.php", - "api:update": "@php scripts/check-public-api.php --update" + "api:update": "@php scripts/check-public-api.php --update", + "api:check:laravel": "@php scripts/check-public-api.php --laravel", + "api:update:laravel": "@php scripts/check-public-api.php --laravel --update" }, "bin": [ "bin/posthog" diff --git a/packages/laravel/LICENSE.md b/packages/laravel/LICENSE.md deleted file mode 100644 index 2c58e5d9..00000000 --- a/packages/laravel/LICENSE.md +++ /dev/null @@ -1,23 +0,0 @@ -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/LICENSE.md b/packages/laravel/LICENSE.md new file mode 120000 index 00000000..f0608a63 --- /dev/null +++ b/packages/laravel/LICENSE.md @@ -0,0 +1 @@ +../../LICENSE.md \ No newline at end of file diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 405b7eca..b1893ccc 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -177,6 +177,13 @@ emitted by an earlier framework listener cannot be retroactively relabeled by a later boundary event. Explicit manual capture is intentionally independent of automatic-report deduplication. +Automatic reports include `$exception_source` from the first known SDK boundary: +`laravel.middleware`, `laravel.queue_job`, `laravel.console_lifecycle`, or +`laravel.octane_worker_error`. Without a remembered boundary source, the registered +Laravel reportable callback supplies `laravel.reportable`. Sources are never inferred +from stack frames. Ordinary manual `captureException()` calls omit the source unless +an integration explicitly supplies it through the typed `source` option. + ## Feature flags ```php @@ -197,7 +204,11 @@ 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: +The manager also supports the core-compatible `getAllFlags()`, `getFeatureFlag()`, +`getFeatureFlagResult()`, `getFeatureFlagPayload()`, and `isFeatureEnabled()` methods +with current context, plus `loadFlags()` and `contextFromHeaders()`. These methods +are declared explicitly; new core methods are not exposed automatically. Use +`client()` for other lower-level PHP SDK operations: ```php $result = PostHog::getFeatureFlagResult('flag-key', 'account-123'); @@ -242,6 +253,12 @@ 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`. +The supported PHP surface consists of the manager, facade, documented `Context` +methods, `IsolatesCommandContext` trait, test fake, service provider registration, +and Laravel flag-definition cache provider. Classes and methods marked `@internal` +are framework plumbing, not supported customer APIs or extension points, even when +Laravel or other package classes require their methods to be public. + ## Diagnostics - `php artisan posthog:doctor`: local configuration validation, no network or keys in output. diff --git a/packages/laravel/src/Commands/DoctorCommand.php b/packages/laravel/src/Commands/DoctorCommand.php index f4981626..c9679492 100644 --- a/packages/laravel/src/Commands/DoctorCommand.php +++ b/packages/laravel/src/Commands/DoctorCommand.php @@ -5,7 +5,8 @@ use Illuminate\Console\Command; use PostHog\Laravel\PostHog; -class DoctorCommand extends Command +/** @internal */ +final class DoctorCommand extends Command { protected $signature = 'posthog:doctor'; protected $description = 'Check server-side PostHog configuration without sending traffic'; diff --git a/packages/laravel/src/Commands/InstallCommand.php b/packages/laravel/src/Commands/InstallCommand.php index 134b465e..9a03ea73 100644 --- a/packages/laravel/src/Commands/InstallCommand.php +++ b/packages/laravel/src/Commands/InstallCommand.php @@ -4,7 +4,8 @@ use Illuminate\Console\Command; -class InstallCommand extends Command +/** @internal */ +final class InstallCommand extends Command { protected $signature = 'posthog:install'; protected $description = 'Publish the PostHog configuration without changing application credentials'; diff --git a/packages/laravel/src/Commands/TestCommand.php b/packages/laravel/src/Commands/TestCommand.php index 9131b340..eaff621e 100644 --- a/packages/laravel/src/Commands/TestCommand.php +++ b/packages/laravel/src/Commands/TestCommand.php @@ -6,7 +6,8 @@ use Illuminate\Support\Str; use PostHog\Laravel\PostHog; -class TestCommand extends Command +/** @internal */ +final class TestCommand extends Command { protected $signature = 'posthog:test {--send : Explicitly allow a diagnostic event to be sent}'; protected $description = 'Send an opt-in diagnostic event without user data'; diff --git a/packages/laravel/src/ConsoleLifecycle.php b/packages/laravel/src/ConsoleLifecycle.php index 2e07b629..2eb792e6 100644 --- a/packages/laravel/src/ConsoleLifecycle.php +++ b/packages/laravel/src/ConsoleLifecycle.php @@ -70,7 +70,7 @@ private function enter(): void private function remember(Throwable $exception): void { try { - $this->app->make(ExceptionReporter::class)->remember($exception, 'runtime'); + $this->app->make(ExceptionReporter::class)->remember($exception, 'runtime', 'laravel.console_lifecycle'); } catch (Throwable) { } } diff --git a/packages/laravel/src/Context.php b/packages/laravel/src/Context.php index 5d8167f0..6baf50c1 100644 --- a/packages/laravel/src/Context.php +++ b/packages/laravel/src/Context.php @@ -145,6 +145,7 @@ private function restore(array $state): void [$this->context, $this->flags, $this->request, $this->headerIdentity, $this->resolved] = $state; } + /** @internal */ public function fromRequest(Request $request): void { $this->begin($this->app['config']->get('posthog.header_context', true) @@ -170,6 +171,7 @@ public function leave(): void $this->restore(array_pop($this->scopes)); } + /** @internal */ public function flags(string $key, callable $evaluate): FeatureFlagEvaluations { return $this->flags[$key] ??= $evaluate(); diff --git a/packages/laravel/src/ExceptionReporter.php b/packages/laravel/src/ExceptionReporter.php index 117c104d..ac5567c1 100644 --- a/packages/laravel/src/ExceptionReporter.php +++ b/packages/laravel/src/ExceptionReporter.php @@ -6,7 +6,8 @@ use Throwable; use WeakMap; -class ExceptionReporter +/** @internal */ +final class ExceptionReporter { private WeakMap $snapshots; private WeakMap $reported; @@ -22,11 +23,14 @@ public function rememberContext(Throwable $exception): void $this->snapshots[$exception] ??= ['data' => $this->app->make(Context::class)->snapshot()]; } - public function remember(Throwable $exception, string $mechanism): void + public function remember(Throwable $exception, string $mechanism, ?string $source = null): void { $this->rememberContext($exception); $snapshot = $this->snapshots[$exception]; $snapshot['options'] ??= ['handled' => false, 'mechanism' => $mechanism]; + if ($source !== null) { + $snapshot['options']['source'] ??= $source; + } $this->snapshots[$exception] = $snapshot; } @@ -52,7 +56,7 @@ private function reportOptions(): array return ['handled' => true, 'mechanism' => 'middleware']; } - public function report(Throwable $exception): void + public function report(Throwable $exception, ?string $source = null): void { if (isset($this->reported[$exception]) || !$this->app['config']->get('posthog.capture_exceptions', true)) { return; @@ -60,9 +64,16 @@ 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 ($source !== null) { + $options['source'] ??= $source; + } $manager = $this->app->make(PostHog::class); try { - $manager->reportException($exception, $snapshot['data'], $options); + $manager->withContext( + $snapshot['data'], + fn () => $manager->captureException($exception, options: $options), + ['fresh' => true], + ); $manager->flush(); } catch (Throwable) { // Reporting must never replace the application's original failure. diff --git a/packages/laravel/src/LaravelClient.php b/packages/laravel/src/LaravelClient.php index 3d66ad12..60d4b04c 100644 --- a/packages/laravel/src/LaravelClient.php +++ b/packages/laravel/src/LaravelClient.php @@ -7,12 +7,15 @@ /** @internal Reports Laravel library metadata and the cached-config release id. */ final class LaravelClient extends Client { + private readonly ?string $configuredReleaseId; + public function __construct( ?string $apiKey, array $options, - private readonly ?string $configuredReleaseId, + ?string $releaseId, ?string $secretKey = null, ) { + $this->configuredReleaseId = $releaseId; parent::__construct($apiKey, $options, loadFeatureFlags: false, secretKey: $secretKey); } diff --git a/packages/laravel/src/Middleware/RequestContext.php b/packages/laravel/src/Middleware/RequestContext.php index 97f762e6..2de0e929 100644 --- a/packages/laravel/src/Middleware/RequestContext.php +++ b/packages/laravel/src/Middleware/RequestContext.php @@ -8,7 +8,8 @@ use PostHog\Laravel\RequestLifecycle; use Throwable; -class RequestContext +/** @internal */ +final class RequestContext { public function __construct( private RequestLifecycle $lifecycle, @@ -23,7 +24,7 @@ public function handle($request, Closure $next): mixed try { return $next($request); } catch (Throwable $exception) { - $this->reporter->remember($exception, 'middleware'); + $this->reporter->remember($exception, 'middleware', 'laravel.middleware'); throw $exception; } finally { if (!method_exists($this->kernel, 'whenRequestLifecycleIsLongerThan')) { diff --git a/packages/laravel/src/PostHog.php b/packages/laravel/src/PostHog.php index 526811fa..276536b9 100644 --- a/packages/laravel/src/PostHog.php +++ b/packages/laravel/src/PostHog.php @@ -7,13 +7,14 @@ use PostHog\Client; use PostHog\ContextScope; use PostHog\FeatureFlagEvaluations; +use PostHog\FeatureFlagResult; use Throwable; class PostHog { public const VERSION = '0.1.0'; - private ?Client $sdk = null; + private ?Client $client = null; private ?int $definitionsLoadedAt = null; public function __construct(protected Container $app) @@ -28,11 +29,11 @@ public function enabled(): bool public function client(): Client { - if ($this->sdk !== null) { - return $this->sdk; + if ($this->client !== null) { + return $this->client; } if (!$this->enabled()) { - return $this->sdk = new Client(null, ['consumer' => 'noop'], loadFeatureFlags: false); + return $this->client = new Client(null, ['consumer' => 'noop'], loadFeatureFlags: false); } $config = $this->app['config']->get('posthog', []); $options = $config['sdk'] ?? []; @@ -53,7 +54,7 @@ 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 LaravelClient( + return $this->client = new LaravelClient( $config['api_key'], $options, $config['release_id'] ?? null, @@ -178,28 +179,14 @@ private function loadDefinitionsIfNeeded(): void } } - /** @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; + return $this->client !== null ? (bool) $this->client->flush() : false; } public function shutdown(): bool { - return $this->sdk !== null ? $this->sdk->shutdown() : false; + return $this->client !== null ? $this->client->shutdown() : false; } public function withContext(array $data, callable $fn, array $options = []): mixed @@ -252,24 +239,84 @@ public function getContext(): ?array return $this->withContext([], fn () => $this->client()->getContext()); } - public function __call(string $method, array $arguments): mixed + /** @deprecated Use evaluateFlags()->isEnabled() instead. */ + public function isFeatureEnabled( + string $key, + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + bool $onlyEvaluateLocally = false, + bool $sendFeatureFlagEvents = true, + ): ?bool { + return $this->callFlagMethod(__FUNCTION__, func_get_args(), $distinctId); + } + + /** @deprecated Use evaluateFlags()->getFlag() instead. */ + public function getFeatureFlag( + string $key, + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + bool $onlyEvaluateLocally = false, + bool $sendFeatureFlagEvents = true, + ): bool|string|null { + return $this->callFlagMethod(__FUNCTION__, func_get_args(), $distinctId); + } + + /** @deprecated Use evaluateFlags() to read the value and payload from one snapshot. */ + public function getFeatureFlagResult( + string $key, + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + bool $onlyEvaluateLocally = false, + bool $sendFeatureFlagEvents = true, + ): ?FeatureFlagResult { + return $this->callFlagMethod(__FUNCTION__, func_get_args(), $distinctId); + } + + /** @deprecated Use evaluateFlags()->getFlagPayload() instead. */ + public function getFeatureFlagPayload( + string $key, + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + ): mixed { + return $this->callFlagMethod(__FUNCTION__, func_get_args(), $distinctId); + } + + public function getAllFlags( + ?string $distinctId = null, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + bool $onlyEvaluateLocally = false, + ): array { + return $this->callFlagMethod(__FUNCTION__, func_get_args(), $distinctId); + } + + public function loadFlags(): void + { + $this->client()->loadFlags(); + } + + public function contextFromHeaders(array $headers): array + { + return $this->client()->contextFromHeaders($headers); + } + + private function callFlagMethod(string $method, array $arguments, ?string $distinctId): mixed { $data = $this->app->make(Context::class)->data(); - $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(); - } + if ($distinctId === null || $distinctId === '') { + $distinctId = $data['distinctId'] ?? null; + } + if ($this->enabled() && is_string($distinctId) && $distinctId !== '') { + $this->loadDefinitionsIfNeeded(); } return $this->client()->withContext( $data, diff --git a/packages/laravel/src/PostHogServiceProvider.php b/packages/laravel/src/PostHogServiceProvider.php index c852345a..397bcfb4 100644 --- a/packages/laravel/src/PostHogServiceProvider.php +++ b/packages/laravel/src/PostHogServiceProvider.php @@ -65,7 +65,7 @@ public function boot(): void $this->callAfterResolving(ExceptionHandler::class, function ($handler) { if (method_exists($handler, 'reportable')) { $handler->reportable(function (Throwable $exception) { - Container::getInstance()->make(ExceptionReporter::class)->report($exception); + Container::getInstance()->make(ExceptionReporter::class)->report($exception, 'laravel.reportable'); }); } }); @@ -81,7 +81,11 @@ public function boot(): void } }); $events->listen(JobExceptionOccurred::class, function ($event) { - Container::getInstance()->make(ExceptionReporter::class)->remember($event->exception, 'task'); + Container::getInstance()->make(ExceptionReporter::class)->remember( + $event->exception, + 'task', + 'laravel.queue_job', + ); }); $events->listen(CommandStarting::class, function () { // The first CLI kernel may already be handling input when package providers boot. @@ -104,7 +108,11 @@ public function boot(): void }); } $events->listen('Laravel\\Octane\\Events\\WorkerErrorOccurred', function ($event) { - $event->sandbox->make(ExceptionReporter::class)->remember($event->exception, 'runtime'); + $event->sandbox->make(ExceptionReporter::class)->remember( + $event->exception, + 'runtime', + 'laravel.octane_worker_error', + ); }); // End-event consumers must observe the completed scope before cleanup restores its caller. $this->app->booted(function () use ($events) { diff --git a/packages/laravel/src/Testing/PostHogFake.php b/packages/laravel/src/Testing/PostHogFake.php index 61fd6aa9..9f45b624 100644 --- a/packages/laravel/src/Testing/PostHogFake.php +++ b/packages/laravel/src/Testing/PostHogFake.php @@ -131,20 +131,6 @@ private function assertRecorded(string $method, ?callable $callback): void )); } - 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; diff --git a/packages/laravel/tests/ApiSurfaceTest.php b/packages/laravel/tests/ApiSurfaceTest.php new file mode 100644 index 00000000..72b1029d --- /dev/null +++ b/packages/laravel/tests/ApiSurfaceTest.php @@ -0,0 +1,154 @@ +set('posthog.api_key', 'project-key'); + $app['config']->set('posthog.sdk.consumer', 'noop'); + } + + public static function unsupportedMethods(): array + { + return [ + 'transport metadata' => ['getFlagsEtag', []], + 'internal logger' => ['logWarning', ['not a Laravel API']], + 'exposure plumbing' => ['captureFlagCalledIfNeeded', ['user', 'flag', true, [], []]], + ]; + } + + #[DataProvider('unsupportedMethods')] + public function testCoreImplementationMethodsAreNotForwarded(string $method, array $arguments): void + { + $this->expectException(Error::class); + $this->app->make(PostHog::class)->$method(...$arguments); + } + + public function testFacadeDoesNotForwardCoreImplementationMethods(): void + { + $this->expectException(Error::class); + Facade::getFlagsEtag(); + } + + public function testNewClientMethodsAreNotAutomaticallyExposed(): void + { + $client = new class (null, ['consumer' => 'noop'], loadFeatureFlags: false) extends Client { + public function futureClientMethod(): string + { + return 'not a Laravel API'; + } + }; + $manager = $this->getMockBuilder(PostHog::class) + ->setConstructorArgs([$this->app]) + ->onlyMethods(['client']) + ->getMock(); + $manager->expects($this->atLeastOnce())->method('client')->willReturn($client); + $this->assertSame('not a Laravel API', $manager->client()->futureClientMethod()); + $this->expectException(Error::class); + $manager->futureClientMethod(); + } + + public static function flagMethods(): array + { + $single = [ + 'key' => 'checkout', 'distinctId' => 'explicit', 'groups' => ['team' => 'one'], + 'personProperties' => ['plan' => 'pro'], 'groupProperties' => ['team' => ['size' => 2]], + ]; + $options = ['onlyEvaluateLocally' => true, 'sendFeatureFlagEvents' => false]; + return [ + 'boolean' => ['isFeatureEnabled', $single + $options, true], + 'value' => ['getFeatureFlag', $single + $options, 'variant'], + 'result' => ['getFeatureFlagResult', $single + $options, null], + 'payload' => ['getFeatureFlagPayload', $single, ['color' => 'blue']], + 'all' => ['getAllFlags', array_diff_key($single, ['key' => true]) + [ + 'onlyEvaluateLocally' => true, + ], ['checkout' => true]], + ]; + } + + #[DataProvider('flagMethods')] + public function testSupportedFlagMethodsDelegateNamedArgumentsAndRestoreContext( + string $method, + array $arguments, + mixed $result, + ): void { + $client = $this->getMockBuilder(Client::class) + ->setConstructorArgs(['project-key', ['consumer' => 'noop'], null, null, false]) + ->onlyMethods([$method]) + ->getMock(); + $client->expects($this->once())->method($method)->willReturnCallback( + function (...$actual) use ($client, $arguments, $result) { + $this->assertSame(array_values($arguments), $actual); + $this->assertSame('ambient', $client->getContext()['distinctId']); + return $result; + }, + ); + $manager = $this->getMockBuilder(PostHog::class) + ->setConstructorArgs([$this->app]) + ->onlyMethods(['client']) + ->getMock(); + $manager->expects($this->atLeastOnce())->method('client')->willReturn($client); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient']); + $raw = $client->enterContext(['distinctId' => 'raw-parent']); + $before = $client->getContext(); + try { + $this->assertSame($result, $manager->$method(...$arguments)); + $this->assertSame($before, $client->getContext()); + } finally { + $raw->close(); + } + } + + public function testSupportedForwardersKeepTheCoreParameterSignatures(): void + { + $methods = [ + 'isFeatureEnabled', 'getFeatureFlag', 'getFeatureFlagResult', 'getFeatureFlagPayload', + 'getAllFlags', 'loadFlags', 'contextFromHeaders', + ]; + foreach ($methods as $method) { + $signature = static fn ($parameter) => [ + $parameter->getName(), (string) $parameter->getType(), $parameter->isOptional(), + $parameter->isPassedByReference(), $parameter->isVariadic(), + $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null, + ]; + $this->assertSame( + array_map($signature, (new ReflectionMethod(Client::class, $method))->getParameters()), + array_map($signature, (new ReflectionMethod(PostHog::class, $method))->getParameters()), + $method, + ); + } + } + + public function testPublicApiMatchesItsSnapshot(): void + { + $root = dirname(__DIR__, 3); + require_once $root . '/scripts/PublicApiSnapshot.php'; + $builder = new ReflectionMethod(PublicApiSnapshot::class, 'buildPublicApi'); + $api = $builder->invoke(null, dirname(__DIR__) . '/src', 'PostHog\\Laravel\\', true); + $this->assertArrayNotHasKey(\PostHog\Laravel\ExceptionReporter::class, $api['symbols']); + $this->assertArrayNotHasKey('fromRequest', $api['symbols'][Context::class]['methods']); + $this->assertArrayHasKey('getFeatureFlagResult', $api['symbols'][PostHog::class]['methods']); + $this->assertSame( + file_get_contents($root . '/api/laravel-public-api.json'), + json_encode($api, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n", + ); + } +} diff --git a/packages/laravel/tests/CommandContextTest.php b/packages/laravel/tests/CommandContextTest.php index f9c9847c..33519331 100644 --- a/packages/laravel/tests/CommandContextTest.php +++ b/packages/laravel/tests/CommandContextTest.php @@ -174,6 +174,7 @@ public function testNestedCallsRestoreEachCallerAndKeepTheDeepestThrowableSnapsh $mechanism = $this->events[0]['properties']['$exception_list'][0]['mechanism']; $this->assertFalse($mechanism['handled']); $this->assertSame('runtime', $mechanism['type']); + $this->assertSame('laravel.console_lifecycle', $this->events[0]['properties']['$exception_source'] ?? null); } public function testChildFailureExitCodeDoesNotReplaceRootIdentityThroughTermination(): void diff --git a/packages/laravel/tests/OctaneTest.php b/packages/laravel/tests/OctaneTest.php index 7b49a717..d34b6153 100644 --- a/packages/laravel/tests/OctaneTest.php +++ b/packages/laravel/tests/OctaneTest.php @@ -64,6 +64,7 @@ public function testWorkerFailureReportsThroughDontReportPolicyAndCleansContext( $fake->assertExceptionCaptured( fn ($message) => $message['distinctId'] === 'failing-task' && !$message['options']['handled'] + && ($message['options']['source'] ?? null) === 'laravel.octane_worker_error' ); $this->assertSame([], $sandbox->make(Context::class)->data()); $sandbox->make(ExceptionHandler::class)->report($exception); @@ -79,7 +80,8 @@ public function testWorkerFailureReportsThroughDontReportPolicyAndCleansContext( (new \Laravel\Octane\Listeners\ReportException())->handle($earlyEvent); $sandbox['events']->dispatch($earlyEvent); $fake->assertExceptionCaptured( - fn ($message) => $message['distinctId'] === 'early-report' && !$message['options']['handled'], + fn ($message) => $message['distinctId'] === 'early-report' && !$message['options']['handled'] + && ($message['options']['source'] ?? null) === 'laravel.reportable', ); $this->assertCount(2, $fake->recorded('captureException')); } diff --git a/packages/laravel/tests/RuntimeContextTest.php b/packages/laravel/tests/RuntimeContextTest.php index ed9a6e88..666075c1 100644 --- a/packages/laravel/tests/RuntimeContextTest.php +++ b/packages/laravel/tests/RuntimeContextTest.php @@ -13,6 +13,7 @@ use PostHog\HttpClient; use PostHog\HttpResponse; use PostHog\Laravel\Context; +use PostHog\Laravel\ExceptionReporter; use PostHog\Laravel\Facades\PostHog as Facade; use PostHog\Laravel\PostHog; use PostHog\Laravel\PostHogServiceProvider; @@ -258,6 +259,87 @@ public function testFreshManagerScopeSuppressesRequestAndResolvers(): void $this->assertSame($before, $context->snapshot()); } + public function testAutomaticReportingUsesCaptureExceptionAndRestoresContextWhenItThrows(): void + { + $manager = $this->getMockBuilder(PostHog::class) + ->setConstructorArgs([$this->app]) + ->onlyMethods(['captureException']) + ->getMock(); + $this->app->instance(PostHog::class, $manager); + $context = $this->app->make(Context::class); + $context->begin([ + 'distinctId' => 'origin', 'sessionId' => 'origin-session', + 'groups' => ['team' => 'origin-team'], 'properties' => ['component' => 'origin'], + ]); + $exception = new RuntimeException('original failure'); + $reporter = $this->app->make(ExceptionReporter::class); + $reporter->remember($exception, 'task'); + $context->begin(['distinctId' => 'later', 'groups' => ['team' => 'later-team']]); + $before = $context->snapshot(); + $flags = $context->flags('later', fn () => $manager->evaluateFlags(flagKeys: [])); + $raw = $manager->client()->enterContext(['distinctId' => 'raw-later']); + $rawBefore = $manager->client()->getContext(); + $observed = null; + $manager->expects($this->once())->method('captureException')->willReturnCallback( + function ($captured, $distinctId, $properties, $options) use ($manager, $context, &$observed) { + $observed = [$captured, $context->data(), $manager->client()->getContext(), $options]; + throw new RuntimeException('capture failed'); + }, + ); + try { + $reporter->report($exception); + $reporter->report($exception); + $this->assertNotNull($observed); + $this->assertSame($exception, $observed[0]); + $this->assertSame('origin', $observed[1]['distinctId']); + $this->assertSame('origin-session', $observed[1]['sessionId']); + $this->assertSame(['team' => 'origin-team'], $observed[1]['groups']); + $this->assertSame('origin', $observed[1]['properties']['component']); + $this->assertSame('origin', $observed[2]['distinctId']); + $this->assertFalse($observed[3]['handled']); + $this->assertSame('task', $observed[3]['mechanism']); + $this->assertSame($before, $context->snapshot()); + $this->assertSame($rawBefore, $manager->client()->getContext()); + $this->assertSame($flags, $context->flags('later', fn () => throw new RuntimeException('lost flags'))); + } finally { + $raw->close(); + } + } + + public function testKnownExceptionSourceSurvivesLaterBoundariesButDoesNotLeakIntoManualCaptures(): void + { + $events = []; + $this->app['config']->set('posthog.sdk.before_send', function ($event) use (&$events) { + $events[] = $event; + return null; + }); + $context = $this->app->make(Context::class); + $context->begin(['distinctId' => 'origin']); + $exception = new RuntimeException('original failure'); + $reporter = $this->app->make(ExceptionReporter::class); + $reporter->remember($exception, 'task', 'laravel.queue_job'); + $context->begin(['distinctId' => 'later']); + $reporter->remember($exception, 'runtime', 'laravel.console_lifecycle'); + $handler = $this->app->make(\Illuminate\Contracts\Debug\ExceptionHandler::class); + $handler->report($exception); + $handler->report($exception); + $this->assertCount(1, $events); + $this->assertSame('origin', $events[0]['distinct_id']); + $this->assertSame('laravel.queue_job', $events[0]['properties']['$exception_source'] ?? null); + $this->assertSame('task', $events[0]['properties']['$exception_list'][0]['mechanism']['type']); + $this->assertSame(['distinctId' => 'later'], $context->snapshot()); + $manager = $this->app->make(PostHog::class); + $manager->captureException($exception, properties: ['$exception_source' => 'forged']); + $this->assertCount(2, $events); + $this->assertSame('later', $events[1]['distinct_id']); + $this->assertArrayNotHasKey('$exception_source', $events[1]['properties']); + $reporter->report(new RuntimeException('unknown hook')); + $this->assertCount(3, $events); + $this->assertArrayNotHasKey('$exception_source', $events[2]['properties']); + $this->assertSame(['distinctId' => 'later'], $context->snapshot()); + $this->assertNull($manager->client()->getContext()); + } + public function testDelayedFlagExposuresKeepOriginAfterRequestSwitchAndEnd(): void { $events = []; diff --git a/packages/laravel/tests/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php index c450c67b..71cda507 100644 --- a/packages/laravel/tests/RuntimeTest.php +++ b/packages/laravel/tests/RuntimeTest.php @@ -51,7 +51,7 @@ public function testDiscoveryBindingsAndLazyDisabledClient(): void { $manager = $this->app->make(PostHog::class); $this->assertSame($manager, $this->app->make('posthog')); - $sdk = new \ReflectionProperty(PostHog::class, 'sdk'); + $sdk = new \ReflectionProperty(PostHog::class, 'client'); $this->assertNull($sdk->getValue($manager)); $this->app['config']->set('posthog.api_key', ' '); $this->assertFalse($manager->capture('no traffic')); @@ -193,7 +193,9 @@ public function testRequestFailureSnapshotsSurviveCleanupAndDontReportIsRespecte $this->assertCount(1, $fake->recorded('captureException')); $fake->assertExceptionCaptured( fn ($message) => $message['distinctId'] === 'origin' - && $message['options'] === ['handled' => false, 'mechanism' => 'middleware'] + && $message['options'] === [ + 'handled' => false, 'mechanism' => 'middleware', 'source' => 'laravel.middleware', + ] ); $handler->dontReport([IgnoredFailure::class]); $handler->report(new IgnoredFailure('ignored')); @@ -215,10 +217,12 @@ public function testRealRouteFailuresAreUnhandledButManualReportRemainsHandled() $fake->assertExceptionCaptured( fn ($message) => $message['distinctId'] === 'failed-user' && $message['options']['handled'] === false + && ($message['options']['source'] ?? null) === 'laravel.reportable' ); $fake->assertExceptionCaptured( fn ($message) => $message['distinctId'] === 'manual-user' && $message['options']['handled'] === true + && ($message['options']['source'] ?? null) === 'laravel.reportable' ); } @@ -239,7 +243,9 @@ public function testQueueBoundariesCleanTwoJobsAndPreserveFailedJobIdentity(): v $this->app->make(ExceptionHandler::class)->report($exception); $fake->assertExceptionCaptured( fn ($message) => $message['distinctId'] === 'failed-job' - && $message['options'] === ['handled' => false, 'mechanism' => 'task'] + && $message['options'] === [ + 'handled' => false, 'mechanism' => 'task', 'source' => 'laravel.queue_job', + ] ); $this->app['events']->dispatch(new JobProcessed('sync', $job)); $this->assertSame([], $this->app->make(Context::class)->data()); @@ -293,7 +299,7 @@ public function testConsoleAndOctaneBoundariesFlushAndReset(): void { $client = new FlushClient(); $manager = $this->app->make(PostHog::class); - (new \ReflectionProperty(PostHog::class, 'sdk'))->setValue($manager, $client); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $client); $context = $this->app->make(Context::class); $this->app->make(\Illuminate\Contracts\Console\Kernel::class)->rerouteSymfonyCommandEvents(); \Illuminate\Support\Facades\Artisan::command('posthog:boundary', function () { @@ -318,7 +324,7 @@ public function testLifecycleCleanupSurvivesFlushFailure(): void $manager = $this->app->make(PostHog::class); $client = new FlushClient(); $client->fail = true; - (new \ReflectionProperty(PostHog::class, 'sdk'))->setValue($manager, $client); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $client); $context = $this->app->make(Context::class); $context->begin(['distinctId' => 'stale']); $this->app['events']->dispatch('Laravel\\Octane\\Events\\TaskTerminated', [(object) []]); @@ -348,7 +354,7 @@ public function testFlagsAreLazyScopedAndValidNoopStillEvaluates(): void loadFeatureFlags: false, ); $manager = $this->app->make(PostHog::class); - (new \ReflectionProperty(PostHog::class, 'sdk'))->setValue($manager, $sdk); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $sdk); $manager->capture('does not fetch', [], 'first'); $context = $this->app->make(Context::class); foreach (['first', 'second'] as $id) { @@ -375,7 +381,7 @@ public function testNoIdentityNeverLoadsPrivilegedDefinitionsAndResolversUseCont secretKey: 'server-secret', ); $manager = $this->app->make(PostHog::class); - (new \ReflectionProperty(PostHog::class, 'sdk'))->setValue($manager, $sdk); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $sdk); $this->assertSame([], $manager->evaluateFlags()->getKeys()); $this->app['config']->set('posthog.distinct_id', IdentityResolver::class); $this->app['config']->set('posthog.groups', GroupsResolver::class); @@ -413,7 +419,7 @@ public function testLegacyLocalOnlyFlagsLoadDefinitionsLazilyOnFirstIdentifiedUs 'flag_definition_cache_provider' => $provider, ], $http, loadFeatureFlags: false); $manager = $this->app->make(PostHog::class); - (new \ReflectionProperty(PostHog::class, 'sdk'))->setValue($manager, $sdk); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $sdk); $this->assertSame([], $manager->getAllFlags(onlyEvaluateLocally: true)); $this->app->make(Context::class)->begin(['distinctId' => '0']); $this->assertSame(['local-flag' => true], $manager->getAllFlags(onlyEvaluateLocally: true)); @@ -477,6 +483,7 @@ public function testRealClientMetadataGroupsAndCanonicalExceptionAreForwarded(): $this->assertSame(['team' => 'one'], $events[1]['properties']['$groups']); $this->assertSame('RuntimeException', $events[1]['properties']['$exception_list'][0]['type']); $this->assertTrue($events[1]['properties']['$exception_list'][0]['mechanism']['handled']); + $this->assertArrayNotHasKey('$exception_source', $events[1]['properties']); $this->assertNull($manager->client()->getContext()); $exception = new RuntimeException('originating boundary'); $this->app->make(ExceptionReporter::class)->remember($exception, 'task'); @@ -486,6 +493,7 @@ public function testRealClientMetadataGroupsAndCanonicalExceptionAreForwarded(): $this->assertSame('user', $events[2]['distinct_id']); $this->assertSame(['team' => 'one'], $events[2]['properties']['$groups']); $this->assertFalse($events[2]['properties']['$exception_list'][0]['mechanism']['handled']); + $this->assertSame('laravel.reportable', $events[2]['properties']['$exception_source'] ?? null); } public function testInstallPublishesWithoutOverwritingApplicationConfig(): void diff --git a/scripts/PublicApiSnapshot.php b/scripts/PublicApiSnapshot.php index 74a7ff8d..916e9773 100644 --- a/scripts/PublicApiSnapshot.php +++ b/scripts/PublicApiSnapshot.php @@ -23,12 +23,19 @@ final class PublicApiSnapshot public static function run(array $argv): int { $root = dirname(__DIR__); - $snapshotFile = $root . '/api/public-api.json'; + $laravel = in_array('--laravel', $argv, true); + $packageRoot = $laravel ? $root . '/packages/laravel' : $root; + $snapshotFile = $root . '/api/' . ($laravel ? 'laravel-public-api.json' : 'public-api.json'); $update = in_array('--update', $argv, true); + $updateCommand = $laravel ? 'composer api:update:laravel' : 'composer api:update'; - require_once $root . '/vendor/autoload.php'; + require_once $packageRoot . '/vendor/autoload.php'; - $api = self::buildPublicApi($root . '/lib', 'PostHog\\'); + $api = self::buildPublicApi( + $packageRoot . ($laravel ? '/src' : '/lib'), + $laravel ? 'PostHog\\Laravel\\' : 'PostHog\\', + $laravel, + ); $json = json_encode($api, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; if ($update) { @@ -43,7 +50,7 @@ public static function run(array $argv): int if (!file_exists($snapshotFile)) { fwrite(STDERR, 'Missing public API snapshot: ' . self::relativePath($snapshotFile, $root) . PHP_EOL); - fwrite(STDERR, "Run `composer api:update` to create it.\n"); + fwrite(STDERR, "Run `$updateCommand` to create it.\n"); return 1; } @@ -57,7 +64,7 @@ public static function run(array $argv): int file_put_contents($tmpFile, $json); fwrite(STDERR, "Public API snapshot is out of date.\n"); - fwrite(STDERR, "Run `composer api:update` if this public API change is intentional.\n\n"); + fwrite(STDERR, "Run `$updateCommand` if this public API change is intentional.\n\n"); fwrite(STDERR, self::unifiedDiff($snapshotFile, $tmpFile, $root)); @unlink($tmpFile); return 1; @@ -66,8 +73,11 @@ public static function run(array $argv): int /** * @return array */ - private static function buildPublicApi(string $sourceDir, string $namespacePrefix): array - { + private static function buildPublicApi( + string $sourceDir, + string $namespacePrefix, + bool $excludeInternal = false, + ): array { foreach (self::phpFiles($sourceDir) as $file) { require_once $file; } @@ -83,11 +93,14 @@ private static function buildPublicApi(string $sourceDir, string $namespacePrefi $reflection = new ReflectionClass($name); $fileName = $reflection->getFileName(); - if ($fileName === false || !self::pathIsInside($fileName, $sourceDir)) { + if ( + $fileName === false || !self::pathIsInside($fileName, $sourceDir) + || ($excludeInternal && self::isInternal($reflection)) + ) { continue; } - $symbols[$name] = self::reflectClass($reflection); + $symbols[$name] = self::reflectClass($reflection, $excludeInternal); } ksort($symbols); @@ -123,11 +136,14 @@ private static function phpFiles(string $directory): array /** * @return array */ - private static function reflectClass(ReflectionClass $class): array + private static function reflectClass(ReflectionClass $class, bool $excludeInternal = false): array { $constants = []; foreach ($class->getReflectionConstants(ReflectionClassConstant::IS_PUBLIC) as $constant) { - if ($constant->getDeclaringClass()->getName() !== $class->getName()) { + if ( + $constant->getDeclaringClass()->getName() !== $class->getName() + || ($excludeInternal && self::isInternal($constant)) + ) { continue; } @@ -140,7 +156,10 @@ private static function reflectClass(ReflectionClass $class): array $properties = []; foreach ($class->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { - if ($property->getDeclaringClass()->getName() !== $class->getName()) { + if ( + $property->getDeclaringClass()->getName() !== $class->getName() + || ($excludeInternal && self::isInternal($property)) + ) { continue; } @@ -156,7 +175,10 @@ private static function reflectClass(ReflectionClass $class): array $methods = []; foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { - if ($method->getDeclaringClass()->getName() !== $class->getName()) { + if ( + $method->getDeclaringClass()->getName() !== $class->getName() + || ($excludeInternal && self::isInternal($method)) + ) { continue; } @@ -177,6 +199,12 @@ private static function reflectClass(ReflectionClass $class): array ]; } + private static function isInternal( + ReflectionClass|ReflectionClassConstant|ReflectionMethod|ReflectionProperty $symbol, + ): bool { + return preg_match('/@internal\b/', $symbol->getDocComment() ?: '') === 1; + } + /** * @return array */ @@ -295,7 +323,14 @@ private static function normalizeTypeName(string $name, ReflectionClass $scope): private static function normalizeConstantValue(ReflectionClassConstant $constant): mixed { - if ($constant->getDeclaringClass()->getName() === 'PostHog\\PostHog' && $constant->getName() === 'VERSION') { + if ( + $constant->getName() === 'VERSION' + && in_array( + $constant->getDeclaringClass()->getName(), + ['PostHog\\PostHog', 'PostHog\\Laravel\\PostHog'], + true, + ) + ) { return ''; } diff --git a/test/PublicApiSnapshotTest.php b/test/PublicApiSnapshotTest.php index 7a1fc478..0888d070 100644 --- a/test/PublicApiSnapshotTest.php +++ b/test/PublicApiSnapshotTest.php @@ -11,6 +11,47 @@ class PublicApiSnapshotTest extends TestCase { + public function testInternalAnnotationsCanBeExcludedWithoutChangingTheCoreDefault(): void + { + require_once __DIR__ . '/../scripts/PublicApiSnapshot.php'; + $directory = sys_get_temp_dir() . '/posthog-api-' . bin2hex(random_bytes(8)); + mkdir($directory); + $namespace = 'PostHog\\Test\\ApiFixture' . bin2hex(random_bytes(8)); + $file = $directory . '/Fixture.php'; + file_put_contents($file, sprintf(<<<'PHP' +invoke(null, $directory, $namespace . '\\', true); + self::assertArrayNotHasKey($namespace . '\\Hidden', $api['symbols']); + $visible = $api['symbols'][$namespace . '\\Visible']; + self::assertSame(['SUPPORTED'], array_keys($visible['constants'])); + self::assertSame(['supported'], array_keys($visible['properties'])); + self::assertSame(['supported'], array_keys($visible['methods'])); + $unfiltered = $builder->invoke(null, $directory, $namespace . '\\'); + self::assertArrayHasKey($namespace . '\\Hidden', $unfiltered['symbols']); + self::assertArrayHasKey('internal', $unfiltered['symbols'][$namespace . '\\Visible']['methods']); + } finally { + unlink($file); + rmdir($directory); + } + } + public function testInternalNamespaceIsExcludedWithoutDroppingExistingSymbols(): void { require_once __DIR__ . '/../scripts/PublicApiSnapshot.php'; From d172504cf848badbb6d34936c61a2b0c0fd80066 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 30 Oct 2026 12:58:21 +0100 Subject: [PATCH 18/21] fix(laravel): inherit context groups in legacy flag getters --- packages/laravel/README.md | 6 ++-- packages/laravel/src/PostHog.php | 25 ++++++++++--- packages/laravel/tests/ApiSurfaceTest.php | 43 ++++++++++++++++++++--- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/packages/laravel/README.md b/packages/laravel/README.md index b1893ccc..2a73f008 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -224,8 +224,10 @@ try { 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. +identified use, preserving local-only options. Like `evaluateFlags()`, they inherit +context groups when the groups argument is empty; explicit nonempty groups override +them. With the raw `client()` escape hatch, call `loadFlags()` explicitly before local +evaluation if definitions have not loaded. ### Shared local definitions diff --git a/packages/laravel/src/PostHog.php b/packages/laravel/src/PostHog.php index 276536b9..b9b94804 100644 --- a/packages/laravel/src/PostHog.php +++ b/packages/laravel/src/PostHog.php @@ -249,7 +249,10 @@ public function isFeatureEnabled( bool $onlyEvaluateLocally = false, bool $sendFeatureFlagEvents = true, ): ?bool { - return $this->callFlagMethod(__FUNCTION__, func_get_args(), $distinctId); + return $this->callFlagMethod(__FUNCTION__, [ + $key, $distinctId, $groups, $personProperties, $groupProperties, + $onlyEvaluateLocally, $sendFeatureFlagEvents, + ], $distinctId); } /** @deprecated Use evaluateFlags()->getFlag() instead. */ @@ -262,7 +265,10 @@ public function getFeatureFlag( bool $onlyEvaluateLocally = false, bool $sendFeatureFlagEvents = true, ): bool|string|null { - return $this->callFlagMethod(__FUNCTION__, func_get_args(), $distinctId); + return $this->callFlagMethod(__FUNCTION__, [ + $key, $distinctId, $groups, $personProperties, $groupProperties, + $onlyEvaluateLocally, $sendFeatureFlagEvents, + ], $distinctId); } /** @deprecated Use evaluateFlags() to read the value and payload from one snapshot. */ @@ -275,7 +281,10 @@ public function getFeatureFlagResult( bool $onlyEvaluateLocally = false, bool $sendFeatureFlagEvents = true, ): ?FeatureFlagResult { - return $this->callFlagMethod(__FUNCTION__, func_get_args(), $distinctId); + return $this->callFlagMethod(__FUNCTION__, [ + $key, $distinctId, $groups, $personProperties, $groupProperties, + $onlyEvaluateLocally, $sendFeatureFlagEvents, + ], $distinctId); } /** @deprecated Use evaluateFlags()->getFlagPayload() instead. */ @@ -286,7 +295,9 @@ public function getFeatureFlagPayload( array $personProperties = [], array $groupProperties = [], ): mixed { - return $this->callFlagMethod(__FUNCTION__, func_get_args(), $distinctId); + return $this->callFlagMethod(__FUNCTION__, [ + $key, $distinctId, $groups, $personProperties, $groupProperties, + ], $distinctId); } public function getAllFlags( @@ -296,7 +307,9 @@ public function getAllFlags( array $groupProperties = [], bool $onlyEvaluateLocally = false, ): array { - return $this->callFlagMethod(__FUNCTION__, func_get_args(), $distinctId); + return $this->callFlagMethod(__FUNCTION__, [ + $distinctId, $groups, $personProperties, $groupProperties, $onlyEvaluateLocally, + ], $distinctId); } public function loadFlags(): void @@ -312,6 +325,8 @@ public function contextFromHeaders(array $headers): array private function callFlagMethod(string $method, array $arguments, ?string $distinctId): mixed { $data = $this->app->make(Context::class)->data(); + $groupsIndex = $method === 'getAllFlags' ? 1 : 2; + $arguments[$groupsIndex] = $arguments[$groupsIndex] ?: ($data['groups'] ?? []); if ($distinctId === null || $distinctId === '') { $distinctId = $data['distinctId'] ?? null; } diff --git a/packages/laravel/tests/ApiSurfaceTest.php b/packages/laravel/tests/ApiSurfaceTest.php index 72b1029d..4bf073ac 100644 --- a/packages/laravel/tests/ApiSurfaceTest.php +++ b/packages/laravel/tests/ApiSurfaceTest.php @@ -6,6 +6,8 @@ use Orchestra\Testbench\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use PostHog\Client; +use PostHog\HttpClient; +use PostHog\HttpResponse; use PostHog\Laravel\Context; use PostHog\Laravel\Facades\PostHog as Facade; use PostHog\Laravel\PostHog; @@ -73,7 +75,7 @@ public static function flagMethods(): array 'personProperties' => ['plan' => 'pro'], 'groupProperties' => ['team' => ['size' => 2]], ]; $options = ['onlyEvaluateLocally' => true, 'sendFeatureFlagEvents' => false]; - return [ + $methods = [ 'boolean' => ['isFeatureEnabled', $single + $options, true], 'value' => ['getFeatureFlag', $single + $options, 'variant'], 'result' => ['getFeatureFlagResult', $single + $options, null], @@ -82,6 +84,12 @@ public static function flagMethods(): array 'onlyEvaluateLocally' => true, ], ['checkout' => true]], ]; + $cases = $methods; + foreach ($methods as $name => [$method, $arguments, $result]) { + $arguments['groups'] = []; + $cases['ambient groups ' . $name] = [$method, $arguments, $result]; + } + return $cases; } #[DataProvider('flagMethods')] @@ -94,9 +102,11 @@ public function testSupportedFlagMethodsDelegateNamedArgumentsAndRestoreContext( ->setConstructorArgs(['project-key', ['consumer' => 'noop'], null, null, false]) ->onlyMethods([$method]) ->getMock(); + $expected = $arguments; + $expected['groups'] = $expected['groups'] ?: ['team' => 'ambient']; $client->expects($this->once())->method($method)->willReturnCallback( - function (...$actual) use ($client, $arguments, $result) { - $this->assertSame(array_values($arguments), $actual); + function (...$actual) use ($client, $expected, $result) { + $this->assertSame(array_values($expected), $actual); $this->assertSame('ambient', $client->getContext()['distinctId']); return $result; }, @@ -106,7 +116,7 @@ function (...$actual) use ($client, $arguments, $result) { ->onlyMethods(['client']) ->getMock(); $manager->expects($this->atLeastOnce())->method('client')->willReturn($client); - $this->app->make(Context::class)->begin(['distinctId' => 'ambient']); + $this->app->make(Context::class)->begin(['distinctId' => 'ambient', 'groups' => ['team' => 'ambient']]); $raw = $client->enterContext(['distinctId' => 'raw-parent']); $before = $client->getContext(); try { @@ -117,6 +127,31 @@ function (...$actual) use ($client, $arguments, $result) { } } + public function testLegacyFlagEvaluationSendsAmbientGroupsWhenArgumentsAreOmitted(): void + { + $payload = null; + $http = $this->createMock(HttpClient::class); + $http->expects($this->once())->method('sendRequest')->willReturnCallback( + function ($path, $body) use (&$payload) { + $payload = json_decode($body, true); + return new HttpResponse(json_encode([ + 'flags' => ['checkout' => ['enabled' => true, 'variant' => null]], + ]), 200); + }, + ); + $client = new Client('project-key', ['consumer' => 'noop'], $http, loadFeatureFlags: false); + $manager = $this->getMockBuilder(PostHog::class) + ->setConstructorArgs([$this->app]) + ->onlyMethods(['client']) + ->getMock(); + $manager->expects($this->atLeastOnce())->method('client')->willReturn($client); + $this->app->make(Context::class)->begin(['distinctId' => 'user', 'groups' => ['team' => 'ambient']]); + $this->assertSame(['checkout' => true], $manager->getAllFlags()); + $this->assertSame(['team' => 'ambient'], $payload['groups']); + $this->assertSame('user', $payload['distinct_id']); + $this->assertNull($client->getContext()); + } + public function testSupportedForwardersKeepTheCoreParameterSignatures(): void { $methods = [ From 4837e6877a5847e1e3ae6ffae6642df282492c0b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 30 Oct 2026 13:11:04 +0100 Subject: [PATCH 19/21] fix(packaging): exclude Laravel sources from core distribution --- .gitattributes | 1 + CONTRIBUTING.md | 2 +- RELEASING.md | 4 ++++ test/PackageArchiveTest.php | 26 ++++++++++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 test/PackageArchiveTest.php diff --git a/.gitattributes b/.gitattributes index 0243e75d..e0e62658 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,4 @@ /phpcs.xml export-ignore /.github export-ignore /Makefile export-ignore +/packages/laravel export-ignore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2601df64..cff303ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ Run the test command used in CI: XDEBUG_MODE=coverage ./vendor/bin/phpunit --bootstrap vendor/autoload.php --configuration phpunit.xml --coverage-text ``` -The PHPUnit transport tests use a loopback HTTP server, not live PostHog endpoints. They require the cURL extension, the `curl` and `gzip` commands, and permission to start PHP subprocesses and listen on loopback ports. +The PHPUnit transport tests use a loopback HTTP server, not live PostHog endpoints. They require the cURL extension, the `curl` and `gzip` commands, and permission to start PHP subprocesses and listen on loopback ports. Distribution archive tests also require the `git` and `tar` commands. Run the adapter fidelity and report checker tests (Python standard library only): diff --git a/RELEASING.md b/RELEASING.md index cd927517..173978aa 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -43,6 +43,10 @@ The pending change intents determine the next version: When multiple intents are pending, the workflow uses the largest requested bump. +## Composer distribution archives + +The root `.gitattributes` excludes `packages/laravel/` from the core Composer distribution. Laravel source stays in Git and has its own publishing flow. Tools that need the complete repository must use a Git checkout, not the core distribution archive. In particular, a whole-repository `git archive` no longer includes Laravel source or tests. + ## Troubleshooting ### No releasable change intents found diff --git a/test/PackageArchiveTest.php b/test/PackageArchiveTest.php new file mode 100644 index 00000000..12497a1b --- /dev/null +++ b/test/PackageArchiveTest.php @@ -0,0 +1,26 @@ + $path === 'packages/laravel' || str_starts_with($path, 'packages/laravel/'), + ))); + } +} From ca087b89f4d4aa2703cca80840e0a2fa5052708f Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 30 Oct 2026 14:01:45 +0100 Subject: [PATCH 20/21] fix(laravel): sync scoped session property and defer exception flush --- packages/laravel/src/Context.php | 7 ++++ packages/laravel/src/ExceptionReporter.php | 1 - packages/laravel/tests/RuntimeContextTest.php | 32 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/laravel/src/Context.php b/packages/laravel/src/Context.php index 6baf50c1..8c59e18e 100644 --- a/packages/laravel/src/Context.php +++ b/packages/laravel/src/Context.php @@ -95,6 +95,13 @@ public function with(array $data, callable $callback): mixed $request = $this->request; $headerIdentity = $this->headerIdentity; $resolved = $this->resolved; + $sessionId = $data['sessionId'] ?? $data['session_id'] ?? null; + if ( + is_scalar($sessionId) && (string) $sessionId !== '' + && !array_key_exists('$session_id', $data['properties'] ?? []) + ) { + $data['properties']['$session_id'] = (string) $sessionId; + } $this->begin(array_replace_recursive($previous, $data)); $this->resolved = $resolved; $this->request = $request; diff --git a/packages/laravel/src/ExceptionReporter.php b/packages/laravel/src/ExceptionReporter.php index ac5567c1..d9075173 100644 --- a/packages/laravel/src/ExceptionReporter.php +++ b/packages/laravel/src/ExceptionReporter.php @@ -74,7 +74,6 @@ public function report(Throwable $exception, ?string $source = null): void fn () => $manager->captureException($exception, options: $options), ['fresh' => true], ); - $manager->flush(); } catch (Throwable) { // Reporting must never replace the application's original failure. } diff --git a/packages/laravel/tests/RuntimeContextTest.php b/packages/laravel/tests/RuntimeContextTest.php index 666075c1..e594aa94 100644 --- a/packages/laravel/tests/RuntimeContextTest.php +++ b/packages/laravel/tests/RuntimeContextTest.php @@ -17,6 +17,7 @@ use PostHog\Laravel\Facades\PostHog as Facade; use PostHog\Laravel\PostHog; use PostHog\Laravel\PostHogServiceProvider; +use PostHog\Laravel\Tests\Fixtures\FlushClient; use PostHog\Laravel\Tests\Fixtures\GroupsResolver; use PostHog\Laravel\Tests\Fixtures\TransportErrorHandler; use RuntimeException; @@ -54,6 +55,37 @@ public function testThrowingIdentityResolverStillUsesAuthAndGroups(): void } } + public function testScopedSessionOverrideReplacesInheritedHeaderSession(): void + { + $events = []; + $this->app['config']->set('posthog.sdk.before_send', function ($event) use (&$events) { + $events[] = $event; + return null; + }); + $context = $this->app->make(Context::class); + $context->fromRequest(Request::create('/', server: ['HTTP_X_POSTHOG_SESSION_ID' => 'old'])); + $manager = $this->app->make(PostHog::class); + $context->with(['sessionId' => 'new'], fn () => $manager->capture('scoped', [], 'user')); + $context->with( + ['sessionId' => 'new', 'properties' => ['$session_id' => 'explicit']], + fn () => $manager->capture('explicit', [], 'user'), + ); + $manager->capture('outer', [], 'user'); + $this->assertSame( + ['new', 'explicit', 'old'], + array_map(fn ($event) => $event['properties']['$session_id'], $events), + ); + } + + public function testAutomaticReportingDefersFlushToLifecycleCleanup(): void + { + $client = new FlushClient(); + $manager = $this->app->make(PostHog::class); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $client); + $this->app->make(ExceptionReporter::class)->report(new RuntimeException('failure')); + $this->assertSame(0, $client->flushes); + } + public function testInvokableErrorHandlerConstructsRealLibCurlConsumer(): void { $this->app['config']->set('posthog.sdk.consumer', 'lib_curl'); From 33a71f9bffd30b05e86d78ec39ba72d0300f22fe Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 2 Nov 2026 11:33:01 +0100 Subject: [PATCH 21/21] fix(laravel): address runtime safety and diagnostics feedback --- packages/laravel/README.md | 51 ++++++-- .../laravel/src/Commands/DoctorCommand.php | 15 +++ packages/laravel/src/ConsoleLifecycle.php | 10 +- packages/laravel/src/Context.php | 46 +++++++- packages/laravel/src/ExceptionReporter.php | 2 +- packages/laravel/tests/ApiSurfaceTest.php | 9 +- packages/laravel/tests/LifecycleTest.php | 32 +++++ packages/laravel/tests/RuntimeContextTest.php | 109 ++++++++++++++++++ packages/laravel/tests/RuntimeTest.php | 67 +++++++++++ 9 files changed, 317 insertions(+), 24 deletions(-) diff --git a/packages/laravel/README.md b/packages/laravel/README.md index 2a73f008..4a2915cb 100644 --- a/packages/laravel/README.md +++ b/packages/laravel/README.md @@ -62,8 +62,12 @@ 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 +applies. Resolver errors do not break the application. With `sdk.debug=true`, failed +identity, authenticated-user and group lookups log at most one debug message each +per context scope through Laravel's logger. Diagnostics contain the exception class, +not its message or resolved data; enable debug output on your Laravel log channel to +see them. Set `groups` to an invokable container class receiving the application and +returning group identifiers. Explicit context groups override matching resolver groups; exception snapshots inherit them. ## Scoped context and workers @@ -82,6 +86,8 @@ 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. +It accepts `distinct_id`/`session_id` aliases; canonical `distinctId`/`sessionId` keys +win if both forms are supplied. The manager/facade also exposes `withContext($data, $callback, $options)`, `enterContext($data, $options)` and `getContext()`. These bridge Laravel context and raw-client context, accept `distinct_id`/`session_id` aliases, and merge properties @@ -108,8 +114,9 @@ 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 +command stays active through kernel termination. Nested calls defer flushing to the +outer command or HTTP termination. A standalone `Artisan::call()` still flushes on +exit. Thrown objects retain their own snapshots; nonzero return codes never supply another exception's identity. The application's commands, custom kernel, and exception-reporting policy are preserved. Direct child-command `$this->call()`/`callSilent()`/`callSilently()` execution bypasses those kernel boundaries. Opt into isolation by adding `IsolatesCommandContext` to @@ -167,8 +174,12 @@ try { } ``` -Manual captures and ordinary `report()` calls are handled. Failures reported from -Laravel's routing pipeline or HTTP/console kernel failure paths, escaping request +The manager/facade uses the named argument `properties:` for exception properties, +not the core client's `additionalProperties:`. Other exception parameters match core. + +Manual captures and ordinary `report()` calls are handled and use mechanism `generic` +unless a known integration boundary supplies a more specific category. Failures +reported from Laravel's routing pipeline or HTTP/console kernel failure paths, escaping request middleware or queue execution, or reported by Octane's worker error listener are unhandled. Boundary detection inspects only framework call names, never arguments. Weakly held exception snapshots preserve the originating identity after cleanup; @@ -178,11 +189,15 @@ later boundary event. Explicit manual capture is intentionally independent of automatic-report deduplication. Automatic reports include `$exception_source` from the first known SDK boundary: -`laravel.middleware`, `laravel.queue_job`, `laravel.console_lifecycle`, or -`laravel.octane_worker_error`. Without a remembered boundary source, the registered -Laravel reportable callback supplies `laravel.reportable`. Sources are never inferred -from stack frames. Ordinary manual `captureException()` calls omit the source unless -an integration explicitly supplies it through the typed `source` option. +`laravel.queue_job`, `laravel.console_lifecycle`, or `laravel.octane_worker_error`. +Normal HTTP pipeline failures are reported and rendered by Laravel before the outer +middleware can catch them: their source is `laravel.reportable` and mechanism is +`middleware`. The safety-net middleware catch supplies `laravel.middleware` only +when an exception escapes to it, such as in a custom pipeline. Without a remembered +boundary source, the registered Laravel reportable callback supplies `laravel.reportable`. +Sources are never inferred from stack frames. Ordinary manual `captureException()` +calls omit the source unless an integration explicitly supplies it through the typed +`source` option. ## Feature flags @@ -242,7 +257,12 @@ 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 +refresh lazily after the interval. The interval only throttles within a single +scoped manager: without a cache provider, each new request/job that evaluates flags +with a secret key performs a blocking definition fetch, even within the interval. +For production local evaluation, enable `flag_cache` with a shared store, or configure +a custom provider. `posthog:doctor` warns about a secret key without a provider. +No cache is enabled implicitly, and there is no background timer. The PHP client's `loadFlags()` remains available for an explicit refresh. You can instead supply a custom provider class in `sdk.flag_definition_cache_provider`. @@ -255,6 +275,13 @@ corresponding SDK callable/interface contract. The Laravel library metadata and framework handler ownership are enforced by the wrapper. `release_id` sets release metadata; advanced SDK options remain accessible under `sdk`. +Client construction remains lazy. Invalid SDK configuration raises an initialization +error on first client use rather than silently disabling analytics. Run +`php artisan posthog:doctor` as a deployment check: it constructs the client and +reports initialization failures without printing credentials, loading flag +definitions or sending a diagnostic event. Custom container callbacks/providers +are resolved, so their constructors should not perform network work. + The supported PHP surface consists of the manager, facade, documented `Context` methods, `IsolatesCommandContext` trait, test fake, service provider registration, and Laravel flag-definition cache provider. Classes and methods marked `@internal` diff --git a/packages/laravel/src/Commands/DoctorCommand.php b/packages/laravel/src/Commands/DoctorCommand.php index c9679492..a055be7f 100644 --- a/packages/laravel/src/Commands/DoctorCommand.php +++ b/packages/laravel/src/Commands/DoctorCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use PostHog\Laravel\PostHog; +use Throwable; /** @internal */ final class DoctorCommand extends Command @@ -25,6 +26,20 @@ public function handle(PostHog $posthog): int $this->error('PostHog host must be an HTTP or HTTPS URL.'); return self::FAILURE; } + try { + $posthog->client(); + } catch (Throwable $exception) { + $this->error('PostHog client initialization failed (' . $exception::class . '). ' + . 'Check posthog.sdk options, including consumer, callbacks and flag_definition_cache_provider.'); + return self::FAILURE; + } + if ( + config('posthog.secret_key') && !config('posthog.flag_cache.enabled') + && !config('posthog.sdk.flag_definition_cache_provider') + ) { + $this->warn('Local flag definitions will be fetched on first evaluation in each request or job. ' + . 'Enable posthog.flag_cache or configure a flag_definition_cache_provider to share definitions.'); + } $this->info('PostHog is configured. Credentials are server-side. No network check was performed.'); return self::SUCCESS; } diff --git a/packages/laravel/src/ConsoleLifecycle.php b/packages/laravel/src/ConsoleLifecycle.php index 2eb792e6..59059505 100644 --- a/packages/laravel/src/ConsoleLifecycle.php +++ b/packages/laravel/src/ConsoleLifecycle.php @@ -47,7 +47,9 @@ public function call(callable $callback): mixed throw $exception; } finally { $this->calls--; - $this->leave(); + $this->leave( + !$this->root && $this->calls === 0 && !$this->app->make(RequestLifecycle::class)->active(), + ); } } @@ -75,10 +77,12 @@ private function remember(Throwable $exception): void } } - private function leave(): void + private function leave(bool $flush = true): void { try { - $this->app->make(PostHog::class)->flush(); + if ($flush) { + $this->app->make(PostHog::class)->flush(); + } } catch (Throwable) { } finally { $this->app->make(Context::class)->leave(); diff --git a/packages/laravel/src/Context.php b/packages/laravel/src/Context.php index 8c59e18e..e0ab6d73 100644 --- a/packages/laravel/src/Context.php +++ b/packages/laravel/src/Context.php @@ -20,6 +20,7 @@ class Context private bool $resolved = false; private array $contextScopes = []; private int $nextScopeId = 0; + private array $loggedResolverFailures = []; public function __construct(private Container $app) { @@ -32,6 +33,7 @@ public function begin(array $data = []): void $this->request = null; $this->headerIdentity = false; $this->resolved = false; + $this->loggedResolverFailures = []; } public function end(): void @@ -58,13 +60,15 @@ public function data(): array if (!$explicitId && $resolver) { try { $id = ($this->app->make($resolver))($this->app); - } catch (Throwable) { + } catch (Throwable $exception) { + $this->logResolverFailure('distinct_id', $exception); } } if ($id === null && $this->request !== null) { try { $id = $this->request->user()?->getAuthIdentifier(); - } catch (Throwable) { + } catch (Throwable $exception) { + $this->logResolverFailure('authenticated user', $exception); } } if (is_scalar($id) && (string) $id !== '') { @@ -77,12 +81,28 @@ public function data(): array if (is_array($groups)) { $data['groups'] = array_replace($groups, $data['groups'] ?? []); } - } catch (Throwable) { + } catch (Throwable $exception) { + $this->logResolverFailure('groups', $exception); } } return $data; } + private function logResolverFailure(string $resolver, Throwable $exception): void + { + if (!$this->app['config']->get('posthog.sdk.debug', false) || isset($this->loggedResolverFailures[$resolver])) { + return; + } + $this->loggedResolverFailures[$resolver] = true; + try { + $this->app->make('log')->debug('[PostHog] Failed to resolve ' . $resolver . ' context.', [ + 'exception_class' => $exception::class, + ]); + } catch (Throwable) { + // A failing application logger must not interrupt analytics context fallback. + } + } + public function snapshot(): array { return $this->data(); @@ -95,7 +115,14 @@ public function with(array $data, callable $callback): mixed $request = $this->request; $headerIdentity = $this->headerIdentity; $resolved = $this->resolved; - $sessionId = $data['sessionId'] ?? $data['session_id'] ?? null; + $loggedResolverFailures = $this->loggedResolverFailures; + foreach (['distinct_id' => 'distinctId', 'session_id' => 'sessionId'] as $alias => $canonical) { + if (array_key_exists($alias, $data) && !array_key_exists($canonical, $data)) { + $data[$canonical] = $data[$alias]; + } + unset($data[$alias]); + } + $sessionId = $data['sessionId'] ?? null; if ( is_scalar($sessionId) && (string) $sessionId !== '' && !array_key_exists('$session_id', $data['properties'] ?? []) @@ -117,6 +144,7 @@ public function with(array $data, callable $callback): mixed $this->request = $request; $this->headerIdentity = $headerIdentity; $this->resolved = $resolved; + $this->loggedResolverFailures = $loggedResolverFailures; } } @@ -144,12 +172,18 @@ public function enterResolvedContext(array $data): ContextScope private function state(): array { - return [$this->context, $this->flags, $this->request, $this->headerIdentity, $this->resolved]; + return [ + $this->context, $this->flags, $this->request, $this->headerIdentity, + $this->resolved, $this->loggedResolverFailures, + ]; } private function restore(array $state): void { - [$this->context, $this->flags, $this->request, $this->headerIdentity, $this->resolved] = $state; + [ + $this->context, $this->flags, $this->request, $this->headerIdentity, + $this->resolved, $this->loggedResolverFailures, + ] = $state; } /** @internal */ diff --git a/packages/laravel/src/ExceptionReporter.php b/packages/laravel/src/ExceptionReporter.php index d9075173..9d3285a2 100644 --- a/packages/laravel/src/ExceptionReporter.php +++ b/packages/laravel/src/ExceptionReporter.php @@ -53,7 +53,7 @@ private function reportOptions(): array return ['handled' => false, 'mechanism' => 'runtime']; } } - return ['handled' => true, 'mechanism' => 'middleware']; + return ['handled' => true, 'mechanism' => 'generic']; } public function report(Throwable $exception, ?string $source = null): void diff --git a/packages/laravel/tests/ApiSurfaceTest.php b/packages/laravel/tests/ApiSurfaceTest.php index 4bf073ac..39740513 100644 --- a/packages/laravel/tests/ApiSurfaceTest.php +++ b/packages/laravel/tests/ApiSurfaceTest.php @@ -156,7 +156,7 @@ public function testSupportedForwardersKeepTheCoreParameterSignatures(): void { $methods = [ 'isFeatureEnabled', 'getFeatureFlag', 'getFeatureFlagResult', 'getFeatureFlagPayload', - 'getAllFlags', 'loadFlags', 'contextFromHeaders', + 'getAllFlags', 'loadFlags', 'contextFromHeaders', 'captureException', 'evaluateFlags', ]; foreach ($methods as $method) { $signature = static fn ($parameter) => [ @@ -164,8 +164,13 @@ public function testSupportedForwardersKeepTheCoreParameterSignatures(): void $parameter->isPassedByReference(), $parameter->isVariadic(), $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null, ]; + $expected = array_map($signature, (new ReflectionMethod(Client::class, $method))->getParameters()); + if ($method === 'captureException') { + $this->assertSame('additionalProperties', $expected[2][0]); + $expected[2][0] = 'properties'; + } $this->assertSame( - array_map($signature, (new ReflectionMethod(Client::class, $method))->getParameters()), + $expected, array_map($signature, (new ReflectionMethod(PostHog::class, $method))->getParameters()), $method, ); diff --git a/packages/laravel/tests/LifecycleTest.php b/packages/laravel/tests/LifecycleTest.php index d3c439f0..8eda7272 100644 --- a/packages/laravel/tests/LifecycleTest.php +++ b/packages/laravel/tests/LifecycleTest.php @@ -99,6 +99,38 @@ public function testThrowingChildKeepsItsOwnExceptionSnapshotAfterCallerCatchesI $fake->assertCaptured('parent continued', fn ($event) => $event['distinctId'] === 'parent'); } + public function testNestedCommandsDeferFlushingUntilHttpOrRootConsoleTermination(): void + { + $client = new \PostHog\Laravel\Tests\Fixtures\FlushClient(); + $manager = $this->app->make(\PostHog\Laravel\PostHog::class); + (new \ReflectionProperty($manager, 'client'))->setValue($manager, $client); + $kernel = $this->reportingKernel(); + Artisan::command('posthog:child', fn () => 0); + Artisan::command('posthog:parent', function () use ($client) { + Artisan::call('posthog:child'); + \PHPUnit\Framework\Assert::assertSame(0, $client->flushes); + return 0; + }); + Route::get('/nested-command', function () use ($client) { + Artisan::call('posthog:parent'); + $this->assertSame(0, $client->flushes); + return 'ok'; + }); + $this->get('/nested-command')->assertOk(); + $this->assertSame(1, $client->flushes); + + $client->flushes = 0; + $input = new ArrayInput(['command' => 'posthog:parent']); + $this->assertSame(0, $kernel->handle($input, new NullOutput())); + $this->assertSame(0, $client->flushes); + $kernel->terminate($input, 0); + $this->assertSame(1, $client->flushes); + + $client->flushes = 0; + $this->assertSame(0, Artisan::call('posthog:parent')); + $this->assertSame(1, $client->flushes); + } + private function reportingKernel(): ConsoleKernel { $this->app->singleton(ConsoleKernel::class, fn ($app) => new ReportingConsoleKernel($app, $app['events'])); diff --git a/packages/laravel/tests/RuntimeContextTest.php b/packages/laravel/tests/RuntimeContextTest.php index e594aa94..ff177021 100644 --- a/packages/laravel/tests/RuntimeContextTest.php +++ b/packages/laravel/tests/RuntimeContextTest.php @@ -86,6 +86,115 @@ public function testAutomaticReportingDefersFlushToLifecycleCleanup(): void $this->assertSame(0, $client->flushes); } + public function testContextAliasesPropagateToCapturesAndQueuePayloads(): void + { + $events = []; + $this->app['config']->set('posthog.sdk.before_send', function ($event) use (&$events) { + $events[] = $event; + return null; + }); + $context = $this->app->make(Context::class); + $context->fromRequest(Request::create('/', server: [ + 'HTTP_X_POSTHOG_DISTINCT_ID' => 'header-user', 'HTTP_X_POSTHOG_SESSION_ID' => 'header-session', + ])); + $request = $context->snapshot(); + $manager = $this->app->make(PostHog::class); + foreach ( + [ + ['distinct_id' => 'scoped-user', 'session_id' => 'scoped-session'], + [ + 'distinct_id' => 'ignored-user', 'session_id' => 'ignored-session', + 'distinctId' => 'scoped-user', 'sessionId' => 'scoped-session', + ], + ] as $data + ) { + $context->with($data, function () use ($context, $manager) { + $this->assertSame('scoped-user', $context->data()['distinctId']); + $this->assertSame('scoped-session', $context->data()['sessionId']); + $this->assertSame([ + 'distinctId' => 'scoped-user', 'sessionId' => 'scoped-session', + 'properties' => ['$session_id' => 'scoped-session'], + ], \PostHog\Laravel\QueueContext::payload()['_posthog']); + $manager->capture('scoped'); + }); + $this->assertSame($request, $context->snapshot()); + } + $this->assertSame(['scoped-user', 'scoped-user'], array_column($events, 'distinct_id')); + foreach ($events as $event) { + $this->assertSame('scoped-session', $event['properties']['$session_id']); + } + } + + public function testResolverDiagnosticsAreDebugOnlyBoundedAndExcludeSensitiveMessages(): void + { + $messages = []; + $logger = $this->createMock(\Psr\Log\LoggerInterface::class); + $logger->expects($this->exactly(9))->method('debug')->willReturnCallback( + function ($message, $data) use (&$messages) { + $messages[] = [$message, $data]; + }, + ); + $this->app->instance('log', $logger); + $this->app->bind('bad-identity', fn () => fn () => throw new RuntimeException('secret-token')); + $this->app->bind('bad-groups', fn () => fn () => throw new RuntimeException('private-groups')); + $this->app['config']->set('posthog.distinct_id', 'bad-identity'); + $this->app['config']->set('posthog.groups', 'bad-groups'); + $request = Request::create('/'); + $request->setUserResolver(fn () => throw new RuntimeException('private-user')); + $context = $this->app->make(Context::class); + $context->fromRequest($request); + $context->data(); + $this->assertSame([], $messages); + $this->app['config']->set('posthog.sdk.debug', true); + for ($i = 0; $i < 3; $i++) { + $context->data(); + } + $this->assertCount(3, $messages); + foreach ($messages as [$message, $data]) { + $this->assertStringContainsString('[PostHog]', $message); + $this->assertSame(['exception_class' => RuntimeException::class], $data); + } + $this->assertStringNotContainsString('secret-token', json_encode($messages)); + $this->assertStringNotContainsString('private-', json_encode($messages)); + $context->with([], fn () => $context->data()); + $this->assertCount(6, $messages); + $context->data(); + $this->assertCount(6, $messages); + $context->fromRequest($request); + $context->data(); + $this->assertCount(9, $messages); + } + + public function testFailingDiagnosticLoggerDoesNotPreventAuthFallback(): void + { + $logger = $this->createMock(\Psr\Log\LoggerInterface::class); + $logger->expects($this->once())->method('debug')->willThrowException(new RuntimeException('logger')); + $this->app->instance('log', $logger); + $this->app['config']->set('posthog.sdk.debug', true); + $this->app->bind('bad-identity', fn () => fn () => throw new RuntimeException('resolver')); + $this->app['config']->set('posthog.distinct_id', 'bad-identity'); + $request = Request::create('/'); + $request->setUserResolver(fn () => new GenericUser(['id' => 'authenticated'])); + $context = $this->app->make(Context::class); + $context->fromRequest($request); + $this->assertSame('authenticated', $context->data()['distinctId']); + $this->assertSame('authenticated', $context->data()['distinctId']); + } + + public function testManualReportUsesGenericMechanismWithoutInventingAMiddlewareBoundary(): void + { + $events = []; + $this->app['config']->set('posthog.sdk.before_send', function ($event) use (&$events) { + $events[] = $event; + return null; + }); + report(new RuntimeException('manual')); + $this->assertCount(1, $events); + $this->assertSame('generic', $events[0]['properties']['$exception_list'][0]['mechanism']['type']); + $this->assertTrue($events[0]['properties']['$exception_list'][0]['mechanism']['handled']); + $this->assertSame('laravel.reportable', $events[0]['properties']['$exception_source']); + } + public function testInvokableErrorHandlerConstructsRealLibCurlConsumer(): void { $this->app['config']->set('posthog.sdk.consumer', 'lib_curl'); diff --git a/packages/laravel/tests/RuntimeTest.php b/packages/laravel/tests/RuntimeTest.php index 71cda507..93991f1c 100644 --- a/packages/laravel/tests/RuntimeTest.php +++ b/packages/laravel/tests/RuntimeTest.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Route; use Orchestra\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use PostHog\Client; use PostHog\HttpClient; use PostHog\HttpResponse; @@ -217,11 +218,13 @@ public function testRealRouteFailuresAreUnhandledButManualReportRemainsHandled() $fake->assertExceptionCaptured( fn ($message) => $message['distinctId'] === 'failed-user' && $message['options']['handled'] === false + && $message['options']['mechanism'] === 'middleware' && ($message['options']['source'] ?? null) === 'laravel.reportable' ); $fake->assertExceptionCaptured( fn ($message) => $message['distinctId'] === 'manual-user' && $message['options']['handled'] === true + && $message['options']['mechanism'] === 'generic' && ($message['options']['source'] ?? null) === 'laravel.reportable' ); } @@ -555,6 +558,70 @@ public function testFakeForwardsAllMessageApisAndAssertions(): void $this->assertFalse($fake->client()->capture(['event' => 'escape', 'distinctId' => 'user'])); } + public static function invalidClientOptions(): array + { + return [ + 'consumer' => [['consumer' => 'invalid-consumer']], + 'provider' => [['consumer' => 'noop', 'flag_definition_cache_provider' => \stdClass::class]], + ]; + } + + #[DataProvider('invalidClientOptions')] + public function testDoctorRejectsInvalidClientOptionsWithoutLeakingSecrets(array $options): void + { + $this->app['config']->set('posthog.sdk', $options); + $this->app->instance(PostHog::class, new PostHog($this->app)); + $this->assertSame(1, \Illuminate\Support\Facades\Artisan::call('posthog:doctor')); + $output = \Illuminate\Support\Facades\Artisan::output(); + $this->assertStringContainsString('PostHog client initialization failed', $output); + $this->assertStringContainsString('posthog.sdk', $output); + $this->assertStringNotContainsString('project-key', $output); + } + + public function testUncachedDefinitionsLoadOncePerScopedManagerNotOncePerWorker(): void + { + $this->app['config']->set('posthog.secret_key', 'private-secret'); + $this->app['config']->set('posthog.flag_cache.refresh_interval', 3600); + $http = $this->createMock(HttpClient::class); + $http->expects($this->exactly(3))->method('sendRequest')->willReturnCallback(function ($path) { + $this->assertStringStartsWith('/flags/definitions?', $path); + return new HttpResponse(json_encode(['flags' => []]), 200); + }); + for ($i = 0; $i < 3; $i++) { + $this->app->forgetScopedInstances(); + $manager = $this->app->make(PostHog::class); + $client = new Client( + 'project-key', + ['consumer' => 'noop'], + $http, + loadFeatureFlags: false, + secretKey: 'private-secret', + ); + (new \ReflectionProperty(PostHog::class, 'client'))->setValue($manager, $client); + for ($read = 0; $read < 2; $read++) { + $this->assertSame([], $manager->evaluateFlags('user', onlyEvaluateLocally: true)->getKeys()); + } + } + } + + public function testDoctorWarnsAboutUncachedDefinitionsWithoutFetching(): void + { + $this->app['config']->set('posthog.secret_key', 'private-secret'); + $manager = $this->getMockBuilder(PostHog::class) + ->setConstructorArgs([$this->app]) + ->onlyMethods(['loadFlags']) + ->getMock(); + $manager->expects($this->never())->method('loadFlags'); + $this->app->instance(PostHog::class, $manager); + $this->assertSame(0, \Illuminate\Support\Facades\Artisan::call('posthog:doctor')); + $output = \Illuminate\Support\Facades\Artisan::output(); + $this->assertStringContainsString('each request or job', $output); + $this->assertStringNotContainsString('private-secret', $output); + $this->app['config']->set('posthog.flag_cache.enabled', true); + $this->assertSame(0, \Illuminate\Support\Facades\Artisan::call('posthog:doctor')); + $this->assertStringNotContainsString('each request or job', \Illuminate\Support\Facades\Artisan::output()); + } + public function testCommandsNeverPrintKeysAndRequireExplicitSending(): void { $this->artisan('posthog:doctor')->expectsOutput(