Skip to content

Commit 0bfa7c6

Browse files
committed
fix: emit ISO 8601 UTC time, JSON object metadata, and integer-string OTLP nano timestamps
Three independent payload-shape bugs were causing the backend to reject SDK payloads with HTTP 400 (logs ingest) and OTLP traces parse failures: - Event::$time used DateTimeImmutable::format('c'), producing strings like "2026-04-07T11:34:24+00:00" with a numeric offset. Logtide's backend uses Zod's strict z.string().datetime(), which only accepts ISO 8601 ending in "Z". Every captureLog() therefore returned 400. Now formatted as "Y-m-d\TH:i:s.v\Z" against UTC. - Event::toArray() emitted "metadata" as JSON [] when no metadata was set, because PHP serializes empty arrays as JSON arrays. The backend schema is z.record(z.unknown()).optional(), which rejects arrays with "Expected object, received array", breaking the default first-time-user happy path. Empty metadata is now serialized via stdClass so it becomes {}. - OtlpHttpTransport::spanToOtlp() and Span::addEvent() cast float nanoseconds to string with (string)($float * 1e9), producing scientific notation like "1.7755623882398E+18". OTLP requires a digit-only stringified uint64. Now formatted with sprintf('%.0f', ...). Closes logtide-dev/logtide#180.
1 parent 831c631 commit 0bfa7c6

File tree

7 files changed

+210
-5
lines changed

7 files changed

+210
-5
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.7.3] - 2026-04-07
9+
10+
### Fixed
11+
12+
- Fixed `Event::$time` formatting that produced ISO 8601 with a numeric offset (e.g. `2026-04-07T11:34:24+00:00`) via `format('c')`. Logtide's backend uses Zod's strict `z.string().datetime()`, which only accepts ISO 8601 ending in `Z`, so every `captureLog(...)` call was rejected with HTTP 400 "Validation error". The SDK now emits UTC ISO 8601 with a `Z` suffix (`2026-04-07T11:34:24.886Z`). Closes [#180](https://github.com/logtide-dev/logtide/issues/180).
13+
- Fixed empty `metadata` field being serialized as JSON array `[]` instead of object `{}`. The backend schema is `metadata: z.record(z.unknown()).optional()`, which rejects arrays with "Expected object, received array". Default `captureLog(...)` calls without any metadata now serialize `metadata` as an empty JSON object via `\stdClass`, so the first-time-user happy path validates cleanly.
14+
- Fixed OTLP `startTimeUnixNano`, `endTimeUnixNano`, and span event `timeUnixNano` being emitted in scientific notation (e.g. `"1.7755623882398E+18"`) due to a naive `(string) ($float * 1e9)` cast. OTLP requires a digit-only stringified `uint64`, which would cause the OTLP traces endpoint to reject the payload. The SDK now uses `sprintf('%.0f', ...)` to emit a fixed-point integer string.
15+
816
## [0.7.2] - 2026-03-19
917

1018
### Fixed

packages/logtide/src/Event.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ public function __construct(
3030
?string $service = null,
3131
) {
3232
$this->id = bin2hex(random_bytes(16));
33-
$this->time = (new \DateTimeImmutable())->format('c');
33+
// Logtide's backend uses Zod's strict `z.string().datetime()`, which only
34+
// accepts ISO 8601 strings ending in `Z` (UTC). Numeric offsets such as
35+
// `+00:00` produced by `format('c')` are rejected with HTTP 400.
36+
$this->time = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
37+
->format('Y-m-d\TH:i:s.v\Z');
3438
$this->level = $level;
3539
$this->message = $message;
3640
$this->service = $service ?? 'unknown';
@@ -198,12 +202,18 @@ public function setRelease(?string $release): void
198202

199203
public function toArray(): array
200204
{
205+
$meta = $this->buildMetadata();
206+
201207
$data = [
202208
'time' => $this->time,
203209
'service' => $this->service,
204210
'level' => $this->level->value,
205211
'message' => $this->message,
206-
'metadata' => $this->buildMetadata(),
212+
// Logtide's `metadata` field is `z.record(z.unknown())` — it must be
213+
// a JSON object. PHP serializes an empty array to `[]`, which Zod
214+
// rejects as "Expected object, received array". An empty stdClass
215+
// forces `{}` so default `captureLog(...)` calls validate cleanly.
216+
'metadata' => $meta === [] ? new \stdClass() : $meta,
207217
];
208218

209219
if ($this->traceId !== null) {

packages/logtide/src/Tracing/Span.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ public function addEvent(string $name, array $attributes = []): void
6262
{
6363
$this->events[] = [
6464
'name' => $name,
65-
'timeUnixNano' => (string) (microtime(true) * 1_000_000_000),
65+
// OTLP requires a digit-only stringified uint64. A naive
66+
// `(string) ($float * 1e9)` would emit scientific notation.
67+
'timeUnixNano' => sprintf('%.0f', microtime(true) * 1_000_000_000),
6668
'attributes' => $attributes,
6769
];
6870
}

packages/logtide/src/Transport/OtlpHttpTransport.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,16 @@ private function buildOtlpPayload(array $spans): array
8686

8787
private function spanToOtlp(Span $span): array
8888
{
89+
// OTLP requires `*UnixNano` fields to be stringified uint64 values.
90+
// Casting `(string)($float * 1e9)` would emit scientific notation
91+
// (e.g. "1.7755623882398E+18"), which the backend rejects.
8992
$otlp = [
9093
'traceId' => $span->getTraceId(),
9194
'spanId' => $span->getSpanId(),
9295
'name' => $span->getOperation(),
9396
'kind' => $this->mapSpanKind($span->getKind()->value),
94-
'startTimeUnixNano' => (string) ($span->getStartTime() * 1_000_000_000),
95-
'endTimeUnixNano' => (string) (($span->getEndTime() ?? microtime(true)) * 1_000_000_000),
97+
'startTimeUnixNano' => sprintf('%.0f', $span->getStartTime() * 1_000_000_000),
98+
'endTimeUnixNano' => sprintf('%.0f', ($span->getEndTime() ?? microtime(true)) * 1_000_000_000),
9699
'status' => [
97100
'code' => $this->mapStatusCode($span->getStatus()->value),
98101
],

packages/logtide/tests/Unit/EventTest.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,53 @@ public function testToArray(): void
8989
$this->assertArrayHasKey('time', $arr);
9090
}
9191

92+
/**
93+
* The Logtide backend uses Zod's strict `z.string().datetime()` which only
94+
* accepts ISO 8601 strings ending in `Z` (UTC). Strings with a numeric
95+
* timezone offset like `+00:00` are rejected with a 400 validation error.
96+
*/
97+
public function testTimeIsIso8601WithZSuffix(): void
98+
{
99+
$event = Event::createLog(LogLevel::INFO, 'msg');
100+
101+
$time = $event->getTime();
102+
103+
$this->assertMatchesRegularExpression(
104+
'/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/',
105+
$time,
106+
'Event time must be ISO 8601 UTC with a Z suffix to satisfy Zod strict datetime validation'
107+
);
108+
}
109+
110+
/**
111+
* The Logtide backend defines `metadata: z.record(z.unknown()).optional()`.
112+
* `z.record` requires a JSON object — it rejects arrays. PHP serializes an
113+
* empty array to JSON `[]`, so a default `captureLog(...)` call without
114+
* any metadata produced a 400 "Expected object, received array".
115+
*/
116+
public function testToArrayMetadataSerializesAsJsonObjectWhenEmpty(): void
117+
{
118+
$event = Event::createLog(LogLevel::INFO, 'hello');
119+
120+
$json = json_encode($event->toArray(), JSON_THROW_ON_ERROR);
121+
122+
$this->assertStringContainsString(
123+
'"metadata":{}',
124+
$json,
125+
'Empty metadata must serialize as JSON object {} — never as array []'
126+
);
127+
}
128+
129+
public function testToArrayMetadataSerializesAsJsonObjectWhenPopulated(): void
130+
{
131+
$event = Event::createLog(LogLevel::INFO, 'hello');
132+
$event->setMetadata(['k' => 'v']);
133+
134+
$json = json_encode($event->toArray(), JSON_THROW_ON_ERROR);
135+
136+
$this->assertStringContainsString('"metadata":{"k":"v"}', $json);
137+
}
138+
92139
public function testToArrayWithoutOptionals(): void
93140
{
94141
$event = Event::createLog(LogLevel::INFO, 'basic');

packages/logtide/tests/Unit/Tracing/SpanTest.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,26 @@ public function testAddEvent(): void
117117
$this->assertArrayHasKey('timeUnixNano', $events[0]);
118118
}
119119

120+
/**
121+
* OTLP requires `timeUnixNano` to be a stringified uint64 (digits only).
122+
* Casting `(string)(microtime(true) * 1e9)` produces scientific notation
123+
* like "1.7755623882398E+18", which the OTLP backend rejects.
124+
*/
125+
public function testAddEventTimeUnixNanoIsIntegerString(): void
126+
{
127+
$span = new Span('test.op');
128+
$span->addEvent('cache.miss');
129+
130+
$timeUnixNano = $span->getEvents()[0]['timeUnixNano'];
131+
132+
$this->assertIsString($timeUnixNano);
133+
$this->assertMatchesRegularExpression(
134+
'/^\d+$/',
135+
$timeUnixNano,
136+
'timeUnixNano must be a digit-only stringified uint64, not scientific notation'
137+
);
138+
}
139+
120140
public function testMultipleEvents(): void
121141
{
122142
$span = new Span('test.op');
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LogTide\Tests\Unit\Transport;
6+
7+
use LogTide\Enum\SpanKind;
8+
use LogTide\HttpClient\HttpClientInterface;
9+
use LogTide\HttpClient\HttpResponse;
10+
use LogTide\Options;
11+
use LogTide\Tracing\Span;
12+
use LogTide\Transport\OtlpHttpTransport;
13+
use PHPUnit\Framework\TestCase;
14+
15+
final class OtlpHttpTransportTest extends TestCase
16+
{
17+
private function createSpyHttpClient(): HttpClientInterface
18+
{
19+
return new class implements HttpClientInterface {
20+
public ?string $lastUrl = null;
21+
public array $lastHeaders = [];
22+
public ?string $lastBody = null;
23+
24+
public function post(string $url, array $headers, string $body): HttpResponse
25+
{
26+
$this->lastUrl = $url;
27+
$this->lastHeaders = $headers;
28+
$this->lastBody = $body;
29+
return new HttpResponse(200);
30+
}
31+
};
32+
}
33+
34+
/**
35+
* OTLP requires `startTimeUnixNano` and `endTimeUnixNano` to be stringified
36+
* uint64 values (digits only). PHP's `(string) ($float * 1e9)` produces
37+
* scientific notation like "1.7755623882398E+18", which the OTLP backend
38+
* rejects.
39+
*/
40+
public function testStartAndEndTimeUnixNanoAreIntegerStrings(): void
41+
{
42+
$http = $this->createSpyHttpClient();
43+
$options = Options::fromArray([
44+
'api_url' => 'https://example.com',
45+
'api_key' => 'test-key',
46+
]);
47+
48+
$transport = new OtlpHttpTransport(
49+
'https://example.com',
50+
'test-key',
51+
$http,
52+
$options,
53+
);
54+
55+
$span = new Span('test.op', SpanKind::INTERNAL, serviceName: 'svc');
56+
$span->finish();
57+
58+
$transport->sendSpans([$span]);
59+
60+
$this->assertNotNull($http->lastBody);
61+
$payload = json_decode($http->lastBody, true);
62+
63+
$otlpSpan = $payload['resourceSpans'][0]['scopeSpans'][0]['spans'][0];
64+
65+
$this->assertIsString($otlpSpan['startTimeUnixNano']);
66+
$this->assertMatchesRegularExpression(
67+
'/^\d+$/',
68+
$otlpSpan['startTimeUnixNano'],
69+
'startTimeUnixNano must be a digit-only stringified uint64'
70+
);
71+
72+
$this->assertIsString($otlpSpan['endTimeUnixNano']);
73+
$this->assertMatchesRegularExpression(
74+
'/^\d+$/',
75+
$otlpSpan['endTimeUnixNano'],
76+
'endTimeUnixNano must be a digit-only stringified uint64'
77+
);
78+
}
79+
80+
/**
81+
* Sanity check: end time must be >= start time when serialized as integer
82+
* strings (i.e. the formatting must preserve ordering).
83+
*/
84+
public function testEndTimeGreaterThanStartTime(): void
85+
{
86+
$http = $this->createSpyHttpClient();
87+
$options = Options::fromArray([
88+
'api_url' => 'https://example.com',
89+
'api_key' => 'test-key',
90+
]);
91+
92+
$transport = new OtlpHttpTransport(
93+
'https://example.com',
94+
'test-key',
95+
$http,
96+
$options,
97+
);
98+
99+
$span = new Span('test.op');
100+
usleep(1000);
101+
$span->finish();
102+
103+
$transport->sendSpans([$span]);
104+
105+
$payload = json_decode($http->lastBody ?? '', true);
106+
$otlpSpan = $payload['resourceSpans'][0]['scopeSpans'][0]['spans'][0];
107+
108+
// bccomp would be ideal but stick to native cmp on the digit strings
109+
$this->assertGreaterThanOrEqual(
110+
0,
111+
strcmp($otlpSpan['endTimeUnixNano'], $otlpSpan['startTimeUnixNano']),
112+
'endTimeUnixNano must be lexicographically >= startTimeUnixNano (same width digit strings)'
113+
);
114+
}
115+
}

0 commit comments

Comments
 (0)