diff --git a/.changeset/quiet-badgers-listen.md b/.changeset/quiet-badgers-listen.md new file mode 100644 index 0000000..6557d72 --- /dev/null +++ b/.changeset/quiet-badgers-listen.md @@ -0,0 +1,5 @@ +--- +"posthog-php": patch +--- + +Omit null custom event property members when sending or saving events while preserving array positions, empty objects, and typed event metadata. diff --git a/api/public-api.json b/api/public-api.json index d2c9f7d..45ba353 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -1741,6 +1741,84 @@ } } }, + "PostHog\\EventSerializer": { + "type": "class", + "abstract": false, + "final": true, + "readonly": false, + "extends": null, + "implements": [], + "constants": [], + "properties": [], + "methods": { + "decode": { + "static": true, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "json", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "error", + "type": "?string", + "byReference": true, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + }, + "encode": { + "static": true, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "payload", + "type": null, + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + }, + { + "name": "batch", + "type": "bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": false, + "defaultConstant": null, + "hasDefault": true + }, + { + "name": "error", + "type": "?string", + "byReference": true, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true + } + ] + } + } + }, "PostHog\\ExceptionCapture": { "type": "class", "abstract": false, @@ -3316,6 +3394,53 @@ } } }, + "PostHog\\JsonObject": { + "type": "class", + "abstract": false, + "final": true, + "readonly": false, + "extends": null, + "implements": [ + "JsonSerializable" + ], + "constants": [], + "properties": { + "members": { + "static": false, + "readonly": false, + "type": "array", + "default": null, + "hasDefault": false + } + }, + "methods": { + "__construct": { + "static": false, + "abstract": false, + "final": false, + "returnType": null, + "parameters": [ + { + "name": "members", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, + "jsonSerialize": { + "static": false, + "abstract": false, + "final": false, + "returnType": "mixed", + "parameters": [] + } + } + }, "PostHog\\PostHog": { "type": "class", "abstract": false, diff --git a/bin/posthog b/bin/posthog index 49d8d40..16e54f8 100755 --- a/bin/posthog +++ b/bin/posthog @@ -141,7 +141,12 @@ function parse_json($input) return null; } - return json_decode($input, true); + // The client expects an array container; nested JSON objects must retain their identity. + $decoded = \PostHog\EventSerializer::decode($input, $decodeError); + if ($decodeError !== null) { + error('Failed to decode properties: ' . $decodeError); + } + return $decoded; } function parse_timestamp($input) diff --git a/lib/Consumer/File.php b/lib/Consumer/File.php index dc4f6e3..8e932d1 100644 --- a/lib/Consumer/File.php +++ b/lib/Consumer/File.php @@ -4,6 +4,7 @@ use Exception; use PostHog\Consumer; +use PostHog\EventSerializer; /** * Consumer that writes analytics messages to a local file. @@ -101,7 +102,11 @@ private function write($body) return false; } - $content = json_encode($body); + $content = EventSerializer::encode($body, false, $error); + if ($content === false) { + $this->handleError(json_last_error(), "Failed to encode event payload: " . $error); + return false; + } $content .= "\n"; return fwrite($this->file_handle, $content) == strlen($content); diff --git a/lib/EventSerializer.php b/lib/EventSerializer.php new file mode 100644 index 0000000..ea72a0b --- /dev/null +++ b/lib/EventSerializer.php @@ -0,0 +1,140 @@ +members['batch'] as $event) { + self::cleanEvent($event); + } + } else { + self::cleanEvent($decoded); + } + + $json = json_encode($decoded); + if ($json === false) { + $error = json_last_error_msg(); + } + return $json; + } + + /** Decode a file/CLI array envelope; $error distinguishes failure from a JSON null value. */ + public static function decode(string $json, ?string &$error = null) + { + $decoded = self::decodeTree($json, 512, $error); + return $decoded instanceof JsonObject ? $decoded->members : $decoded; + } + + private static function decodeTree(string $json, int $depth, ?string &$error) + { + $error = null; + // Match complete string tokens, never a quote inside a value string. Prefix ALL keys + // bijectively so even NUL-prefixed names are representable by native stdClass decoding. + $prefixed = preg_replace_callback('/"(?:[^"\\\\]++|\\\\.)*+"(\s*:)?/s', static function ($match) { + return isset($match[1]) ? '"_' . substr($match[0], 1) : $match[0]; + }, $json); + if ($prefixed === null) { + $error = 'JSON key transform failed: ' . preg_last_error_msg(); + return null; + } + $decoded = json_decode($prefixed, false, $depth); + if (json_last_error() !== JSON_ERROR_NONE) { + $error = json_last_error_msg(); + return null; + } + return self::restoreKeys($decoded); + } + + private static function restoreKeys($value) + { + if ($value instanceof \stdClass) { + $members = []; + foreach ($value as $key => $item) { + $members[substr($key, 1)] = self::restoreKeys($item); + } + return new JsonObject($members); + } + return is_array($value) ? array_map([self::class, 'restoreKeys'], $value) : $value; + } + + private static function cleanEvent($event): void + { + if (!$event instanceof JsonObject) { + return; + } + + $properties = $event->members['properties'] ?? null; + if ($properties instanceof JsonObject) { + foreach ($properties->members as $key => $value) { + $name = $event->members['event'] ?? null; + // Preserve only producer-backed typed metadata on its own event type. + if ( + ($name === '$exception' && $key === '$exception_list') || + ($name === '$feature_flag_called' && $key === '$feature_flag_response') + ) { + continue; + } + if ($value === null) { + unset($properties->members[$key]); + } else { + $properties->members[$key] = self::cleanValue($value); + } + } + } elseif (is_array($properties)) { + $event->members['properties'] = self::cleanValue($properties); + } + + // Identify also carries custom person properties outside the properties envelope. + foreach (['$set', '$set_once', '$group_set'] as $key) { + if (isset($event->members[$key])) { + $event->members[$key] = self::cleanValue($event->members[$key]); + } + } + } + + private static function cleanValue($value) + { + if ($value instanceof JsonObject) { + foreach ($value->members as $key => $item) { + if ($item === null) { + unset($value->members[$key]); + } else { + $value->members[$key] = self::cleanValue($item); + } + } + } elseif (is_array($value)) { + return array_map([self::class, 'cleanValue'], $value); + } + + return $value; + } +} diff --git a/lib/JsonObject.php b/lib/JsonObject.php new file mode 100644 index 0000000..08d38be --- /dev/null +++ b/lib/JsonObject.php @@ -0,0 +1,29 @@ +members as $key => $_) { + // A leading-NUL string key guarantees this array is not a list. Casting it to + // stdClass would make json_encode silently skip the member as non-public. + if (is_string($key) && str_starts_with($key, "\0")) { + return $this->members; + } + } + + // Retain object identity even after cleanup leaves no keys or only consecutive integers. + return (object) $this->members; + } +} diff --git a/lib/QueueConsumer.php b/lib/QueueConsumer.php index f28a814..ceaf1e1 100644 --- a/lib/QueueConsumer.php +++ b/lib/QueueConsumer.php @@ -209,9 +209,9 @@ private function now(): float */ protected function encodeBatchPayload($batch) { - $payload = json_encode($this->payload($batch)); + $payload = EventSerializer::encode($this->payload($batch), true, $error); if (false === $payload) { - $this->handleError(json_last_error(), "Failed to encode batch payload: " . json_last_error_msg()); + $this->handleError(json_last_error(), "Failed to encode batch payload: " . $error); return false; } diff --git a/send.php b/send.php index 0ec2348..f128e23 100755 --- a/send.php +++ b/send.php @@ -12,6 +12,11 @@ * Args */ +if (in_array('--help', $argv, true)) { + print("Usage: php send.php --apiKey KEY --file FILE [--host HOST]\n"); + exit(0); +} + $args = parse($argv); /** @@ -54,13 +59,15 @@ * Initialize the client. */ -PostHog::init($args["apiKey"], array( +$options = array( "debug" => true, "error_handler" => function($code, $msg){ print("$code: $msg\n"); exit(1); } -)); +); +if (isset($args["host"])) $options["host"] = $args["host"]; +PostHog::init($args["apiKey"], $options); /** * Payloads @@ -70,7 +77,12 @@ $successful = 0; foreach ($lines as $line) { if (!trim($line)) continue; - $payload = json_decode($line, true); + // Keep the public array envelope without turning nested JSON objects into lists. + $payload = \PostHog\EventSerializer::decode($line, $decodeError); + if ($decodeError !== null) { + print("Failed to decode event payload: $decodeError\n"); + exit(1); + } $ret = call_user_func_array(array("PostHog\\PostHog", "raw"), array($payload)); if ($ret) $successful++; $total++; diff --git a/test/NullPropertyLoopbackTest.php b/test/NullPropertyLoopbackTest.php new file mode 100644 index 0000000..fcb750c --- /dev/null +++ b/test/NullPropertyLoopbackTest.php @@ -0,0 +1,274 @@ +directory = __DIR__ . '/null-loopback-' . bin2hex(random_bytes(8)); + mkdir($this->directory); + $socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); + $this->assertIsResource($socket); + $address = stream_socket_get_name($socket, false); + fclose($socket); + $this->host = 'http://' . $address; + $this->environment = array_merge(getenv(), [ + 'POSTHOG_TEST_HOST' => $this->host, + 'POSTHOG_TEST_BODIES' => $this->directory . '/bodies.ndjson', + 'TMPDIR' => $this->directory, + 'http_proxy' => '', 'https_proxy' => '', 'all_proxy' => '', + 'HTTP_PROXY' => '', 'HTTPS_PROXY' => '', 'ALL_PROXY' => '', + 'NO_PROXY' => '*', 'no_proxy' => '*', + ]); + unset($this->environment['PHP_CLI_SERVER_WORKERS']); + $this->server = proc_open( + [PHP_BINARY, '-S', $address, __DIR__ . '/fixtures/null-property-loopback-router.php'], + $this->descriptors(), + $pipes, + dirname(__DIR__), + $this->environment + ); + $this->assertIsResource($this->server); + $deadline = microtime(true) + 5; + do { + $ready = @file_get_contents($this->host . '/ready', false, stream_context_create([ + 'http' => ['timeout' => 0.1], + ])); + if ($ready === 'ready') { + return; + } + usleep(10_000); + } while (microtime(true) < $deadline); + $this->fail('Loopback receiver did not start'); + } + + protected function tearDown(): void + { + if (is_resource($this->server)) { + proc_terminate($this->server); + proc_close($this->server); + } + foreach (glob($this->directory . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($this->directory); + } + + private function descriptors(): array + { + return [ + 0 => ['file', '/dev/null', 'r'], + 1 => ['file', $this->directory . '/stdout', 'a'], + 2 => ['file', $this->directory . '/stderr', 'a'], + ]; + } + + private function runPhp(array $arguments, int $expectedExit = 0): void + { + $command = array_merge([ + PHP_BINARY, '-d', 'auto_prepend_file=' . __DIR__ . '/fixtures/null-property-egress-guard.php', + ], $arguments); + $process = proc_open($command, $this->descriptors(), $pipes, dirname(__DIR__), $this->environment); + $this->assertIsResource($process); + try { + $deadline = microtime(true) + 15; + do { + $status = proc_get_status($process); + if (!$status['running']) { + $this->assertSame( + $expectedExit, + $status['exitcode'], + file_get_contents($this->directory . '/stderr') + ); + return; + } + usleep(10_000); + } while (microtime(true) < $deadline); + $this->fail('SDK subprocess exceeded timeout'); + } finally { + proc_terminate($process); + proc_close($process); + } + } + + private function events(): array + { + $lines = file($this->directory . '/bodies.ndjson', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $events = []; + foreach ($lines as $line) { + $body = json_decode($line); + $this->assertSame('fake-key', $body->api_key); + array_push($events, ...$body->batch); + } + return $events; + } + + public function testSenderDefaultHostIsUnchangedAndGuardDeniesEgress(): void + { + $path = $this->directory . '/events.ndjson'; + file_put_contents($path, '{"event":"guard-check","properties":{}}' . "\n"); + $this->runPhp(['send.php', '--apiKey', 'fake-key', '--file', $path], 255); + $this->assertStringContainsString( + 'Non-loopback or unexpected SDK request denied: https://us.i.posthog.com/batch/', + file_get_contents($this->directory . '/stderr') + ); + $this->assertFileDoesNotExist($this->directory . '/bodies.ndjson'); + } + + public function testAllNetworkConsumersSendNormalizedBytes(): void + { + $this->runPhp([__DIR__ . '/fixtures/null-property-consumers.php']); + $events = $this->events(); + $this->assertCount(20, $events); + foreach ($events as $event) { + $properties = $event->properties; + $this->assertObjectNotHasProperty('test', $properties); + $this->assertObjectNotHasProperty('hookNull', $properties); + $this->assertSame('{}', json_encode($properties->nested)); + $this->assertSame('[null,{}]', json_encode($properties->items)); + $this->assertSame('[null,{}]', json_encode($properties->hookItems)); + } + } + + public function testSenderAndCliReportKeyTransformFailureWithoutSendingOrDestroyingFile(): void + { + $path = $this->directory . '/events.ndjson'; + $json = '{"event":"failure","properties":{"drop":null}}' . "\n"; + file_put_contents($path, $json); + $this->runPhp(['-d', 'pcre.backtrack_limit=0', 'send.php', '--apiKey', 'fake-key', + '--file', $path, '--host', $this->host], 1); + $retained = glob($this->directory . '/posthog-*.log'); + $this->assertCount(1, $retained); + $this->assertSame($json, file_get_contents($retained[0])); + $this->assertStringContainsString( + 'Failed to decode event payload: JSON key transform failed:', + file_get_contents($this->directory . '/stdout') + ); + $this->runPhp(['-d', 'pcre.backtrack_limit=0', 'bin/posthog', '--type', 'capture', + '--apiKey', 'fake-key', '--host', $this->host, '--distinctId', 'user', '--event', 'cli', + '--properties', '{"drop":null}'], 1); + $this->assertStringContainsString( + 'Failed to decode properties: JSON key transform failed:', + file_get_contents($this->directory . '/stdout') + ); + $this->assertFileDoesNotExist($this->directory . '/bodies.ndjson'); + } + + public function testGeneratedMissingFlagFileRestoresTypedResponse(): void + { + $path = $this->directory . '/flags.ndjson'; + $http = $this->createMock(\PostHog\HttpClient::class); + $http->method('sendRequest')->willReturnCallback(function ($url) { + $this->assertStringStartsWith('/flags/?', $url); + return new \PostHog\HttpResponse('{"flags":{},"errorsWhileComputingFlags":true}', 200); + }); + $client = new Client('fake-key', [ + 'consumer' => 'file', 'filename' => $path, 'host' => $this->host, + 'before_send' => static function ($event) { + $event['properties']['custom'] = ['drop' => null]; + $event['properties']['$feature/unrelated'] = null; + return $event; + }, + ], $http); + $this->assertNull($client->evaluateFlags('user')->getFlag('missing')); + unset($client); + $disk = json_decode(trim(file_get_contents($path)), true); + $this->assertArrayHasKey('$feature_flag_response', $disk['properties']); + $this->assertNull($disk['properties']['$feature_flag_response']); + $this->runPhp(['send.php', '--apiKey', 'fake-key', '--file', $path, '--host', $this->host]); + $events = $this->events(); + $this->assertCount(1, $events); + $props = $events[0]->properties; + $this->assertObjectHasProperty('$feature_flag_response', $props); + $this->assertNull($props->{'$feature_flag_response'}); + $this->assertSame('errors_while_computing_flags,flag_missing', $props->{'$feature_flag_error'}); + $this->assertSame('{}', json_encode($props->custom)); + $this->assertObjectNotHasProperty('$feature/unrelated', $props); + } + + public function testNulKeysSurviveFileSenderAndHistoricalCliIngress(): void + { + $path = $this->directory . '/events.ndjson'; + $properties = [ + "\0keep" => 1, "\0drop" => null, 'nested' => ["\0keep" => 2, "\0drop" => null], + 'emptied' => ["\0drop" => null], 'existing' => (object) [], + 'numeric' => ["\0drop" => null, '0' => 'keep'], + 'items' => [null, ["\0drop" => null]], + '_prefix' => "\0value", 'a"b\\c雪' => 3, "mid\0nul" => 4, + ]; + $client = new Client('fake-key', ['consumer' => 'file', 'filename' => $path]); + $client->capture(['event' => 'persisted', 'distinctId' => 'user', 'properties' => $properties]); + unset($client); + $disk = json_decode(trim(file_get_contents($path)), true); + $this->assertIsArray($disk); + $this->assertSame(1, $disk['properties']["\0keep"]); + $this->assertArrayNotHasKey("\0drop", $disk['properties']); + // A historical file has not yet passed through the new serializer. + file_put_contents($path, json_encode([ + 'event' => 'historical', 'distinct_id' => 'user', 'properties' => $properties, + ]) . "\n", FILE_APPEND); + $this->runPhp(['send.php', '--apiKey', 'fake-key', '--file', $path, '--host', $this->host]); + $this->runPhp(['bin/posthog', '--type', 'capture', '--apiKey', 'fake-key', '--host', $this->host, + '--distinctId', 'user', '--event', 'cli', '--properties', json_encode($properties)]); + $events = []; + foreach (file($this->directory . '/bodies.ndjson', FILE_IGNORE_NEW_LINES) as $body) { + array_push($events, ...json_decode($body, true)['batch']); + $this->assertStringContainsString('"emptied":{}', $body); + $this->assertStringContainsString('"existing":{}', $body); + $this->assertStringContainsString('"numeric":{"0":"keep"}', $body); + $this->assertStringContainsString('"items":[null,{}]', $body); + } + $this->assertCount(3, $events); + foreach ($events as $event) { + $wire = $event['properties']; + $this->assertSame(1, $wire["\0keep"]); + $this->assertSame(["\0keep" => 2], $wire['nested']); + $this->assertArrayNotHasKey("\0drop", $wire); + foreach (['_prefix', 'a"b\\c雪', "mid\0nul"] as $key) { + $this->assertSame($properties[$key], $wire[$key]); + } + } + $this->assertFileDoesNotExist($path); + } + + public function testActualSenderRestoresFileObjectsAndCliPreservesObjects(): void + { + $path = $this->directory . '/events.ndjson'; + $client = new Client('fake-key', [ + 'consumer' => 'file', 'filename' => $path, + 'before_send' => static function ($event) { + $event['properties']['hookNull'] = null; + $event['properties']['hookItems'] = [null, ['drop' => null]]; + return $event; + }, + ]); + $client->capture(['event' => 'persisted', 'distinctId' => 'user', 'properties' => [ + 'nested' => ['drop' => null], 'existing' => (object) [], 'items' => [null, ['drop' => null]], + ]]); + unset($client); + $disk = json_decode(trim(file_get_contents($path))); + $this->assertSame('{}', json_encode($disk->properties->nested)); + $this->assertObjectNotHasProperty('hookNull', $disk->properties); + $this->assertSame('[null,{}]', json_encode($disk->properties->hookItems)); + $this->runPhp(['send.php', '--apiKey', 'fake-key', '--file', $path, '--host', $this->host]); + $events = $this->events(); + $this->assertCount(1, $events); + $this->assertEquals($disk->properties, $events[0]->properties); + $this->assertFileDoesNotExist($path); + $this->runPhp(['bin/posthog', '--type', 'capture', '--apiKey', 'fake-key', '--host', $this->host, + '--distinctId', 'user', '--event', 'cli', '--properties', '{"existing":{},"nested":{"drop":null}}']); + $events = $this->events(); + $this->assertCount(2, $events); + $this->assertSame('{}', json_encode($events[1]->properties->existing)); + $this->assertSame('{}', json_encode($events[1]->properties->nested)); + } +} diff --git a/test/NullPropertySerializationTest.php b/test/NullPropertySerializationTest.php new file mode 100644 index 0000000..6eb4cf2 --- /dev/null +++ b/test/NullPropertySerializationTest.php @@ -0,0 +1,330 @@ + null, + 'nested' => ['drop' => null], + 'items' => ['1', null, 2, ['drop' => null], [null]], + 'empty' => '', 'zero' => 0, 'enabled' => false, + 'literal' => 'null', 'literalUndefined' => 'undefined', + 'emptyArray' => [], 'emptyObject' => (object) [], + 'object' => (object) ['drop' => null], + 'serialized' => new NullableJsonValue(['drop' => null]), + 'serializedNull' => new NullableJsonValue(null), + 'serializedList' => new NullableJsonValue([null, ['drop' => null]]), + 'numericObject' => [1 => null, 2 => 'keep'], + 'maxInteger' => PHP_INT_MAX, 'float' => 1.2345678901234567, + 'negativeZero' => -0.0, + '$set' => ['drop' => null], '$group_set' => ['drop' => null], + '$ai_input' => [['content' => null]], + ]; + } + + private function assertProperties($properties): void + { + $expected = json_decode(json_encode(self::properties())); + unset($expected->test, $expected->serializedNull); + foreach (['nested', 'object', 'serialized', '$set', '$group_set'] as $key) { + $expected->$key = (object) []; + } + $expected->items[3] = (object) []; + $expected->serializedList[1] = (object) []; + $expected->numericObject = (object) ['2' => 'keep']; + $expected->{'$ai_input'} = [(object) []]; + foreach (get_object_vars($expected) as $key => $value) { + $this->assertEquals($value, $properties->$key, $key); + $this->assertSame(json_encode($value), json_encode($properties->$key), $key); + } + $this->assertObjectNotHasProperty('test', $properties); + $this->assertObjectNotHasProperty('serializedNull', $properties); + $this->assertObjectNotHasProperty('missing', $properties); + } + + #[DataProvider('modes')] + public function testLibCurlWireAfterHooksAndRaw(int $batchSize, bool $compressed): void + { + $http = $this->createMock(HttpClient::class); + $bodies = []; + $http->method('sendRequest')->willReturnCallback( + function ($path, $body) use (&$bodies, $compressed) { + $this->assertSame('/batch/', $path); + $bodies[] = json_decode($compressed ? gzdecode($body) : $body); + return new HttpResponse('{}', 200); + } + ); + $client = new Client('fake-key', [ + 'consumer' => 'lib_curl', + 'host' => 'http://127.0.0.1:1', 'batch_size' => $batchSize, + 'compress_request' => $compressed, + 'before_send' => static function ($event) { + if ($event['event'] === 'drop') { + return null; + } + $event['properties']['hookNull'] = null; + $event['properties']['hookItems'] = [null, ['drop' => null]]; + return $event; + }, + ], $http); + $properties = self::properties(); + $original = json_encode($properties); + $this->assertTrue($client->capture([ + 'event' => 'capture', 'distinctId' => 'user', 'properties' => $properties, + ])); + $this->assertTrue($client->captureException('test exception', 'user', $properties)); + $this->assertTrue($client->identify(['distinctId' => 'user', 'properties' => $properties])); + $client->capture(['event' => 'drop', 'distinctId' => 'user']); + $this->assertTrue($client->raw(['event' => '$exception', 'timestamp' => null, 'properties' => [ + 'test' => null, '$exception_list' => [['value' => null]], + ]])); + $this->assertTrue($client->raw(['event' => 'only-null', 'properties' => ['test' => null]])); + $this->assertTrue($client->capture([ + 'event' => 'only-null-capture', 'distinctId' => 'user', 'properties' => ['test' => null], + ])); + $this->assertTrue($client->flush()); + $events = array_merge(...array_map(static fn ($body) => $body->batch, $bodies)); + $this->assertCount(6, $events); + foreach (array_slice($events, 0, 3) as $event) { + $this->assertProperties($event->properties); + $this->assertObjectNotHasProperty('hookNull', $event->properties); + $this->assertSame('[null,{}]', json_encode($event->properties->hookItems)); + } + $this->assertProperties($events[2]->{'$set'}); + $this->assertNull($events[3]->timestamp); + $this->assertSame('[{"value":null}]', json_encode($events[3]->properties->{'$exception_list'})); + $this->assertSame('{}', json_encode($events[4]->properties)); + $this->assertObjectNotHasProperty('test', $events[5]->properties); + $this->assertSame('posthog-php', $events[5]->properties->{'$lib'}); + $this->assertSame($original, json_encode($properties)); + } + + #[DataProvider('modes')] + public function testGeneratedMissingAndErrorFlagMetadata(int $batchSize, bool $compressed): void + { + $path = tempnam(__DIR__, 'null-flags-'); + try { + foreach (['lib_curl', 'file'] as $consumer) { + $bodies = []; + $http = $this->createMock(HttpClient::class); + $http->method('sendRequest')->willReturnCallback( + function ($url, $body) use (&$bodies, $compressed) { + if (str_starts_with($url, '/flags/?')) { + return new HttpResponse('{"flags":{},"errorsWhileComputingFlags":true}', 200); + } + $this->assertSame('/batch/', $url); + $bodies[] = json_decode($compressed ? gzdecode($body) : $body, true); + return new HttpResponse('{}', 200); + } + ); + $client = new Client('fake-key', [ + 'consumer' => $consumer, 'filename' => $path, 'host' => 'http://127.0.0.1:1', + 'batch_size' => $batchSize, 'compress_request' => $compressed, + 'before_send' => static function ($event) { + $event['properties']['custom'] = ['drop' => null]; + $event['properties']['$feature/unrelated'] = null; + return $event; + }, + ], $http); + $this->assertNull($client->evaluateFlags('snapshot-user')->getFlag('missing')); + $this->assertNull($client->getFeatureFlag('missing', 'legacy-user')); + $client->capture(['event' => 'ordinary', 'distinctId' => 'user', 'properties' => [ + '$feature_flag' => 'missing', '$feature_flag_response' => null, + ]]); + $client->flush(); + unset($client); + $events = $consumer === 'file' + ? array_map(static fn ($line) => json_decode($line, true), file($path, FILE_IGNORE_NEW_LINES)) + : array_merge(...array_column($bodies, 'batch')); + $this->assertCount(3, $events); + foreach (array_slice($events, 0, 2) as $event) { + $this->assertSame('$feature_flag_called', $event['event']); + $this->assertArrayHasKey('$feature_flag_response', $event['properties']); + $this->assertNull($event['properties']['$feature_flag_response']); + $this->assertStringContainsString('flag_missing', $event['properties']['$feature_flag_error']); + $this->assertSame([], $event['properties']['custom']); + $this->assertArrayNotHasKey('$feature/unrelated', $event['properties']); + } + $this->assertArrayNotHasKey('$feature_flag_response', $events[2]['properties']); + } + } finally { + unlink($path); + } + } + + #[DataProvider('modes')] + public function testNulKeysDoNotDestroyBatch(int $batchSize, bool $compressed): void + { + $http = $this->createMock(HttpClient::class); + $bodies = []; + $http->method('sendRequest')->willReturnCallback(function ($url, $body) use (&$bodies, $compressed) { + $this->assertSame('/batch/', $url); + $bodies[] = json_decode($compressed ? gzdecode($body) : $body, true); + return new HttpResponse('{}', 200); + }); + $client = new Client('fake-key', [ + 'host' => 'http://127.0.0.1:1', 'batch_size' => $batchSize, 'compress_request' => $compressed, + ], $http); + $value = new NullableJsonValue(["\0custom" => 1, "\0drop" => null]); + $properties = [ + "\0custom" => 1, "\0drop" => null, 'nested' => $value, + 'items' => [null, ["\0drop" => null]], + '_prefix' => "\0value", 'a"b\\c雪' => 2, "mid\0nul" => 3, + '' => 4, 'numberObject' => (object) ['0' => null, '1' => 'keep'], + ]; + $client->capture(['event' => 'nul', 'distinctId' => 'user', 'properties' => $properties]); + $client->capture(['event' => 'companion', 'distinctId' => 'user', 'properties' => ['keep' => true]]); + $client->flush(); + $events = array_merge(...array_column($bodies, 'batch')); + $this->assertCount(2, $events); + $wire = $events[0]['properties']; + $this->assertSame(1, $wire["\0custom"]); + $this->assertArrayNotHasKey("\0drop", $wire); + $this->assertSame(["\0custom" => 1], $wire['nested']); + $this->assertSame([null, []], $wire['items']); + foreach (['_prefix', 'a"b\\c雪', "mid\0nul", ''] as $key) { + $this->assertSame($properties[$key], $wire[$key]); + } + $this->assertTrue($events[1]['properties']['keep']); + $this->assertSame(1, $value->calls); + $this->assertArrayHasKey("\0drop", $properties); + } + + public function testKeyTransformResourceFailureUsesExistingErrorHandling(): void + { + $path = tempnam(__DIR__, 'null-failure-'); + $messages = []; + $options = ['filename' => $path, 'host' => 'http://127.0.0.1:1', + 'error_handler' => static function ($code, $message) use (&$messages) { + $messages[] = $message; + }]; + $http = $this->createMock(HttpClient::class); + $http->expects($this->never())->method('sendRequest'); + $file = new File('fake-key', $options); + $queue = new \PostHog\Consumer\LibCurl('fake-key', $options, $http); + $event = ['event' => 'failure', 'properties' => ['drop' => null]]; + $limit = ini_get('pcre.backtrack_limit'); + try { + ini_set('pcre.backtrack_limit', '0'); + $encoded = \PostHog\EventSerializer::encode($event, false, $encodeError); + $decoded = \PostHog\EventSerializer::decode('{"properties":{"drop":null}}', $decodeError); + $written = $file->capture($event); + $sent = $queue->flushBatch([$event]); + } finally { + ini_set('pcre.backtrack_limit', $limit); + unset($file, $queue); + } + try { + $this->assertFalse($encoded); + $this->assertNull($decoded); + $this->assertStringContainsString('JSON key transform failed:', $encodeError); + $this->assertSame($encodeError, $decodeError); + $this->assertFalse($written); + $this->assertSame('non_retryable_failure', $sent); + $this->assertSame('', file_get_contents($path)); + $this->assertCount(2, $messages); + foreach ($messages as $message) { + $this->assertStringContainsString('JSON key transform failed:', $message); + } + $this->assertIsString(\PostHog\EventSerializer::encode($event, false, $encodeError)); + $this->assertNull($encodeError); + } finally { + unlink($path); + } + } + + public function testKeyDecodingCompatibilityControls(): void + { + foreach (['a', '\\"'] as $character) { + $event = ['event' => 'long', 'properties' => ['value' => str_repeat($character, 30_000)]]; + $this->assertSame(json_encode($event), \PostHog\EventSerializer::encode($event)); + } + $json = <<<'JSON' + {"properties":{"\u0000keep":1,"\u0000drop":null,"_":2,"":3, + "value":"\u0000keep: \"_key\": null","numeric":{"0":"keep"},"empty":{}}} + JSON; + $decoded = \PostHog\EventSerializer::decode($json); + $this->assertSame(json_decode($json, true), json_decode(json_encode($decoded), true)); + $this->assertStringContainsString('"numeric":{"0":"keep"}', json_encode($decoded)); + $this->assertStringContainsString('"empty":{}', json_encode($decoded)); + $this->assertSame( + '{"cache":{"\\u0000keep":1,"drop":null},"timestamp":null}', + \PostHog\EventSerializer::encode(['cache' => ["\0keep" => 1, 'drop' => null], 'timestamp' => null]) + ); + foreach (['{', '{"x":01}', '{"x":"\\q"}', "{\"x\":\"\xFF\"}"] as $invalid) { + json_decode($invalid); + $error = json_last_error(); + $this->assertNull(\PostHog\EventSerializer::decode($invalid)); + $this->assertSame($error, json_last_error()); + } + $deep = null; + for ($i = 0; $i < 513; $i++) { + $deep = [$deep]; + } + $this->assertFalse(\PostHog\EventSerializer::encode(['properties' => $deep])); + $this->assertSame(JSON_ERROR_DEPTH, json_last_error()); + $this->assertFalse(\PostHog\EventSerializer::encode(['properties' => ['infinite' => INF]])); + $this->assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + } + + public function testJsonCompatibilityControls(): void + { + $event = ['event' => 'numbers', 'properties' => [ + 'max' => PHP_INT_MAX, 'min' => PHP_INT_MIN, 'negativeZero' => -0.0, + 'largeFloat' => 1.2345678901234567e100, 'smallFloat' => 5.0e-324, + ]]; + $this->assertSame(json_encode($event), \PostHog\EventSerializer::encode($event)); + $cycle = new \stdClass(); + $cycle->self = $cycle; + $this->assertFalse(\PostHog\EventSerializer::encode(['properties' => $cycle])); + $this->assertSame(JSON_ERROR_RECURSION, json_last_error()); + $this->assertFalse(\PostHog\EventSerializer::encode(['properties' => ['invalid' => "\xB1\x31"]])); + $this->assertSame(JSON_ERROR_UTF8, json_last_error()); + $this->assertSame('{"properties":null}', \PostHog\EventSerializer::encode(['properties' => null])); + $this->assertSame( + '{"event":"custom","properties":{"$exception_list":[{}]}}', + \PostHog\EventSerializer::encode(['event' => 'custom', 'properties' => [ + '$exception_list' => [['drop' => null]], + ]]) + ); + } + + public function testFilePersistsObjectsAndRestoresAnArrayEnvelope(): void + { + $path = tempnam(__DIR__, 'null-events-'); + try { + $consumer = new File('fake-key', ['filename' => $path]); + $this->assertTrue($consumer->capture(['event' => 'disk', 'properties' => self::properties()])); + unset($consumer); + $event = json_decode(trim(file_get_contents($path))); + $this->assertProperties($event->properties); + // Use the same object-preserving decoder as send.php. + $restored = \PostHog\EventSerializer::decode(trim(file_get_contents($path))); + $http = $this->createMock(HttpClient::class); + $http->expects($this->once())->method('sendRequest')->willReturnCallback(function ($path, $body) { + $this->assertProperties(json_decode($body)->batch[0]->properties); + return new HttpResponse('{}', 200); + }); + $client = new Client('fake-key', ['host' => 'http://127.0.0.1:1'], $http); + $this->assertTrue($client->raw($restored)); + $this->assertTrue($client->flush()); + } finally { + unlink($path); + } + } +} diff --git a/test/NullableJsonValue.php b/test/NullableJsonValue.php new file mode 100644 index 0000000..87c5cb9 --- /dev/null +++ b/test/NullableJsonValue.php @@ -0,0 +1,18 @@ +calls++; + return $this->value; + } +} diff --git a/test/assests/snapshots/event-family-request.json b/test/assests/snapshots/event-family-request.json index 64b593b..beb238b 100644 --- a/test/assests/snapshots/event-family-request.json +++ b/test/assests/snapshots/event-family-request.json @@ -39,10 +39,8 @@ "negative_floating_zero": 0, "negative_integer": -42, "nested_object": { - "alpha": "first", - "zeta": null + "alpha": "first" }, - "null": null, "numeric_string": "0", "text": "line one\n\"quoted\" \\ slash / café 🌍", "zero": 0 diff --git a/test/fixtures/null-property-consumers.php b/test/fixtures/null-property-consumers.php new file mode 100644 index 0000000..e14c1f8 --- /dev/null +++ b/test/fixtures/null-property-consumers.php @@ -0,0 +1,31 @@ + getenv('POSTHOG_TEST_HOST'), + 'consumer' => $consumer, + 'batch_size' => $size, + 'debug' => true, + 'compress_request' => $gzip, + 'maximum_backoff_duration' => 0, + 'timeout' => 2, + 'before_send' => static function ($event) { + $event['properties']['hookNull'] = null; + $event['properties']['hookItems'] = [null, ['drop' => null]]; + return $event; + }, + ]); + $properties = ['test' => null, 'nested' => ['drop' => null], 'items' => [null, ['drop' => null]]]; + $client->capture(['event' => "$consumer-$size-$gzip", 'distinctId' => 'user', 'properties' => $properties]); + $client->captureException('test exception', 'user', $properties); + if (!$client->flush()) { + throw new \RuntimeException('Loopback flush failed'); + } + } + } +} diff --git a/test/fixtures/null-property-egress-guard.php b/test/fixtures/null-property-egress-guard.php new file mode 100644 index 0000000..58ce90e --- /dev/null +++ b/test/fixtures/null-property-egress-guard.php @@ -0,0 +1,44 @@ +