Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-badgers-listen.md
Original file line number Diff line number Diff line change
@@ -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.
125 changes: 125 additions & 0 deletions api/public-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion bin/posthog
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion lib/Consumer/File.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Exception;
use PostHog\Consumer;
use PostHog\EventSerializer;

/**
* Consumer that writes analytics messages to a local file.
Expand Down Expand Up @@ -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);
Expand Down
140 changes: 140 additions & 0 deletions lib/EventSerializer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

namespace PostHog;

/**
* JSON serialization policy for event properties, not other API/cache payloads.
*
* @internal
*/
final class EventSerializer
{
/**
* Encode events after PHP has resolved JsonSerializable values and object/list identity.
*
* @param mixed $payload Event or batch envelope.
* @param bool $batch Whether this is a batch envelope.
* @param string|null $error Receives a JSON or key-transform failure message.
* @return string|false
*/
public static function encode($payload, bool $batch = false, ?string &$error = null)
{
// Preserve floats (including negative zero) through the intermediate JSON tree.
$error = null;
$json = json_encode($payload, JSON_PRESERVE_ZERO_FRACTION);
if ($json === false) {
$error = json_last_error_msg();
return false;
}

// The encoder has already checked depth and cycles. Decoding must allow its full depth.
$decoded = self::decodeTree($json, 514, $error);
if ($error !== null) {
return false;
}
if ($batch) {
foreach ($decoded->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;
}
}
29 changes: 29 additions & 0 deletions lib/JsonObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace PostHog;

/**
* Internal decoded JSON object, including names PHP cannot store on stdClass.
*
* @internal
*/
final class JsonObject implements \JsonSerializable
{
public function __construct(public array $members)
{
}

public function jsonSerialize(): mixed
{
foreach ($this->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;
}
}
4 changes: 2 additions & 2 deletions lib/QueueConsumer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading
Loading