Viewing is open to everyone. Downloading and starring need a GitHub sign-in. Sign in with GitHub

telemetry_tests.cpp

v0.1.0-build.85 Latest Commit ede38f5

Size
38 KB
Lines
832
Language
cpp
SHA-256
004ae5fc39a3…
View on GitHub

Sign in with GitHub to download. Each download is recorded with your GitHub username.

1// Tests for the telemetry core (include/ckey/telemetry.h). Self-contained:
2// links only src/telemetry.cpp.
3
4#include "ckey/telemetry.h"
5#include "check.h"
6
7#include <cstdint>
8#include <cstring>
9#include <string>
10#include <thread>
11#include <vector>
12
13namespace {
14
15// ---- Minimal JSON reader + batch validator per docs/adoption-and-telemetry.md
16
17struct Json {
18 enum Kind { Num, Str, Arr, Obj } kind = Num;
19 uint64_t num = 0;
20 std::string str;
21 std::vector<Json> arr;
22 std::vector<std::string> keys; // object keys, in document order
23 std::vector<Json> vals; // object values, parallel to keys
24};
25
26struct Reader {
27 const std::string &s;
28 size_t i = 0;
29 bool ok = true;
30 void fail() { ok = false; }
31 Json value() {
32 Json v;
33 if (!ok || i >= s.size()) { fail(); return v; }
34 char c = s[i];
35 if (c == '{') {
36 v.kind = Json::Obj; ++i;
37 if (i < s.size() && s[i] == '}') { ++i; return v; }
38 for (;;) {
39 Json k = value();
40 if (!ok || k.kind != Json::Str || i >= s.size() || s[i] != ':') { fail(); return v; }
41 ++i;
42 Json x = value();
43 v.keys.push_back(k.str);
44 v.vals.push_back(x);
45 if (i < s.size() && s[i] == ',') { ++i; continue; }
46 if (i < s.size() && s[i] == '}') { ++i; return v; }
47 fail(); return v;
48 }
49 }
50 if (c == '[') {
51 v.kind = Json::Arr; ++i;
52 if (i < s.size() && s[i] == ']') { ++i; return v; }
53 for (;;) {
54 v.arr.push_back(value());
55 if (i < s.size() && s[i] == ',') { ++i; continue; }
56 if (i < s.size() && s[i] == ']') { ++i; return v; }
57 fail(); return v;
58 }
59 }
60 if (c == '"') {
61 v.kind = Json::Str; ++i;
62 while (i < s.size() && s[i] != '"') {
63 if (s[i] == '\\' || static_cast<unsigned char>(s[i]) < 0x20) { fail(); return v; }
64 v.str += s[i++];
65 }
66 if (i >= s.size()) { fail(); return v; }
67 ++i;
68 return v;
69 }
70 if (c >= '0' && c <= '9') {
71 if (c == '0' && i + 1 < s.size() && s[i + 1] >= '0' && s[i + 1] <= '9') { fail(); return v; }
72 while (i < s.size() && s[i] >= '0' && s[i] <= '9') {
73 if (v.num > (UINT64_MAX - 9) / 10) { fail(); return v; }
74 v.num = v.num * 10 + static_cast<uint64_t>(s[i++] - '0');
75 }
76 return v;
77 }
78 fail();
79 return v;
80 }
81};
82
83bool keys_are(const Json &o, std::vector<std::string> keys) {
84 return o.kind == Json::Obj && o.keys == keys;
85}
86
87bool count_ok(const Json &v) { return v.kind == Json::Num && v.num <= CKEY_TELEMETRY_MAX_COUNT; }
88
89bool hist_ok(const Json &h) {
90 if (!keys_are(h, {"count", "sum", "buckets"})) return false;
91 const Json &count = h.vals[0], &sum = h.vals[1], &buckets = h.vals[2];
92 if (!count_ok(count) || sum.kind != Json::Num || sum.num > CKEY_TELEMETRY_MAX_SUM) return false;
93 if (buckets.kind != Json::Arr || buckets.arr.size() != 10) return false;
94 uint64_t total = 0;
95 for (const Json &b : buckets.arr) {
96 if (!count_ok(b)) return false;
97 total += b.num;
98 }
99 return total == count.num;
100}
101
102// "D" matches one digit, any other char matches itself.
103bool shape_ok(const std::string &v, const std::string &shape) {
104 if (v.size() != shape.size()) return false;
105 for (size_t i = 0; i < v.size(); ++i)
106 if (shape[i] == 'D' ? !(v[i] >= '0' && v[i] <= '9') : v[i] != shape[i]) return false;
107 return true;
108}
109
110// ^0\.[0-9]+\.[0-9]+(-(build|dev)\.[0-9]{1,6})?$, written independently of the core.
111bool version_ok(const std::string &v) {
112 size_t i = 0;
113 auto digits = [&](size_t min, size_t max) {
114 size_t start = i;
115 while (i < v.size() && v[i] >= '0' && v[i] <= '9') ++i;
116 return i - start >= min && i - start <= max;
117 };
118 auto lit = [&](const std::string &t) {
119 if (v.compare(i, t.size(), t) != 0) return false;
120 i += t.size();
121 return true;
122 };
123 if (!lit("0.") || !digits(1, SIZE_MAX) || !lit(".") || !digits(1, SIZE_MAX)) return false;
124 if (i == v.size()) return true;
125 if (!(lit("-build.") || lit("-dev."))) return false;
126 return digits(1, 6) && i == v.size();
127}
128
129// Validates a batch body against every field rule of schema version 1.
130bool batch_valid(const std::string &body) {
131 if (body.size() > CKEY_TELEMETRY_MAX_BODY_BYTES) return false;
132 Reader r{body};
133 Json root = r.value();
134 if (!r.ok || r.i != body.size()) return false;
135 if (!keys_are(root, {"schema", "sdk", "sdk_version", "platform", "day", "sessions", "frames", "decoded",
136 "time_to_decode_ms", "frame_ms"}))
137 return false;
138 auto f = [&](size_t i) -> const Json & { return root.vals[i]; };
139 if (f(0).kind != Json::Num || f(0).num != 1) return false;
140 auto one_of = [](const std::string &v, std::vector<std::string> list) {
141 for (auto &x : list)
142 if (v == x) return true;
143 return false;
144 };
145 if (f(1).kind != Json::Str || !one_of(f(1).str, {"c", "swift", "android", "web", "dotnet"})) return false;
146 if (f(2).kind != Json::Str || !version_ok(f(2).str)) return false;
147 if (f(3).kind != Json::Str ||
148 !one_of(f(3).str, {"ios", "macos", "android", "web", "windows", "linux", "other"}))
149 return false;
150 if (f(4).kind != Json::Str || !shape_ok(f(4).str, "DDDD-DD-DD")) return false;
151 if (!keys_are(f(5), {"started", "succeeded", "failed"})) return false;
152 for (auto &v : f(5).vals)
153 if (!count_ok(v)) return false;
154 if (!count_ok(f(6))) return false;
155 if (!keys_are(f(7), {"p0", "p0_1", "p1", "p2", "p3"})) return false;
156 for (auto &v : f(7).vals)
157 if (!count_ok(v)) return false;
158 return hist_ok(f(8)) && hist_ok(f(9));
159}
160
161// Field getters on a parsed batch.
162Json parse(const std::string &s) {
163 Reader r{s};
164 Json j = r.value();
165 CKEY_CHECK(r.ok && r.i == s.size());
166 return j;
167}
168const Json &field(const Json &o, const char *k) {
169 for (size_t i = 0; i < o.keys.size(); ++i)
170 if (o.keys[i] == k) return o.vals[i];
171 CKEY_CHECK(false);
172 return o;
173}
174
175// ---- Helpers ---------------------------------------------------------------
176
177const char *kDay = "2026-11-05";
178
179std::string take(uint64_t *id = nullptr) {
180 char buf[4096];
181 size_t len = 99;
182 uint64_t bid = 99;
183 CKEY_CHECK(ckey_telemetry_take_batch(kDay, buf, sizeof buf, &len, &bid) == CKEY_OK);
184 CKEY_CHECK(len == std::strlen(buf));
185 CKEY_CHECK((len == 0) == (bid == 0));
186 if (id) *id = bid;
187 if (len) CKEY_CHECK(batch_valid(buf));
188 return std::string(buf, len);
189}
190
191std::string export_state() {
192 size_t len = 0;
193 CKEY_CHECK(ckey_telemetry_export_state(nullptr, 0, &len) == CKEY_INSUFFICIENT_BUFFER);
194 std::string out(len + 1, '\0');
195 size_t len2 = 0;
196 CKEY_CHECK(ckey_telemetry_export_state(out.data(), out.size(), &len2) == CKEY_OK);
197 CKEY_CHECK(len2 == len);
198 out.resize(len);
199 return out;
200}
201
202ckey_telemetry_counters snap() {
203 ckey_telemetry_counters c;
204 std::memset(&c, 0xab, sizeof c);
205 CKEY_CHECK(ckey_telemetry_snapshot(&c) == CKEY_OK);
206 return c;
207}
208
209bool is_zero(const ckey_telemetry_counters &c) {
210 ckey_telemetry_counters z{};
211 return std::memcmp(&c, &z, sizeof c) == 0;
212}
213
214bool same(const ckey_telemetry_counters &a, const ckey_telemetry_counters &b) {
215 return std::memcmp(&a, &b, sizeof a) == 0;
216}
217
218// The known scenario used for the exact-JSON test (see expected string).
219void record_scenario() {
220 ckey_telemetry_session_begin(1000);
221 ckey_telemetry_frame(1000, 1); // frame bucket 0 (<= 2)
222 ckey_telemetry_frame(1010, 2); // bucket 0 (edge, inclusive)
223 ckey_telemetry_frame(1020, 3); // bucket 1
224 CKEY_CHECK(ckey_telemetry_session_success(1025, CKEY_TELEMETRY_PROFILE_P0) == CKEY_OK); // ttd 25 -> bucket 0
225 ckey_telemetry_session_begin(2000);
226 ckey_telemetry_frame(2000, 640); // bucket 8 (edge)
227 ckey_telemetry_frame(2010, 641); // bucket 9
228 CKEY_CHECK(ckey_telemetry_session_success(2026, CKEY_TELEMETRY_PROFILE_P1) == CKEY_OK); // ttd 26 -> bucket 1
229 ckey_telemetry_session_begin(3000);
230 ckey_telemetry_frame(3000, 10); // bucket 2
231 ckey_telemetry_session_end(3100); // failed
232 ckey_telemetry_frame(40000, 5); // implicit begin; bucket 1
233 ckey_telemetry_frame(70000, 20); // 30 s elapsed: timeout fail, implicit begin; bucket 3
234 ckey_telemetry_session_end(70001); // failed
235}
236
237const char *kScenarioJson =
238 "{\"schema\":1,\"sdk\":\"swift\",\"sdk_version\":\"0.1.0-build.61\",\"platform\":\"ios\",\"day\":\"2026-11-05\","
239 "\"sessions\":{\"started\":5,\"succeeded\":2,\"failed\":3},\"frames\":8,"
240 "\"decoded\":{\"p0\":1,\"p0_1\":0,\"p1\":1,\"p2\":0,\"p3\":0},"
241 "\"time_to_decode_ms\":{\"count\":2,\"sum\":51,\"buckets\":[1,1,0,0,0,0,0,0,0,0]},"
242 "\"frame_ms\":{\"count\":8,\"sum\":1322,\"buckets\":[2,2,1,1,0,0,0,0,1,1]}}";
243
244// ---- Tests -------------------------------------------------------------------
245
246void test_defaults_and_modes() {
247 ckey_telemetry_reset_for_testing();
248 CKEY_CHECK(ckey_telemetry_get_mode() == CKEY_TELEMETRY_ON);
249 CKEY_CHECK(CKEY_TELEMETRY_OFF == 0 && CKEY_TELEMETRY_ON == 1 && CKEY_TELEMETRY_LOCAL == 2);
250 CKEY_CHECK(std::strcmp(CKEY_TELEMETRY_ENDPOINT, "https://openckey.org/api/telemetry/v1/batch") == 0);
251 CKEY_CHECK(CKEY_TELEMETRY_SCHEMA == 1);
252 for (int bad : {-1, 3, 99, 1000, INT32_MIN}) {
253 CKEY_CHECK(ckey_telemetry_set_mode(bad) == CKEY_INVALID_ARGUMENT);
254 CKEY_CHECK(ckey_telemetry_get_mode() == CKEY_TELEMETRY_ON);
255 }
256 CKEY_CHECK(ckey_telemetry_set_mode(2) == CKEY_OK);
257 for (int bad : {-1, 3, 99}) {
258 CKEY_CHECK(ckey_telemetry_set_mode(bad) == CKEY_INVALID_ARGUMENT);
259 CKEY_CHECK(ckey_telemetry_get_mode() == CKEY_TELEMETRY_LOCAL);
260 }
261 CKEY_CHECK(ckey_telemetry_set_mode(0) == CKEY_OK);
262 CKEY_CHECK(ckey_telemetry_set_mode(3) == CKEY_INVALID_ARGUMENT);
263 CKEY_CHECK(ckey_telemetry_get_mode() == CKEY_TELEMETRY_OFF);
264 CKEY_CHECK(ckey_telemetry_snapshot(nullptr) == CKEY_INVALID_ARGUMENT);
265}
266
267void test_default_client_json() {
268 ckey_telemetry_reset_for_testing();
269 ckey_telemetry_frame(0, 1);
270 std::string b = take();
271 Json j = parse(b);
272 CKEY_CHECK(field(j, "sdk").str == "c");
273 CKEY_CHECK(field(j, "sdk_version").str == CKEY_TELEMETRY_DEFAULT_VERSION);
274 CKEY_CHECK(field(j, "platform").str == "other");
275}
276
277void test_mode_off() {
278 ckey_telemetry_reset_for_testing();
279 record_scenario();
280 CKEY_CHECK(!is_zero(snap()));
281 uint64_t id = 0;
282 CKEY_CHECK(!take(&id).empty());
283 ckey_telemetry_frame(80000, 3); // unsent delta beside the in-flight batch
284 CKEY_CHECK(ckey_telemetry_set_mode(0) == CKEY_OK);
285 CKEY_CHECK(is_zero(snap())); // purged
286 CKEY_CHECK(ckey_telemetry_batch_result(id, 500, 90000) == CKEY_INVALID_ARGUMENT); // in-flight discarded
287 record_scenario(); // no-ops
288 CKEY_CHECK(ckey_telemetry_session_success(1, 0) == CKEY_OK);
289 CKEY_CHECK(is_zero(snap()));
290 CKEY_CHECK(take().empty());
291 CKEY_CHECK(!ckey_telemetry_should_send(UINT64_MAX / 2));
292 // 0 -> 1 starts from zero.
293 CKEY_CHECK(ckey_telemetry_set_mode(1) == CKEY_OK);
294 CKEY_CHECK(is_zero(snap()));
295 CKEY_CHECK(take().empty());
296 ckey_telemetry_frame(100, 7);
297 ckey_telemetry_counters c = snap();
298 CKEY_CHECK(c.frames == 1 && c.sessions_started == 1);
299 // 0 -> 2 also from zero.
300 CKEY_CHECK(ckey_telemetry_set_mode(0) == CKEY_OK);
301 CKEY_CHECK(ckey_telemetry_set_mode(2) == CKEY_OK);
302 CKEY_CHECK(is_zero(snap()));
303}
304
305void test_on_to_local() {
306 ckey_telemetry_reset_for_testing();
307 record_scenario();
308 ckey_telemetry_counters before = snap();
309 uint64_t id = 0;
310 CKEY_CHECK(!take(&id).empty());
311 ckey_telemetry_frame(80000, 4); // unsent
312 before = snap();
313 CKEY_CHECK(ckey_telemetry_set_mode(2) == CKEY_OK);
314 CKEY_CHECK(same(snap(), before)); // local totals kept
315 // In-flight result ignored, never merged back, whatever the status.
316 for (int st : {0, 429, 500, 204, 400})
317 CKEY_CHECK(ckey_telemetry_batch_result(id, st, 90000) == CKEY_INVALID_ARGUMENT);
318 CKEY_CHECK(take().empty()); // mode 2 never sends
319 ckey_telemetry_frame(90000, 4);
320 CKEY_CHECK(snap().frames == before.frames + 1); // still counts locally
321 CKEY_CHECK(!ckey_telemetry_should_send(UINT64_MAX / 2));
322 // Back to 1: the dropped delta and mode-2 counts are never sent.
323 CKEY_CHECK(ckey_telemetry_set_mode(1) == CKEY_OK);
324 CKEY_CHECK(take().empty());
325 CKEY_CHECK(ckey_telemetry_batch_result(id, 500, 90000) == CKEY_INVALID_ARGUMENT);
326 CKEY_CHECK(take().empty());
327}
328
329void test_local_to_on_fresh_delta() {
330 ckey_telemetry_reset_for_testing();
331 CKEY_CHECK(ckey_telemetry_set_mode(2) == CKEY_OK);
332 record_scenario();
333 CKEY_CHECK(snap().frames == 8);
334 CKEY_CHECK(ckey_telemetry_set_mode(1) == CKEY_OK);
335 CKEY_CHECK(snap().frames == 8); // totals kept
336 CKEY_CHECK(take().empty()); // fresh delta
337 ckey_telemetry_session_begin(100000);
338 ckey_telemetry_frame(100000, 3);
339 Json j = parse(take());
340 CKEY_CHECK(field(j, "frames").num == 1);
341 CKEY_CHECK(field(field(j, "sessions"), "started").num == 1);
342 CKEY_CHECK(snap().frames == 9);
343}
344
345void test_sessions() {
346 ckey_telemetry_reset_for_testing();
347 // Explicit begin + success; later frames/decodes in the same session are not new sessions.
348 ckey_telemetry_session_begin(0);
349 ckey_telemetry_frame(10, 1);
350 CKEY_CHECK(ckey_telemetry_session_success(100, CKEY_TELEMETRY_PROFILE_P2) == CKEY_OK);
351 ckey_telemetry_frame(110, 1);
352 CKEY_CHECK(ckey_telemetry_session_success(120, CKEY_TELEMETRY_PROFILE_P2) == CKEY_OK); // ignored
353 ckey_telemetry_frame(100000, 1); // DONE does not time out or start a session
354 ckey_telemetry_session_end(100001); // DONE closes without failing
355 ckey_telemetry_counters c = snap();
356 CKEY_CHECK(c.sessions_started == 1 && c.sessions_succeeded == 1 && c.sessions_failed == 0);
357 CKEY_CHECK(c.frames == 3 && c.decoded[3] == 1 && c.time_to_decode_ms.count == 1 && c.time_to_decode_ms.sum == 100);
358 CKEY_CHECK(c.time_to_decode_ms.buckets[2] == 1); // 100 -> bucket 2 (<= 100)
359 // After end, the next frame implicitly begins.
360 ckey_telemetry_frame(200000, 1);
361 c = snap();
362 CKEY_CHECK(c.sessions_started == 2);
363 // Begin while open fails the open one.
364 ckey_telemetry_session_begin(200500);
365 c = snap();
366 CKEY_CHECK(c.sessions_started == 3 && c.sessions_failed == 1);
367 // End with nothing open is a no-op; end twice fails once.
368 ckey_telemetry_session_end(200600);
369 ckey_telemetry_session_end(200700);
370 c = snap();
371 CKEY_CHECK(c.sessions_failed == 2);
372 // Timeout boundary: 29,999 ms is still the same session; 30,000 ms times out.
373 ckey_telemetry_session_begin(300000);
374 ckey_telemetry_frame(329999, 1);
375 CKEY_CHECK(snap().sessions_started == 4);
376 ckey_telemetry_frame(330000, 1);
377 c = snap();
378 CKEY_CHECK(c.sessions_started == 5 && c.sessions_failed == 3);
379 // Success after a timeout: old session fails, new one starts at now with ttd 0.
380 CKEY_CHECK(ckey_telemetry_session_success(360000, CKEY_TELEMETRY_PROFILE_P0_1) == CKEY_OK);
381 c = snap();
382 CKEY_CHECK(c.sessions_started == 6 && c.sessions_failed == 4 && c.sessions_succeeded == 2);
383 CKEY_CHECK(c.decoded[1] == 1 && c.time_to_decode_ms.buckets[0] == 1 && c.time_to_decode_ms.sum == 100);
384 // Success with no session: implicit begin, ttd 0.
385 ckey_telemetry_session_end(360001);
386 CKEY_CHECK(ckey_telemetry_session_success(400000, CKEY_TELEMETRY_PROFILE_P3) == CKEY_OK);
387 c = snap();
388 CKEY_CHECK(c.sessions_started == 7 && c.sessions_succeeded == 3 && c.decoded[4] == 1);
389 // Success just before the timeout is a success, ttd 29,999.
390 ckey_telemetry_session_begin(500000);
391 CKEY_CHECK(ckey_telemetry_session_success(529999, CKEY_TELEMETRY_PROFILE_P0) == CKEY_OK);
392 c = snap();
393 CKEY_CHECK(c.sessions_succeeded == 4 && c.time_to_decode_ms.buckets[9] == 1);
394 // Clock going backwards counts as 0 ms elapsed.
395 ckey_telemetry_session_begin(600000);
396 CKEY_CHECK(ckey_telemetry_session_success(500, CKEY_TELEMETRY_PROFILE_P0) == CKEY_OK);
397 c = snap();
398 CKEY_CHECK(c.time_to_decode_ms.buckets[0] == 3);
399 // Invalid profile: nothing recorded.
400 ckey_telemetry_counters before = snap();
401 ckey_telemetry_session_begin(700000);
402 before = snap();
403 CKEY_CHECK(ckey_telemetry_session_success(700010, 5) == CKEY_INVALID_ARGUMENT);
404 CKEY_CHECK(ckey_telemetry_session_success(700010, UINT32_MAX) == CKEY_INVALID_ARGUMENT);
405 CKEY_CHECK(same(snap(), before));
406 // Bucket sums equal counts throughout.
407 c = snap();
408 for (const ckey_telemetry_histogram *h : {&c.time_to_decode_ms, &c.frame_ms}) {
409 uint64_t t = 0;
410 for (uint64_t b : h->buckets) t += b;
411 CKEY_CHECK(t == h->count);
412 }
413}
414
415void test_histogram_edges() {
416 const uint64_t ttd_edges[9] = {25, 50, 100, 200, 400, 800, 1600, 3200, 6400};
417 const uint32_t frame_edges[9] = {2, 5, 10, 20, 40, 80, 160, 320, 640};
418 for (size_t i = 0; i < 9; ++i) {
419 for (uint64_t delta : {0u, 1u}) {
420 ckey_telemetry_reset_for_testing();
421 ckey_telemetry_session_begin(1000);
422 CKEY_CHECK(ckey_telemetry_session_success(1000 + ttd_edges[i] + delta, 0) == CKEY_OK);
423 ckey_telemetry_counters c = snap();
424 size_t want = i + delta;
425 for (size_t b = 0; b < 10; ++b) CKEY_CHECK(c.time_to_decode_ms.buckets[b] == (b == want ? 1u : 0u));
426 CKEY_CHECK(c.time_to_decode_ms.sum == ttd_edges[i] + delta);
427
428 ckey_telemetry_reset_for_testing();
429 ckey_telemetry_frame(0, frame_edges[i] + static_cast<uint32_t>(delta));
430 c = snap();
431 for (size_t b = 0; b < 10; ++b) CKEY_CHECK(c.frame_ms.buckets[b] == (b == want ? 1u : 0u));
432 }
433 }
434 ckey_telemetry_reset_for_testing();
435 ckey_telemetry_frame(0, 0);
436 ckey_telemetry_frame(1, UINT32_MAX);
437 ckey_telemetry_counters c = snap();
438 CKEY_CHECK(c.frame_ms.buckets[0] == 1 && c.frame_ms.buckets[9] == 1 && c.frame_ms.sum == UINT32_MAX);
439}
440
441void test_exact_json_and_buffer() {
442 ckey_telemetry_reset_for_testing();
443 CKEY_CHECK(ckey_telemetry_set_client("swift", "0.1.0-build.61", "ios") == CKEY_OK);
444 record_scenario();
445 const size_t want = std::strlen(kScenarioJson);
446 // Size query and too-small buffers move nothing.
447 size_t len = 0;
448 uint64_t id = 7;
449 CKEY_CHECK(ckey_telemetry_take_batch(kDay, nullptr, 0, &len, &id) == CKEY_INSUFFICIENT_BUFFER);
450 CKEY_CHECK(len == want && id == 0);
451 std::vector<char> small(want); // no room for the NUL
452 CKEY_CHECK(ckey_telemetry_take_batch(kDay, small.data(), small.size(), &len, &id) == CKEY_INSUFFICIENT_BUFFER);
453 CKEY_CHECK(len == want && id == 0);
454 CKEY_CHECK(ckey_telemetry_take_batch(kDay, nullptr, 10, &len, &id) == CKEY_INVALID_ARGUMENT);
455 CKEY_CHECK(ckey_telemetry_take_batch(kDay, small.data(), small.size(), nullptr, &id) == CKEY_INVALID_ARGUMENT);
456 CKEY_CHECK(ckey_telemetry_take_batch(kDay, small.data(), small.size(), &len, nullptr) == CKEY_INVALID_ARGUMENT);
457 CKEY_CHECK(ckey_telemetry_take_batch(nullptr, small.data(), small.size(), &len, &id) == CKEY_INVALID_ARGUMENT);
458 for (const char *bad : {"2026-13-01", "2026-02-29", "2026-1-05", "2026-11-5", "2026/11/05", "2026-11-050",
459 "2026-11-00", "", "20261105xx", "2026-04-31"})
460 CKEY_CHECK(ckey_telemetry_take_batch(bad, small.data(), small.size(), &len, &id) == CKEY_INVALID_ARGUMENT);
461 std::vector<char> exact(want + 1);
462 CKEY_CHECK(ckey_telemetry_take_batch(kDay, exact.data(), exact.size(), &len, &id) == CKEY_OK);
463 CKEY_CHECK(len == want && id != 0);
464 CKEY_CHECK(std::string(exact.data()) == kScenarioJson);
465 CKEY_CHECK(batch_valid(exact.data()));
466 // In flight: nothing more to take.
467 CKEY_CHECK(take().empty());
468 CKEY_CHECK(ckey_telemetry_batch_result(id, 204, 100000) == CKEY_OK);
469 // Leap day accepted.
470 ckey_telemetry_frame(100001, 1);
471 char buf[4096];
472 CKEY_CHECK(ckey_telemetry_take_batch("2024-02-29", buf, sizeof buf, &len, &id) == CKEY_OK && len > 0);
473 CKEY_CHECK(batch_valid(buf));
474}
475
476void test_set_client() {
477 ckey_telemetry_reset_for_testing();
478 CKEY_CHECK(ckey_telemetry_set_client("dotnet", "0.12.3-dev.5", "windows") == CKEY_OK);
479 for (const char *s : {"c", "swift", "android", "web", "dotnet"})
480 CKEY_CHECK(ckey_telemetry_set_client(s, "0.1.0", "linux") == CKEY_OK);
481 for (const char *p : {"ios", "macos", "android", "web", "windows", "linux", "other"})
482 CKEY_CHECK(ckey_telemetry_set_client("web", "0.1.0", p) == CKEY_OK);
483 CKEY_CHECK(ckey_telemetry_set_client("android", "0.2.3-build.123456", "android") == CKEY_OK);
484 const char *bad_versions[] = {"1.0.0", "0.1", "0.1.", "0..1", "0.1.0-", "0.1.0-build.", "0.1.0-build.1234567",
485 "0.1.0-rc.1", "0.1.0-build.1x", "v0.1.0", "0.1.0 ", "", "0.a.0",
486 "0.1.0-dev.1.2", "00.1.0x"};
487 for (const char *v : bad_versions) CKEY_CHECK(ckey_telemetry_set_client("c", v, "other") == CKEY_INVALID_ARGUMENT);
488 std::string long_version = "0." + std::string(70, '1') + ".0";
489 CKEY_CHECK(ckey_telemetry_set_client("c", long_version.c_str(), "other") == CKEY_INVALID_ARGUMENT);
490 for (const char *s : {"C", "java", "", "swift "})
491 CKEY_CHECK(ckey_telemetry_set_client(s, "0.1.0", "other") == CKEY_INVALID_ARGUMENT);
492 for (const char *p : {"iOS", "freebsd", ""})
493 CKEY_CHECK(ckey_telemetry_set_client("c", "0.1.0", p) == CKEY_INVALID_ARGUMENT);
494 CKEY_CHECK(ckey_telemetry_set_client(nullptr, "0.1.0", "ios") == CKEY_INVALID_ARGUMENT);
495 CKEY_CHECK(ckey_telemetry_set_client("c", nullptr, "ios") == CKEY_INVALID_ARGUMENT);
496 CKEY_CHECK(ckey_telemetry_set_client("c", "0.1.0", nullptr) == CKEY_INVALID_ARGUMENT);
497 // Failed calls changed nothing: the last good client is reported.
498 ckey_telemetry_frame(0, 1);
499 Json j = parse(take());
500 CKEY_CHECK(field(j, "sdk").str == "android" && field(j, "sdk_version").str == "0.2.3-build.123456" &&
501 field(j, "platform").str == "android");
502 // The client survives mode changes.
503 CKEY_CHECK(ckey_telemetry_set_mode(0) == CKEY_OK && ckey_telemetry_set_mode(1) == CKEY_OK);
504 ckey_telemetry_frame(0, 1);
505 CKEY_CHECK(field(parse(take()), "sdk").str == "android");
506}
507
508void test_batch_results() {
509 struct Case { int status; bool kept; };
510 const Case cases[] = {{204, false}, {200, false}, {400, false}, {413, false}, {404, false},
511 {429, true}, {500, true}, {503, true}, {0, true}, {302, true}};
512 for (const Case &k : cases) {
513 ckey_telemetry_reset_for_testing();
514 record_scenario();
515 uint64_t id = 0;
516 std::string first = take(&id);
517 CKEY_CHECK(!first.empty());
518 CKEY_CHECK(ckey_telemetry_batch_result(id + 1, k.status, 80000) == CKEY_INVALID_ARGUMENT);
519 CKEY_CHECK(ckey_telemetry_batch_result(0, k.status, 80000) == CKEY_INVALID_ARGUMENT);
520 CKEY_CHECK(ckey_telemetry_batch_result(id, -1, 80000) == CKEY_INVALID_ARGUMENT);
521 CKEY_CHECK(ckey_telemetry_batch_result(id, 1000, 80000) == CKEY_INVALID_ARGUMENT);
522 CKEY_CHECK(ckey_telemetry_batch_result(id, k.status, 80000) == CKEY_OK);
523 CKEY_CHECK(ckey_telemetry_batch_result(id, k.status, 80000) == CKEY_INVALID_ARGUMENT); // once only
524 uint64_t id2 = 0;
525 std::string again = take(&id2);
526 if (k.kept) {
527 CKEY_CHECK(again == first && id2 != id);
528 } else {
529 CKEY_CHECK(again.empty());
530 }
531 // Totals are unaffected by sending.
532 CKEY_CHECK(snap().frames == 8);
533 }
534 // A failed batch merges with counts recorded while it was in flight.
535 ckey_telemetry_reset_for_testing();
536 ckey_telemetry_frame(0, 1);
537 uint64_t id = 0;
538 CKEY_CHECK(!take(&id).empty());
539 ckey_telemetry_frame(5, 1);
540 CKEY_CHECK(ckey_telemetry_batch_result(id, 0, 10) == CKEY_OK);
541 Json j = parse(take());
542 CKEY_CHECK(field(j, "frames").num == 2);
543}
544
545void test_should_send() {
546 ckey_telemetry_reset_for_testing();
547 CKEY_CHECK(!ckey_telemetry_should_send(0));
548 CKEY_CHECK(!ckey_telemetry_should_send(10000000)); // empty delta
549 ckey_telemetry_frame(1000, 1);
550 CKEY_CHECK(!ckey_telemetry_should_send(1000));
551 CKEY_CHECK(!ckey_telemetry_should_send(60999)); // delta younger than 60 s
552 CKEY_CHECK(ckey_telemetry_should_send(61000));
553 uint64_t id = 0;
554 CKEY_CHECK(!take(&id).empty());
555 CKEY_CHECK(!ckey_telemetry_should_send(62000)); // in flight
556 ckey_telemetry_frame(62000, 1);
557 CKEY_CHECK(!ckey_telemetry_should_send(10000000)); // still in flight
558 CKEY_CHECK(ckey_telemetry_batch_result(id, 204, 70000) == CKEY_OK);
559 CKEY_CHECK(!ckey_telemetry_should_send(70000 + 3599999)); // hourly cadence
560 CKEY_CHECK(ckey_telemetry_should_send(70000 + 3600000));
561 CKEY_CHECK(!take(&id).empty());
562 // Failure: retried no sooner than an hour after the reported result.
563 CKEY_CHECK(ckey_telemetry_batch_result(id, 503, 4000000) == CKEY_OK);
564 CKEY_CHECK(!ckey_telemetry_should_send(4000000));
565 CKEY_CHECK(!ckey_telemetry_should_send(4000000 + 3599999));
566 CKEY_CHECK(ckey_telemetry_should_send(4000000 + 3600000)); // no minimum age for a kept batch
567 CKEY_CHECK(!take(&id).empty());
568 CKEY_CHECK(ckey_telemetry_batch_result(id, 204, 8000000) == CKEY_OK);
569 CKEY_CHECK(!ckey_telemetry_should_send(UINT64_MAX / 2)); // nothing left
570 // New delta after an hour still needs 60 s of age.
571 ckey_telemetry_frame(20000000, 1);
572 CKEY_CHECK(!ckey_telemetry_should_send(20000000 + 59999));
573 CKEY_CHECK(ckey_telemetry_should_send(20000000 + 60000));
574 // Never in modes 0 and 2.
575 CKEY_CHECK(ckey_telemetry_set_mode(2) == CKEY_OK);
576 ckey_telemetry_frame(20000000, 1);
577 CKEY_CHECK(!ckey_telemetry_should_send(UINT64_MAX / 2));
578 CKEY_CHECK(ckey_telemetry_set_mode(0) == CKEY_OK);
579 CKEY_CHECK(!ckey_telemetry_should_send(UINT64_MAX / 2));
580}
581
582void test_persistence() {
583 ckey_telemetry_reset_for_testing();
584 record_scenario();
585 uint64_t id = 0;
586 CKEY_CHECK(!take(&id).empty()); // in flight: not exported
587 ckey_telemetry_session_begin(100000);
588 ckey_telemetry_frame(100000, 33);
589 CKEY_CHECK(ckey_telemetry_session_success(100050, CKEY_TELEMETRY_PROFILE_P2) == CKEY_OK);
590 const std::string state = export_state();
591 const ckey_telemetry_counters totals = snap();
592 CKEY_CHECK(state.rfind("{\"state\":1,\"totals\":{\"sessions\":{\"started\":6,", 0) == 0);
593 CKEY_CHECK(state.find("\"unsent\":{\"sessions\":{\"started\":1,\"succeeded\":1,\"failed\":0},\"frames\":1,") !=
594 std::string::npos);
595 // Buffer too small.
596 size_t len = 0;
597 std::vector<char> small(state.size());
598 CKEY_CHECK(ckey_telemetry_export_state(small.data(), small.size(), &len) == CKEY_INSUFFICIENT_BUFFER);
599 CKEY_CHECK(len == state.size());
600 CKEY_CHECK(ckey_telemetry_export_state(nullptr, 5, &len) == CKEY_INVALID_ARGUMENT);
601 CKEY_CHECK(ckey_telemetry_export_state(small.data(), small.size(), nullptr) == CKEY_INVALID_ARGUMENT);
602
603 // Round trip into a fresh process state.
604 ckey_telemetry_reset_for_testing();
605 CKEY_CHECK(ckey_telemetry_import_state(state.c_str()) == CKEY_OK);
606 CKEY_CHECK(ckey_telemetry_get_mode() == CKEY_TELEMETRY_ON);
607 CKEY_CHECK(same(snap(), totals));
608 CKEY_CHECK(export_state() == state);
609 CKEY_CHECK(ckey_telemetry_should_send(0)); // restored delta: no minimum age, no cadence yet
610 Json j = parse(take());
611 CKEY_CHECK(field(j, "frames").num == 1);
612 CKEY_CHECK(field(field(j, "decoded"), "p2").num == 1);
613
614 // Whitespace between tokens is accepted.
615 std::string spaced;
616 for (char c : state) {
617 spaced += c;
618 if (c == ',' || c == ':' || c == '{' || c == '[') spaced += " \n\t";
619 }
620 ckey_telemetry_reset_for_testing();
621 CKEY_CHECK(ckey_telemetry_import_state((" " + spaced + "\r\n").c_str()) == CKEY_OK);
622 CKEY_CHECK(export_state() == state);
623
624 // Import adds.
625 CKEY_CHECK(ckey_telemetry_import_state(state.c_str()) == CKEY_OK);
626 CKEY_CHECK(snap().frames == 2 * totals.frames);
627
628 // Malformed input is rejected and leaves the state unchanged.
629 const std::string current = export_state();
630 auto replaced = [&](const std::string &from, const std::string &to) {
631 std::string s = state;
632 size_t p = s.find(from);
633 CKEY_CHECK(p != std::string::npos);
634 s.replace(p, from.size(), to);
635 return s;
636 };
637 std::vector<std::string> bad = {
638 "",
639 "{}",
640 "null",
641 state.substr(0, state.size() - 1), // truncated
642 state + "x", // trailing data
643 state + "}",
644 replaced("{\"state\":1", "{\"state\":2"), // unknown version
645 replaced("\"frames\":9", "\"frames\":09"), // leading zero
646 replaced("\"frames\":9", "\"frames\":-9"),
647 replaced("\"frames\":9", "\"frames\":9.0"),
648 replaced("\"frames\":9", "\"frames\":1e3"),
649 replaced("\"frames\":9", "\"frames\":18446744073709551616"), // overflow
650 replaced("\"frames\":9", "\"frames\":\"9\""),
651 replaced("\"frames\":9,", "\"frames\":9,\"extra\":1,"), // unknown key
652 replaced("\"frames\":9,", ""), // missing key
653 replaced("\"started\":6,\"succeeded\":3", "\"succeeded\":3,\"started\":6"), // reordered
654 replaced("\"totals\"", "\"Totals\""),
655 replaced("{\"count\":3,", "{\"count\":4,"), // buckets do not sum to count
656 replaced("\"unsent\"", "\"unsent \""),
657 replaced("[", "[ 1,"), // 11 buckets
658 };
659 // A sum no observations could produce.
660 bad.push_back(replaced("{\"count\":3,\"sum\":101,", "{\"count\":3,\"sum\":99999999999999,"));
661 std::string huge(20000, ' ');
662 bad.push_back(state + huge);
663 for (const std::string &b : bad) {
664 CKEY_CHECK(ckey_telemetry_import_state(b.c_str()) == CKEY_INVALID_ARGUMENT);
665 CKEY_CHECK(export_state() == current);
666 }
667 CKEY_CHECK(ckey_telemetry_import_state(nullptr) == CKEY_INVALID_ARGUMENT);
668 // Sanity: the replacements above targeted the intended text.
669 CKEY_CHECK(state.find("\"frames\":9,") != std::string::npos);
670
671 // Mode 0: import rejected, export is all zeros.
672 CKEY_CHECK(ckey_telemetry_set_mode(0) == CKEY_OK);
673 CKEY_CHECK(ckey_telemetry_import_state(state.c_str()) == CKEY_INVALID_ARGUMENT);
674 CKEY_CHECK(ckey_telemetry_get_mode() == CKEY_TELEMETRY_OFF);
675 CKEY_CHECK(is_zero(snap()));
676 const std::string zero_state = export_state();
677 ckey_telemetry_reset_for_testing();
678 CKEY_CHECK(export_state() == zero_state);
679
680 // Mode 2: totals imported, the unsent delta never.
681 CKEY_CHECK(ckey_telemetry_set_mode(2) == CKEY_OK);
682 CKEY_CHECK(ckey_telemetry_import_state(state.c_str()) == CKEY_OK);
683 CKEY_CHECK(ckey_telemetry_get_mode() == CKEY_TELEMETRY_LOCAL);
684 CKEY_CHECK(same(snap(), totals));
685 CKEY_CHECK(ckey_telemetry_set_mode(1) == CKEY_OK);
686 CKEY_CHECK(take().empty());
687 CKEY_CHECK(!ckey_telemetry_should_send(UINT64_MAX / 2));
688}
689
690std::string zero_counters_json() {
691 return "{\"sessions\":{\"started\":0,\"succeeded\":0,\"failed\":0},\"frames\":0,"
692 "\"decoded\":{\"p0\":0,\"p0_1\":0,\"p1\":0,\"p2\":0,\"p3\":0},"
693 "\"time_to_decode_ms\":{\"count\":0,\"sum\":0,\"buckets\":[0,0,0,0,0,0,0,0,0,0]},"
694 "\"frame_ms\":{\"count\":0,\"sum\":0,\"buckets\":[0,0,0,0,0,0,0,0,0,0]}}";
695}
696
697void test_clamping() {
698 ckey_telemetry_reset_for_testing();
699 // Unsent: 25M frames and started; frame_ms with an average of 4,000,000 ms
700 // so its sum hits 1e12 first; time_to_decode with 15M observations.
701 const std::string unsent =
702 "{\"sessions\":{\"started\":25000000,\"succeeded\":15000000,\"failed\":3},\"frames\":25000000,"
703 "\"decoded\":{\"p0\":15000000,\"p0_1\":0,\"p1\":0,\"p2\":0,\"p3\":0},"
704 "\"time_to_decode_ms\":{\"count\":15000000,\"sum\":150000000,\"buckets\":[5000000,10000000,0,0,0,0,0,0,0,0]},"
705 "\"frame_ms\":{\"count\":1000000,\"sum\":4000000000000,\"buckets\":[0,0,0,0,0,0,0,0,0,1000000]}}";
706 const std::string state = "{\"state\":1,\"totals\":" + unsent + ",\"unsent\":" + unsent + "}";
707 CKEY_CHECK(ckey_telemetry_import_state(state.c_str()) == CKEY_OK);
708
709 uint64_t frames = 0, started = 0, failed = 0, ttd_count = 0, ttd_sum = 0, fm_count = 0, fm_sum = 0;
710 uint64_t ttd_b0 = 0, ttd_b1 = 0;
711 int batches = 0;
712 for (;;) {
713 uint64_t id = 0;
714 std::string b = take(&id); // take() validates every batch
715 if (b.empty()) break;
716 ++batches;
717 Json j = parse(b);
718 if (batches == 1) {
719 CKEY_CHECK(field(j, "frames").num == 10000000);
720 const Json &fm = field(j, "frame_ms");
721 CKEY_CHECK(field(fm, "count").num == 250000 && field(fm, "sum").num == 1000000000000ull);
722 const Json &ttd = field(j, "time_to_decode_ms");
723 CKEY_CHECK(field(ttd, "count").num == 10000000);
724 CKEY_CHECK(field(ttd, "buckets").arr[0].num == 3333334 && field(ttd, "buckets").arr[1].num == 6666666);
725 CKEY_CHECK(field(ttd, "sum").num == 100000000);
726 }
727 frames += field(j, "frames").num;
728 started += field(field(j, "sessions"), "started").num;
729 failed += field(field(j, "sessions"), "failed").num;
730 const Json &ttd = field(j, "time_to_decode_ms");
731 ttd_count += field(ttd, "count").num;
732 ttd_sum += field(ttd, "sum").num;
733 ttd_b0 += field(ttd, "buckets").arr[0].num;
734 ttd_b1 += field(ttd, "buckets").arr[1].num;
735 const Json &fm = field(j, "frame_ms");
736 fm_count += field(fm, "count").num;
737 fm_sum += field(fm, "sum").num;
738 CKEY_CHECK(ckey_telemetry_batch_result(id, 204, 0) == CKEY_OK);
739 CKEY_CHECK(batches < 10);
740 }
741 CKEY_CHECK(batches == 4); // frame_ms needs 4 batches of 250,000
742 CKEY_CHECK(frames == 25000000 && started == 25000000 && failed == 3);
743 CKEY_CHECK(ttd_count == 15000000 && ttd_sum == 150000000 && ttd_b0 == 5000000 && ttd_b1 == 10000000);
744 CKEY_CHECK(fm_count == 1000000 && fm_sum == 4000000000000ull);
745 // Excess carried in the export while pending, and retained across a failed send.
746 ckey_telemetry_reset_for_testing();
747 CKEY_CHECK(ckey_telemetry_import_state(state.c_str()) == CKEY_OK);
748 uint64_t id = 0;
749 std::string first = take(&id);
750 CKEY_CHECK(ckey_telemetry_batch_result(id, 500, 0) == CKEY_OK);
751 CKEY_CHECK(take(&id) == first);
752 CKEY_CHECK(ckey_telemetry_batch_result(id, 204, 0) == CKEY_OK);
753 const std::string rest = export_state();
754 CKEY_CHECK(rest.find("\"unsent\":{\"sessions\":{\"started\":15000000,") != std::string::npos);
755 // Local totals saturate instead of wrapping.
756 ckey_telemetry_reset_for_testing();
757 const std::string max_state = "{\"state\":1,\"totals\":" + std::string(
758 "{\"sessions\":{\"started\":18446744073709551615,\"succeeded\":0,\"failed\":0},\"frames\":18446744073709551615,"
759 "\"decoded\":{\"p0\":0,\"p0_1\":0,\"p1\":0,\"p2\":0,\"p3\":0},"
760 "\"time_to_decode_ms\":{\"count\":0,\"sum\":0,\"buckets\":[0,0,0,0,0,0,0,0,0,0]},"
761 "\"frame_ms\":{\"count\":0,\"sum\":0,\"buckets\":[0,0,0,0,0,0,0,0,0,0]}}") +
762 ",\"unsent\":" + zero_counters_json() + "}";
763 CKEY_CHECK(ckey_telemetry_import_state(max_state.c_str()) == CKEY_OK);
764 ckey_telemetry_frame(0, 1);
765 CKEY_CHECK(ckey_telemetry_import_state(max_state.c_str()) == CKEY_OK);
766 ckey_telemetry_counters c = snap();
767 CKEY_CHECK(c.frames == UINT64_MAX && c.sessions_started == UINT64_MAX && c.frame_ms.count == 1);
768 Json j = parse(take());
769 CKEY_CHECK(field(j, "frames").num == 1);
770}
771
772void test_threads() {
773 ckey_telemetry_reset_for_testing();
774 std::vector<std::thread> threads;
775 for (int t = 0; t < 4; ++t) {
776 threads.emplace_back([t] {
777 for (uint64_t i = 0; i < 5000; ++i) {
778 ckey_telemetry_frame(i, static_cast<uint32_t>(i % 50));
779 if (i % 100 == 0) ckey_telemetry_session_success(i, static_cast<uint32_t>(t));
780 if (i % 777 == 0) {
781 uint64_t id = 0;
782 char buf[4096];
783 size_t len = 0;
784 if (ckey_telemetry_take_batch(kDay, buf, sizeof buf, &len, &id) == CKEY_OK && id)
785 ckey_telemetry_batch_result(id, (i % 2) ? 500 : 204, i);
786 }
787 }
788 });
789 }
790 for (auto &th : threads) th.join();
791 ckey_telemetry_counters c = snap();
792 CKEY_CHECK(c.frames == 20000 && c.frame_ms.count == 20000);
793 uint64_t t = 0;
794 for (uint64_t b : c.frame_ms.buckets) t += b;
795 CKEY_CHECK(t == 20000);
796}
797
798} // namespace
799
800int main() {
801 // Self-check of the validator against the doc's own example.
802 const std::string doc_example =
803 "{\"schema\":1,\"sdk\":\"swift\",\"sdk_version\":\"0.1.0-build.61\",\"platform\":\"ios\",\"day\":\"2026-11-05\","
804 "\"sessions\":{\"started\":12,\"succeeded\":10,\"failed\":2},\"frames\":3120,"
805 "\"decoded\":{\"p0\":7,\"p0_1\":1,\"p1\":1,\"p2\":1,\"p3\":0},"
806 "\"time_to_decode_ms\":{\"count\":10,\"sum\":4210,\"buckets\":[0,1,3,4,1,1,0,0,0,0]},"
807 "\"frame_ms\":{\"count\":3120,\"sum\":40110,\"buckets\":[10,400,2000,600,100,10,0,0,0,0]}}";
808 CKEY_CHECK(batch_valid(doc_example));
809 CKEY_CHECK(batch_valid(kScenarioJson));
810 std::string broken = doc_example;
811 broken.replace(broken.find("\"count\":10,"), 11, "\"count\":11,");
812 CKEY_CHECK(!batch_valid(broken)); // bucket sum mismatch
813 CKEY_CHECK(!batch_valid(doc_example.substr(0, doc_example.size() - 1) + ",\"x\":1}")); // unknown key
814
815 test_defaults_and_modes();
816 test_default_client_json();
817 test_mode_off();
818 test_on_to_local();
819 test_local_to_on_fresh_delta();
820 test_sessions();
821 test_histogram_edges();
822 test_exact_json_and_buffer();
823 test_set_client();
824 test_batch_results();
825 test_should_send();
826 test_persistence();
827 test_clamping();
828 test_threads();
829 ckey_telemetry_reset_for_testing();
830 std::puts("telemetry tests passed");
831 return 0;
832}