From 740580e4c2a3a32157998af868ac825ef1e0e0cd Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Wed, 23 Sep 2026 10:33:28 +0200 Subject: [PATCH 1/2] feat: send $release_id from POSTHOG_RELEASE_ID on every event Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/release-id-env.md | 5 ++ lib/Client.php | 20 +++++ lib/PostHog.php | 3 +- test/ReleaseIdTest.php | 170 +++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 .changeset/release-id-env.md create mode 100644 test/ReleaseIdTest.php diff --git a/.changeset/release-id-env.md b/.changeset/release-id-env.md new file mode 100644 index 0000000..868e713 --- /dev/null +++ b/.changeset/release-id-env.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Read the release id from the `POSTHOG_RELEASE_ID` environment variable and send it as `$release_id` on every event, including minimal `$feature_flag_called` events. On `$exception` events, error tracking uses it to link the exception to its release by a direct id lookup. Create the release and get its id with `posthog-cli release resolve`. An explicit `$release_id` in the event properties or in the request context wins over the environment variable. Under PHP-FPM, pass the variable through to workers (`clear_env = no` or an `env[POSTHOG_RELEASE_ID]` pool entry). diff --git a/lib/Client.php b/lib/Client.php index 5ba8a2b..84c6b6b 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -19,6 +19,8 @@ class Client implements FeatureFlagEvaluationsHost { private const SIZE_LIMIT = 50_000; + private const ENV_RELEASE_ID = "POSTHOG_RELEASE_ID"; + /** * Allowlist of event properties kept when a $feature_flag_called event is minimized. When the * server-controlled minimal_flag_called_events gate is on and the flag reports @@ -46,6 +48,7 @@ class Client implements FeatureFlagEvaluationsHost '$lib', '$lib_version', '$is_server', + '$release_id', ]; private const CONSUMERS = [ @@ -178,6 +181,15 @@ class Client implements FeatureFlagEvaluationsHost */ private $shutdownComplete; + /** + * Release id from POSTHOG_RELEASE_ID, sent as $release_id on every event. A PHP app has no + * bundle to inject a release into, so a deploy step creates the release with + * `posthog-cli release resolve` and starts the app with the printed id in the environment. + * + * @var string|null + */ + private ?string $releaseId; + /** * @var bool */ @@ -203,6 +215,8 @@ class Client implements FeatureFlagEvaluationsHost * * @param string|null $apiKey Your project API key. When omitted or empty, the client is disabled * and uses the noop consumer. + * When POSTHOG_RELEASE_ID is set at construction, its value is sent as $release_id on every + * event unless the event already carries one. * Time-based options use milliseconds unless the option name says otherwise: * `timeout` defaults to 10000ms, `feature_flag_request_timeout_ms` defaults to 3000ms, * and `maximum_backoff_duration` defaults to 10000ms for retry backoff. Retry backoff starts @@ -269,6 +283,8 @@ public function __construct( ); $this->flagDefinitionCacheProviderShutdown = false; $this->shutdownComplete = false; + $envReleaseId = getenv(self::ENV_RELEASE_ID); + $this->releaseId = $envReleaseId === false ? null : StringNormalizer::normalizeOptional($envReleaseId); $this->options['host'] = StringNormalizer::normalizeHost($options['host'] ?? null); if (!$this->enabled) { if (($this->options['consumer'] ?? null) !== 'noop') { @@ -2339,6 +2355,10 @@ private function message($msg) : $this->consumer->getConsumer(); } + if ($this->releaseId !== null && !array_key_exists('$release_id', $msg["properties"])) { + $msg["properties"]['$release_id'] = $this->releaseId; + } + // When running as a server SDK (the default), tag events as server-side so // PostHog does not attribute the host machine's device/OS to the event. // Set the `is_server` option to false when using posthog-php as a diff --git a/lib/PostHog.php b/lib/PostHog.php index 113c3fe..5b6ee1d 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -21,7 +21,8 @@ class PostHog * * When $apiKey is omitted or blank, POSTHOG_API_KEY is used when present. When no * non-empty API key can be resolved, a disabled no-op client is initialized. When the - * host option is omitted, POSTHOG_HOST is used when present. + * host option is omitted, POSTHOG_HOST is used when present. When POSTHOG_RELEASE_ID is set, + * its value is sent as $release_id on every event unless the event already carries one. * * @param string|null $apiKey Your project API key. * Time-based options use milliseconds unless the option name says otherwise: diff --git a/test/ReleaseIdTest.php b/test/ReleaseIdTest.php new file mode 100644 index 0000000..76b001d --- /dev/null +++ b/test/ReleaseIdTest.php @@ -0,0 +1,170 @@ +previousReleaseId = getenv(self::ENV_RELEASE_ID); + } + + public function tearDown(): void + { + $this->setReleaseIdEnv($this->previousReleaseId === false ? null : $this->previousReleaseId); + } + + public static function eventCases(): array + { + return [ + 'capture' => ['Module PHP Event', static function (Client $client): void { + $client->capture(["distinctId" => "john", "event" => "Module PHP Event"]); + }], + 'captureException' => ['$exception', static function (Client $client): void { + $client->captureException(new RuntimeException("boom"), "john"); + }], + 'identify' => ['$identify', static function (Client $client): void { + $client->identify(["distinctId" => "john", "properties" => ["email" => "john@example.com"]]); + }], + 'alias' => ['$create_alias', static function (Client $client): void { + $client->alias(["distinctId" => "john", "alias" => "anonymous-id"]); + }], + 'groupIdentify' => ['$groupidentify', static function (Client $client): void { + PostHog::init(null, null, $client); + PostHog::groupIdentify(["groupType" => "company", "groupKey" => "id:5"]); + }], + 'full $feature_flag_called' => ['$feature_flag_called', static function (Client $client): void { + $client->evaluateFlags('john')->isEnabled('simple-test'); + }], + 'minimal $feature_flag_called' => ['$feature_flag_called', static function (Client $client): void { + $client->evaluateFlags('john')->isEnabled('simple-test'); + }, true], + ]; + } + + #[DataProvider('eventCases')] + public function testReleaseIdIsSentOnEveryEvent( + string $expectedEvent, + callable $send, + bool $minimalFlagCalledEvents = false + ): void { + $this->setReleaseIdEnv("rel-123"); + $flagsResponse = MockedResponses::FLAGS_V2_RESPONSE; + if ($minimalFlagCalledEvents) { + $flagsResponse['minimalFlagCalledEvents'] = true; + $flagsResponse['flags']['simple-test']['metadata']['has_experiment'] = false; + } + $client = $this->createClient($flagsResponse); + + $send($client); + $client->flush(); + + $event = $this->onlyBatchEvent(); + $this->assertSame($expectedEvent, $event['event']); + $this->assertSame("rel-123", $event['properties']['$release_id']); + if ($minimalFlagCalledEvents) { + // Proves the allowlist ran, so the release id survived minimization rather than skipping it. + $this->assertArrayNotHasKey('$lib_consumer', $event['properties']); + } + } + + public static function envValueCases(): array + { + return [ + 'set' => ["rel-123", "rel-123"], + 'surrounding whitespace is trimmed' => [" rel-123 \n", "rel-123"], + 'empty counts as unset' => ["", null], + 'whitespace only counts as unset' => [" \t ", null], + 'unset' => [null, null], + ]; + } + + #[DataProvider('envValueCases')] + public function testReleaseIdFollowsEnvValue(?string $envValue, ?string $expected): void + { + $this->setReleaseIdEnv($envValue); + $client = $this->createClient(); + + $client->capture(["distinctId" => "john", "event" => "Module PHP Event"]); + $client->flush(); + + $properties = $this->onlyBatchEvent()['properties']; + if ($expected === null) { + $this->assertArrayNotHasKey('$release_id', $properties); + } else { + $this->assertSame($expected, $properties['$release_id']); + } + } + + public static function explicitReleaseIdCases(): array + { + return [ + 'event property' => [static function (Client $client): void { + $client->capture([ + "distinctId" => "john", + "event" => "Module PHP Event", + "properties" => ['$release_id' => "explicit"], + ]); + }], + 'request context property' => [static function (Client $client): void { + $client->withContext( + ["properties" => ['$release_id' => "explicit"]], + static function () use ($client): void { + $client->capture(["distinctId" => "john", "event" => "Module PHP Event"]); + } + ); + }], + ]; + } + + #[DataProvider('explicitReleaseIdCases')] + public function testExplicitReleaseIdWinsOverEnv(callable $send): void + { + $this->setReleaseIdEnv("rel-123"); + $client = $this->createClient(); + + $send($client); + $client->flush(); + + $this->assertSame("explicit", $this->onlyBatchEvent()['properties']['$release_id']); + } + + private function createClient(array $flagsResponse = MockedResponses::FLAGS_V2_RESPONSE): Client + { + $this->httpClient = new MockedHttpClient("app.posthog.com", flagsEndpointResponse: $flagsResponse); + + return new Client(self::FAKE_API_KEY, [], $this->httpClient, null, false); + } + + private function onlyBatchEvent(): array + { + $events = []; + foreach ($this->httpClient->calls as $call) { + if (($call["path"] ?? null) === "/batch/") { + $events = array_merge($events, json_decode($call["payload"], true)["batch"]); + } + } + $this->assertCount(1, $events); + + return $events[0]; + } + + private function setReleaseIdEnv(?string $value): void + { + putenv($value === null ? self::ENV_RELEASE_ID : self::ENV_RELEASE_ID . "=" . $value); + } +} From 0c62123502b292cc83abd4b1880a7acae3ac0e44 Mon Sep 17 00:00:00 2001 From: ablaszkiewicz Date: Wed, 23 Sep 2026 12:33:19 +0200 Subject: [PATCH 2/2] test: clear the facade client after each release id test Co-Authored-By: Claude Opus 5.5 (1M context) --- test/ReleaseIdTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/ReleaseIdTest.php b/test/ReleaseIdTest.php index 76b001d..24db7af 100644 --- a/test/ReleaseIdTest.php +++ b/test/ReleaseIdTest.php @@ -7,6 +7,7 @@ use PostHog\Client; use PostHog\PostHog; use PostHog\Test\Assets\MockedResponses; +use ReflectionProperty; use RuntimeException; class ReleaseIdTest extends TestCase @@ -26,6 +27,9 @@ public function setUp(): void public function tearDown(): void { $this->setReleaseIdEnv($this->previousReleaseId === false ? null : $this->previousReleaseId); + // The groupIdentify case installs its client in the facade, and a later test that uses the + // facade without calling init would otherwise send through it. + (new ReflectionProperty(PostHog::class, 'client'))->setValue(null, null); } public static function eventCases(): array