From 34e07100d9ce06f2c56312a9d1072a59574ffed8 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 09:25:09 +0100 Subject: [PATCH 1/6] 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 8773c4377aace83707198b15581925138b740d31 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 11:42:44 +0100 Subject: [PATCH 2/6] 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 3/6] 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 a33384afc1a1babfb8c17007f48ca8a56c0db05c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 13:05:06 +0100 Subject: [PATCH 4/6] 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:23:08 +0100 Subject: [PATCH 5/6] 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 858c20496e1df664ff275417875e05b34a8fca14 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 29 Oct 2026 17:04:26 +0100 Subject: [PATCH 6/6] 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);