diff --git a/.changeset/gold-cats-jump.md b/.changeset/gold-cats-jump.md new file mode 100644 index 0000000..54fb092 --- /dev/null +++ b/.changeset/gold-cats-jump.md @@ -0,0 +1,5 @@ +--- +"posthog-php": patch +--- + +Generate personless distinct IDs with UUID v7. diff --git a/composer.json b/composer.json index f1115d6..c9e6d00 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,10 @@ "autoload": { "psr-4": { "PostHog\\": "lib/" - } + }, + "files": [ + "lib/Uuid.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/composer.lock b/composer.lock index e4ade16..c17e233 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c11900659348ffada66c82406a3d8c6c", + "content-hash": "14cfceee7677b783774cabf54cf62c97", "packages": [ { "name": "psr/clock", diff --git a/lib/Client.php b/lib/Client.php index 7dccb58..f6b3404 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -1899,7 +1899,7 @@ private function applyCaptureContext(array $msg, bool &$usedGeneratedPersonlessD } if (!$explicitDistinctId) { - $msg["distinct_id"] = Uuid::v4(); + $msg["distinct_id"] = uuidV7(); $usedGeneratedPersonlessDistinctId = true; if (!array_key_exists('$process_person_profile', $msg["properties"])) { $msg["properties"]['$process_person_profile'] = false; diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php index 32c18c6..2cb6f58 100644 --- a/lib/ExceptionCapture.php +++ b/lib/ExceptionCapture.php @@ -433,7 +433,7 @@ private static function sendExceptionEvent( $distinctId = $providerContext['distinctId']; if ($distinctId === null) { - $distinctId = Uuid::v4(); + $distinctId = uuidV7(); $properties['$process_person_profile'] = false; } diff --git a/lib/Uuid.php b/lib/Uuid.php index 064a6a4..4531559 100644 --- a/lib/Uuid.php +++ b/lib/Uuid.php @@ -30,3 +30,38 @@ public static function v4(): string ); } } + +/** + * Generate a UUID v7 string using the RFC 9562 Unix timestamp layout. + * + * PHP does not provide a built-in UUID API in the SDK's supported runtimes, so + * this internal helper keeps UUID v7 generation local without adding a runtime dependency. + * + * @internal + * @return string UUID v7. + * @throws \Random\RandomException When random_bytes() cannot gather sufficient entropy. + */ +function uuidV7(): string +{ + $bytes = random_bytes(16); + $timestamp = (int) floor(microtime(true) * 1000); + + for ($i = 5; $i >= 0; --$i) { + $bytes[$i] = chr($timestamp & 0xff); + $timestamp >>= 8; + } + + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x70); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + + $hex = bin2hex($bytes); + + return sprintf( + '%s-%s-%s-%s-%s', + substr($hex, 0, 8), + substr($hex, 8, 4), + substr($hex, 12, 4), + substr($hex, 16, 4), + substr($hex, 20, 12) + ); +} diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index bae5752..7908d22 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -169,7 +169,7 @@ public function testExceptionHandlerCapturesFlushesAndChainsPreviousHandler(): v $this->assertSame('RuntimeException', $event['properties']['$exception_list'][0]['type']); $this->assertFalse($event['properties']['$process_person_profile']); $this->assertMatchesRegularExpression( - '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + '/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $event['distinct_id'] ); } finally { diff --git a/test/ExceptionPayloadBuilderTest.php b/test/ExceptionPayloadBuilderTest.php index 4718ba0..edcb13f 100644 --- a/test/ExceptionPayloadBuilderTest.php +++ b/test/ExceptionPayloadBuilderTest.php @@ -396,9 +396,9 @@ public function testCaptureExceptionWithoutDistinctIdGeneratesUuidAndSetsNoProfi $payload = json_decode($batchCall['payload'], true); $event = $payload['batch'][0]; - // distinct_id should look like a UUID + // distinct_id should look like a UUID v7 $this->assertMatchesRegularExpression( - '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + '/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $event['distinct_id'] ); $this->assertFalse($event['properties']['$process_person_profile']); diff --git a/test/RequestContextTest.php b/test/RequestContextTest.php index 7b025f3..ef49269 100644 --- a/test/RequestContextTest.php +++ b/test/RequestContextTest.php @@ -113,7 +113,10 @@ public function testMissingDistinctIdCreatesPersonlessEvent(): void $event = $this->flushAndGetEvents()[0]; $this->assertIsString($event['distinct_id']); - $this->assertNotSame('', $event['distinct_id']); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $event['distinct_id'] + ); $this->assertFalse($event['properties']['$process_person_profile']); $this->assertSame('free', $event['properties']['plan']); } diff --git a/test/UuidTest.php b/test/UuidTest.php new file mode 100644 index 0000000..7594479 --- /dev/null +++ b/test/UuidTest.php @@ -0,0 +1,43 @@ +assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $uuid + ); + } + + public function testV7EmbedsCurrentTimestamp(): void + { + $before = (int) floor(microtime(true) * 1000); + $uuid = uuidV7(); + $after = (int) floor(microtime(true) * 1000); + + $timestamp = hexdec(substr(str_replace('-', '', $uuid), 0, 12)); + + $this->assertGreaterThanOrEqual($before, $timestamp); + $this->assertLessThanOrEqual($after, $timestamp); + } + + public function testV7GeneratesUniqueValues(): void + { + $uuids = []; + + for ($i = 0; $i < 100; ++$i) { + $uuid = uuidV7(); + $this->assertArrayNotHasKey($uuid, $uuids); + $uuids[$uuid] = true; + } + } +}