From 5fffdf43525c7087a976e10a919f78a62f070db8 Mon Sep 17 00:00:00 2001 From: "malini-agent[bot]" <338999285+malini-agent[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:45:25 +0200 Subject: [PATCH] Add event tracking with track and stop logger throwing on bad UTF-8 --- README.md | 31 +++++- check-deployed-package/README.md | 2 +- src/Events/Events.php | 92 ++++++++++++++++ src/Logdash.php | 20 +++- src/Logger/InternalLogger.php | 5 + src/Logger/Logger.php | 3 +- tests/LogdashTest.php | 173 +++++++++++++++++++++++++++++-- tests/server.php | 8 +- 8 files changed, 318 insertions(+), 16 deletions(-) create mode 100644 src/Events/Events.php diff --git a/README.md b/README.md index 3c0b57f..24c31e6 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ $logdash = Logdash::create([ ]); ``` -Every log and metric sent by this instance carries the app. +Every log, metric and event sent by this instance carries the app. Older service keys ignore `app`. ### Framework Integration @@ -157,10 +157,10 @@ Creates a new Logdash instance. **Parameters:** -- `apiKey` (string, optional): Your Logdash API key. If not provided, only local logging will work +- `apiKey` (string, optional): Your Logdash API key. If not provided, logs and events are only printed locally - `host` (string, optional): Logdash API host. Defaults to `https://api.logdash.io` - `verbose` (bool, optional): Enable verbose internal logging. Defaults to `false` -- `app` (string, optional): Name of this app within its domain, sent with every log and metric. See [Apps](#apps) +- `app` (string, optional): Name of this app within its domain, sent with every log, metric and event. See [Apps](#apps) ### Logger @@ -196,6 +196,31 @@ $metrics->mutate('error_count', -1); // Decrement $metrics->mutate('temperature', 2.5); // Increase by 2.5 ``` +### Events + +#### `track(string $name, array $props = [], string|int|null $userId = null): void` + +Track an event, such as an agent run or a signup. + +```php +$logdash->track('agent_run', [ + 'agent' => 'support', + 'steps' => 4, + 'success' => true, +], $user->id); +``` + +Events show up in the domain's Analytics as goals, with a daily trend. +Pass the user ID for signed-in users, so one person counts once across the website and apps. +Without it, Logdash counts a visitor of that app that changes every day. +Never put personal data in props. + +**Parameters:** + +- `name` (string): The event name, matching `[a-z][a-z0-9_]{0,63}`, except `pageview`, `pageleave` and `browser_error`, which belong to the website tracker. An invalid name drops the event with a warning +- `props` (array, optional): Up to 10 properties with string, number or boolean values +- `userId` (string|int, optional): The signed-in user's internal ID, never an email address. An ID that contains `@` drops the event with a warning + ## Log Levels The SDK supports the following log levels (in order of severity): diff --git a/check-deployed-package/README.md b/check-deployed-package/README.md index 46e09f1..400bcad 100644 --- a/check-deployed-package/README.md +++ b/check-deployed-package/README.md @@ -1,4 +1,4 @@ -# Logdash Rust SDK Demo +# Logdash PHP SDK Demo Use command below to test if the published SDK works: diff --git a/src/Events/Events.php b/src/Events/Events.php new file mode 100644 index 0000000..ab9d4a2 --- /dev/null +++ b/src/Events/Events.php @@ -0,0 +1,92 @@ +httpClient = new Client(); + } + + /** + * @param array $props + */ + public function track(string $name, array $props, string|int|null $userId): void + { + if (preg_match(self::NAME, $name) !== 1 || in_array($name, self::WEBSITE_EVENTS, true)) { + InternalLogger::getInternalLogger()->warn( + "Dropping event \"{$name}\": its name must match [a-z][a-z0-9_]{0,63}", + 'and not be pageview, pageleave or browser_error' + ); + return; + } + + if (is_string($userId) && str_contains($userId, '@')) { + InternalLogger::getInternalLogger()->warn( + "Dropping event \"{$name}\": its userId must be an internal ID, never an email address" + ); + return; + } + + $event = [ + 'name' => $name, + ...($props === [] ? [] : ['props' => $props]), + ...($userId === null || $userId === '' ? [] : ['userId' => $userId]), + ...($this->params->app === null ? [] : ['app' => $this->params->app]), + ]; + + if (empty($this->params->apiKey)) { + // Like logs without an API key: printed locally only + InternalLogger::getInternalLogger()->log( + 'Event', + (string) json_encode($event, JSON_PARTIAL_OUTPUT_ON_ERROR) + ); + return; + } + + if ($this->params->verbose) { + InternalLogger::getInternalLogger()->verbose("Tracking event {$name}"); + } + + try { + $this->httpClient->post($this->params->host . '/events', [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'project-api-key' => $this->params->apiKey, + ], + // The API stores an event once per id, so a retry must send the same one + 'json' => ['id' => self::uuidV4()] + $event, + 'timeout' => 5, + ]); + } catch (GuzzleException $e) { + if ($this->params->verbose) { + InternalLogger::getInternalLogger()->verbose("Failed to send event: " . $e->getMessage()); + } + // Fail silently in production + } + } + + private static function uuidV4(): string + { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); + } +} diff --git a/src/Logdash.php b/src/Logdash.php index e87c623..6fe5821 100644 --- a/src/Logdash.php +++ b/src/Logdash.php @@ -4,6 +4,7 @@ namespace Logdash; +use Logdash\Events\Events; use Logdash\Logger\Logger; use Logdash\Metrics\BaseMetrics; use Logdash\Types\InitializationParams; @@ -16,13 +17,16 @@ class Logdash { private Logger $logger; private BaseMetrics $metrics; + private Events $events; private function __construct( Logger $logger, - BaseMetrics $metrics + BaseMetrics $metrics, + Events $events ) { $this->logger = $logger; $this->metrics = $metrics; + $this->events = $events; } /** @@ -49,7 +53,7 @@ public static function create(?array $params = null): self } ); - return new self($logger, $metrics); + return new self($logger, $metrics, new Events($requiredParams)); } public function logger(): Logger @@ -61,4 +65,16 @@ public function metrics(): BaseMetrics { return $this->metrics; } + + /** + * Tracks an event, such as an agent run or a signup, and never throws. + * + * @param string $name Matches [a-z][a-z0-9_]{0,63}, any other name drops the event with a warning + * @param array $props Up to 10 properties, never personal data + * @param string|int|null $userId The signed-in user's internal ID, never an email address + */ + public function track(string $name, array $props = [], string|int|null $userId = null): void + { + $this->events->track($name, $props, $userId); + } } diff --git a/src/Logger/InternalLogger.php b/src/Logger/InternalLogger.php index cf3041a..a137de2 100644 --- a/src/Logger/InternalLogger.php +++ b/src/Logger/InternalLogger.php @@ -24,6 +24,11 @@ public function log(string ...$data): void $this->internalLog(LogLevel::INFO, implode(' ', $data)); } + public function warn(string ...$data): void + { + $this->internalLog(LogLevel::WARN, implode(' ', $data)); + } + public function verbose(string ...$data): void { $this->internalLog(LogLevel::VERBOSE, implode(' ', $data)); diff --git a/src/Logger/Logger.php b/src/Logger/Logger.php index 8544044..04cb0ab 100644 --- a/src/Logger/Logger.php +++ b/src/Logger/Logger.php @@ -139,7 +139,8 @@ private function convertToStrings(array $data): array } if (is_array($item) || is_object($item)) { - return json_encode($item, JSON_THROW_ON_ERROR); + // Never throws into the app, for example on a Latin-1 string + return (string) json_encode($item, JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR); } return var_export($item, true); diff --git a/tests/LogdashTest.php b/tests/LogdashTest.php index f5a66b2..bfdc66e 100644 --- a/tests/LogdashTest.php +++ b/tests/LogdashTest.php @@ -11,6 +11,8 @@ class LogdashTest extends TestCase { + private const UUID_V4 = '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/D'; + /** @var resource */ private static $server; private static string $host; @@ -102,6 +104,18 @@ public function testMetricsMethods(): void $this->assertTrue(true); // If we get here, no exceptions were thrown } + public function testLoggingInvalidUtf8InAnArrayDoesNotThrow(): void + { + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); + + $logdash->logger()->info('upload', ['file' => "caf\xe9.txt"]); + + $this->assertSame( + ['upload {"file":"caf\\ufffd.txt"}'], + array_column($this->receivedBodies('POST /logs'), 'message') + ); + } + public function testLogLevelEnum(): void { $this->assertEquals('error', LogLevel::ERROR->value); @@ -114,7 +128,7 @@ public function testLogLevelEnum(): void } #[DataProvider('apps')] - public function testLogsAndMetricsCarryApp(string $app, string $sentApp): void + public function testLogsMetricsAndEventsCarryApp(string $app, string $sentApp): void { $logdash = Logdash::create([ 'apiKey' => 'test-api-key', @@ -126,9 +140,11 @@ public function testLogsAndMetricsCarryApp(string $app, string $sentApp): void $logdash->logger()->error('Test error'); $logdash->metrics()->set('test_metric', 42.5); $logdash->metrics()->mutate('test_counter', 1); + $logdash->track('agent_run'); $this->assertSame([$sentApp, $sentApp], array_column($this->receivedBodies('POST /logs'), 'app')); $this->assertSame([$sentApp, $sentApp], array_column($this->receivedBodies('PUT /metrics'), 'app')); + $this->assertSame([$sentApp], array_column($this->receivedBodies('POST /events'), 'app')); } /** @@ -146,20 +162,24 @@ public static function apps(): array * @param array{app?: string} $appParams */ #[DataProvider('unsetApps')] - public function testLogsAndMetricsOmitAppWhenUnset(array $appParams): void + public function testLogsMetricsAndEventsOmitAppWhenUnset(array $appParams): void { $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host] + $appParams); $logdash->logger()->info('Test info'); $logdash->metrics()->set('test_metric', 42.5); + $logdash->track('agent_run'); $logs = $this->receivedBodies('POST /logs'); $metrics = $this->receivedBodies('PUT /metrics'); + $events = $this->receivedBodies('POST /events'); $this->assertCount(1, $logs); $this->assertArrayNotHasKey('app', $logs[0]); $this->assertCount(1, $metrics); $this->assertArrayNotHasKey('app', $metrics[0]); + $this->assertCount(1, $events); + $this->assertArrayNotHasKey('app', $events[0]); } /** @@ -174,21 +194,160 @@ public static function unsetApps(): array ]; } + public function testTrackPostsTheEvent(): void + { + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); + + $logdash->track('agent_run', ['agent' => 'support', 'steps' => 4, 'success' => true]); + + $events = $this->receivedRequests('POST /events'); + + $this->assertCount(1, $events); + $this->assertSame('test-api-key', $events[0]['headers']['project-api-key']); + $this->assertSame('application/json', $events[0]['headers']['content-type']); + $this->assertMatchesRegularExpression(self::UUID_V4, $events[0]['body']['id']); + $this->assertSame( + ['name' => 'agent_run', 'props' => ['agent' => 'support', 'steps' => 4, 'success' => true]], + array_diff_key($events[0]['body'], ['id' => true]) + ); + } + + public function testEveryEventGetsItsOwnId(): void + { + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); + + $logdash->track('agent_run'); + $logdash->track('agent_run'); + + $ids = array_column($this->receivedBodies('POST /events'), 'id'); + + $this->assertCount(2, array_unique($ids)); + } + + /** + * @param array{userId?: string|int} $sentUserId + */ + #[DataProvider('userIds')] + public function testEventsCarryUserId(string|int|null $userId, array $sentUserId): void + { + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); + + $logdash->track('agent_run', [], $userId); + + $events = $this->receivedBodies('POST /events'); + + $this->assertCount(1, $events); + $this->assertSame(['name' => 'agent_run'] + $sentUserId, array_diff_key($events[0], ['id' => true])); + } + + /** + * @return array + */ + public static function userIds(): array + { + return [ + 'string' => ['42', ['userId' => '42']], + 'number' => [42, ['userId' => 42]], + 'zero' => [0, ['userId' => 0]], + 'empty' => ['', []], + 'none' => [null, []], + ]; + } + + #[DataProvider('invalidEvents')] + public function testInvalidEventsAreDroppedWithAWarning(string $name, ?string $userId): void + { + $printed = $this->printedBy(sprintf( + 'Logdash\Logdash::create(%s)->track(%s, [], %s);', + var_export(['apiKey' => 'test-api-key', 'host' => self::$host], true), + var_export($name, true), + var_export($userId, true) + )); + + $this->assertStringContainsString('WARNING', $printed); + $this->assertStringContainsString('Dropping event', $printed); + $this->assertStringNotContainsString('alice@example.com', $printed); + $this->assertSame([], $this->receivedRequests('POST /events')); + } + + /** + * @return array + */ + public static function invalidEvents(): array + { + return [ + 'uppercase and space' => ['Agent run', null], + 'empty' => ['', null], + 'leading digit' => ['9lives', null], + 'over 64 characters' => [str_repeat('a', 65), null], + 'trailing newline' => ["agent_run\n", null], + 'pageview' => ['pageview', null], + 'pageleave' => ['pageleave', null], + 'browser_error' => ['browser_error', null], + 'email as user ID' => ['agent_run', 'alice@example.com'], + ]; + } + + public function testTrackWithoutApiKeyPrintsTheEventLocallyOnly(): void + { + $printed = $this->printedBy(sprintf( + 'Logdash\Logdash::create(%s)->track("agent_run", ["steps" => 4], 42);', + var_export(['host' => self::$host], true) + )); + + $this->assertStringContainsString('Event {"name":"agent_run","props":{"steps":4},"userId":42}', $printed); + $this->assertSame([], $this->receivedRequests('POST /events')); + } + + public function testTrackDoesNotThrowWhenTheApiIsUnreachable(): void + { + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => 'http://127.0.0.1:1']); + + $logdash->track('agent_run'); + + $this->assertTrue(true); // If we get here, no exceptions were thrown + } + /** * @return list> JSON bodies the local server received for $request, such as "POST /logs" */ private function receivedBodies(string $request): array { - $bodies = []; + return array_column($this->receivedRequests($request), 'body'); + } + + /** + * @return list, body: array}> + * Requests the local server received for $request, such as "POST /events", with lowercase header names + */ + private function receivedRequests(string $request): array + { + $requests = []; foreach (file(self::$requestsFile, FILE_IGNORE_NEW_LINES) as $line) { - [$method, $path, $body] = explode(' ', $line, 3); + $received = json_decode($line, true, 512, JSON_THROW_ON_ERROR); - if ("{$method} {$path}" === $request) { - $bodies[] = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + if ($received['request'] === $request) { + $requests[] = $received; } } - return $bodies; + return $requests; + } + + /** + * Runs $code in a new PHP process, like an app that uses the SDK + * + * @return string What the process printed + */ + private function printedBy(string $code): string + { + $autoload = var_export(dirname(__DIR__) . '/vendor/autoload.php', true); + $process = proc_open([PHP_BINARY, '-r', "require {$autoload}; {$code}"], [1 => ['pipe', 'w']], $pipes); + $printed = stream_get_contents($pipes[1]); + fclose($pipes[1]); + proc_close($process); + + return $printed; } } diff --git a/tests/server.php b/tests/server.php index fa7231b..d0e570f 100644 --- a/tests/server.php +++ b/tests/server.php @@ -2,9 +2,13 @@ declare(strict_types=1); -// Router for the PHP built-in server that LogdashTest starts: appends "METHOD /path body" for every request +// Router for the PHP built-in server that LogdashTest starts: appends every request as a line of JSON file_put_contents( getenv('LOGDASH_REQUESTS_FILE'), - "{$_SERVER['REQUEST_METHOD']} {$_SERVER['REQUEST_URI']} " . file_get_contents('php://input') . PHP_EOL, + json_encode([ + 'request' => "{$_SERVER['REQUEST_METHOD']} {$_SERVER['REQUEST_URI']}", + 'headers' => array_change_key_case(getallheaders()), + 'body' => json_decode(file_get_contents('php://input'), true), + ]) . PHP_EOL, FILE_APPEND );