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
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 | "ckey/telemetry.h" |
| 5 | "check.h" |
| 6 | |
| 7 | <cstdint> |
| 8 | <cstring> |
| 9 | <string> |
| 10 | <thread> |
| 11 | <vector> |
| 12 | |
| 13 | namespace { |
| 14 | |
| 15 | // ---- Minimal JSON reader + batch validator per docs/adoption-and-telemetry.md |
| 16 | |
| 17 | struct 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 | |
| 26 | struct 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 | |
| 83 | bool keys_are(const Json &o, std::vector<std::string> keys) { |
| 84 | return o.kind == Json::Obj && o.keys == keys; |
| 85 | } |
| 86 | |
| 87 | bool count_ok(const Json &v) { return v.kind == Json::Num && v.num <= CKEY_TELEMETRY_MAX_COUNT; } |
| 88 | |
| 89 | bool 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. |
| 103 | bool 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. |
| 111 | bool 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. |
| 130 | bool 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. |
| 162 | Json 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 | } |
| 168 | const 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 | |
| 177 | const char *kDay = "2026-11-05"; |
| 178 | |
| 179 | std::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 | |
| 191 | std::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 | |
| 202 | ckey_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 | |
| 209 | bool is_zero(const ckey_telemetry_counters &c) { |
| 210 | ckey_telemetry_counters z{}; |
| 211 | return std::memcmp(&c, &z, sizeof c) == 0; |
| 212 | } |
| 213 | |
| 214 | bool 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). |
| 219 | void 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 | |
| 237 | const 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 | |
| 246 | void 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 | |
| 267 | void 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 | |
| 277 | void 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 | |
| 305 | void 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 | |
| 329 | void 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 | |
| 345 | void 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 | |
| 415 | void 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 | |
| 441 | void 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 | |
| 476 | void 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 | |
| 508 | void 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 | |
| 545 | void 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 | |
| 582 | void 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 | |
| 690 | std::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 | |
| 697 | void 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 | |
| 772 | void 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 | |
| 800 | int 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 | } |