cartethyia 2.5.3 → 2.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/dist/dashboard/assets/{BannedPage-hfCzi-Ux.js → BannedPage-C2orBsI2.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  3. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +1 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-DEDyKBlC.js → CliToolsPage-BV6pxVQo.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-CfuYiQ8Z.js → CombosPage-Bsan4679.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-bsLuZmYH.js → ConsoleLogPage-CW-wPmJ6.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-_UWhwziJ.js → CustomizationPage-0p8uX7vB.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  13. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js +1 -0
  14. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-DDVIfZGW.js → LoginPage-BvzCx8Kr.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  17. package/dist/dashboard/assets/{ModelPicker-CWkTUtpX.js → ModelPicker-BY1aKEkP.js} +1 -1
  18. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  19. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +1 -0
  20. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  21. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +3 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js +1 -0
  24. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  25. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +7 -0
  26. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  27. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +1 -0
  28. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  29. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +1 -0
  30. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  31. package/dist/dashboard/assets/{SetupPage-D6pmQ86L.js → SetupPage-DDbMQsoo.js} +1 -1
  32. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  33. package/dist/dashboard/assets/{SortableList-DWvKiuSl.js → SortableList-CB3Z7VTf.js} +1 -1
  34. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  35. package/dist/dashboard/assets/{StudioPage-CmkUrgdj.js → StudioPage-D95UCPHI.js} +3 -3
  36. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  37. package/dist/dashboard/assets/{UsagePage-B47uOydr.js → UsagePage-Cnae9SWA.js} +13 -13
  38. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  39. package/dist/dashboard/assets/{api-keys-uNywYlfv.js → api-keys-VY68Mhd1.js} +1 -1
  40. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  41. package/dist/dashboard/assets/app-COlK95oi.css +2 -0
  42. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  43. package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-Chi7pCnt.js} +1 -1
  44. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  45. package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-D4pQapKj.js} +1 -1
  46. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  47. package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-DH6cg-_B.js} +1 -1
  48. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  49. package/dist/dashboard/assets/{card--AtFedAq.js → card-BKqlUOe4.js} +1 -1
  50. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  51. package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-fTgljTGA.js} +1 -1
  52. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  53. package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-D5Ys_sM0.js} +1 -1
  54. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  55. package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
  56. package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
  57. package/dist/dashboard/assets/{dialog-BtpsJU98.js → dialog-BYj-8WuK.js} +1 -1
  58. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  59. package/dist/dashboard/assets/{drawer-WwwtzihF.js → drawer-CmXIZ6H1.js} +1 -1
  60. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  61. package/dist/dashboard/assets/entry-CBg5zeu8.js +1 -0
  62. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  63. package/dist/dashboard/assets/entry-CQBr6U9c.js +1 -0
  64. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CgdSAzEV.js +4 -0
  66. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  67. package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-BlptL8Uf.js} +1 -1
  68. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  69. package/dist/dashboard/assets/{index-BydyKaDz.js → index-js6TJvdr.js} +2 -2
  70. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  71. package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-DUWDSmDV.js} +1 -1
  72. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  73. package/dist/dashboard/assets/{input-BG41et9G.js → input-LVjgZq4f.js} +1 -1
  74. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  75. package/dist/dashboard/assets/{layout-C8mer_Ak.js → layout-CPfxpsfy.js} +1 -1
  76. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  77. package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-DupvLfDx.js} +1 -1
  78. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  79. package/dist/dashboard/assets/{live-BJGYRBnR.js → live-C4pEJUqI.js} +1 -1
  80. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  81. package/dist/dashboard/assets/model-bans-BRiuufkB.js +1 -0
  82. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  83. package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-B4ROwk8L.js} +1 -1
  84. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  85. package/dist/dashboard/assets/numbers-YNcHvRY7.js +2 -0
  86. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  87. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-gmjzC3RS.js} +1 -1
  88. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js.gz → query-keys-gmjzC3RS.js.gz} +0 -0
  89. package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-C7tBT5o-.js} +1 -1
  90. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  91. package/dist/dashboard/assets/{select-BO6h8yNv.js → select-WUCXorXw.js} +2 -2
  92. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  93. package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-C_zKl1GS.js} +1 -1
  94. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  95. package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-_3O0F8Xn.js} +1 -1
  96. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  97. package/dist/dashboard/assets/{state-DYdQmdAl.js → state-BgTxHcJS.js} +1 -1
  98. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  99. package/dist/dashboard/assets/switch-DEjTtOWf.js +1 -0
  100. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  101. package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
  102. package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-BWSC0UAS.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  105. package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
  106. package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
  107. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  108. package/dist/dashboard/index.html +1 -1
  109. package/dist/dashboard/index.html.gz +0 -0
  110. package/dist/dashboard/precompressed-assets.json +54 -54
  111. package/package.json +1 -1
  112. package/scripts/cli/autostart.ts +237 -0
  113. package/scripts/cli/command/console-menu.ts +4 -8
  114. package/scripts/cli/command/update.ts +27 -10
  115. package/scripts/cli/main.ts +28 -1
  116. package/src/boot/app.ts +693 -693
  117. package/src/boot/dependencies.ts +646 -616
  118. package/src/config/spec.ts +55 -0
  119. package/src/config/telemetry.ts +46 -0
  120. package/src/console/api-keys/bucket-spend.ts +15 -4
  121. package/src/console/backup/contracts.ts +15 -2
  122. package/src/console/console-router.ts +8 -0
  123. package/src/console/data-management/contracts.ts +93 -0
  124. package/src/console/data-management/operations.ts +229 -0
  125. package/src/console/data-management/routes.ts +104 -0
  126. package/src/console/domain-registration.ts +39 -0
  127. package/src/console/observability/contracts.ts +524 -485
  128. package/src/console/observability/store.ts +1115 -1018
  129. package/src/console/providers/catalog/contracts.ts +6 -0
  130. package/src/console/providers/catalog/provider-operations.ts +20 -0
  131. package/src/console/providers/catalog/routes.ts +7 -0
  132. package/src/console/providers/catalog/store.ts +22 -1
  133. package/src/console/quota/account-quota-shared.ts +52 -39
  134. package/src/console/quota/refresh.ts +441 -429
  135. package/src/console/routing/pools/import-parse.ts +206 -0
  136. package/src/console/routing/pools/operations.ts +543 -501
  137. package/src/console/routing/pools/routes.ts +181 -164
  138. package/src/console/settings/contracts.ts +37 -0
  139. package/src/console/settings/store.ts +51 -0
  140. package/src/console/updates/routes.ts +16 -0
  141. package/src/main.ts +10 -0
  142. package/src/observability/event-compaction.ts +315 -0
  143. package/src/observability/event-day-index.ts +442 -0
  144. package/src/observability/event-log.ts +152 -3
  145. package/src/observability/payload-capture.ts +8 -1
  146. package/src/observability/payload-store.ts +440 -376
  147. package/src/persistence/health-event-flush.ts +36 -0
  148. package/src/persistence/schema.ts +1402 -1360
  149. package/src/persistence/stores/telemetry-store.ts +237 -214
  150. package/src/protocol/codecs.ts +1 -2
  151. package/src/protocol/image-clamp.ts +19 -1
  152. package/src/protocol/registry.ts +8 -0
  153. package/src/protocol/request/gemini.ts +9 -2
  154. package/src/protocol/request/messages.ts +14 -2
  155. package/src/providers/adapters/compatible-adapter.ts +3 -0
  156. package/src/providers/bundled-discovery-providers.ts +122 -119
  157. package/src/providers/configured-openai-providers.ts +136 -134
  158. package/src/providers/default-registry.ts +5 -4
  159. package/src/providers/discovery/openai-model-discovery.ts +5 -0
  160. package/src/providers/discovery/openrouter-free-roster.ts +15 -17
  161. package/src/providers/integrations/cline/cline-quota.ts +132 -125
  162. package/src/providers/integrations/codex/codex-headers.ts +25 -39
  163. package/src/providers/integrations/codex/codex.ts +2 -29
  164. package/src/providers/integrations/cursor/cursor-quota.ts +310 -0
  165. package/src/providers/integrations/cursor/cursor-request.ts +616 -471
  166. package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
  167. package/src/providers/integrations/cursor/cursor.ts +1485 -960
  168. package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
  169. package/src/providers/integrations/factory/factory.ts +8 -1
  170. package/src/providers/integrations/gemini.ts +6 -1
  171. package/src/providers/integrations/grok/grok-quota.ts +279 -177
  172. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
  173. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
  174. package/src/providers/integrations/opencode/provider.ts +12 -13
  175. package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
  176. package/src/providers/integrations/trae/trae-quota.ts +384 -0
  177. package/src/providers/operations/account-health-service.ts +1523 -1480
  178. package/src/providers/provider-contracts.ts +7 -0
  179. package/src/providers/provider-metadata.ts +6 -0
  180. package/src/providers/quota/quota-support.ts +8 -0
  181. package/src/security/api-key-auth.ts +504 -494
  182. package/src/transport/dispatch/generation.ts +6 -0
  183. package/src/transport/dispatch/proxy-request.ts +24 -1
  184. package/src/transport/dispatch/ratelimit-capture.ts +37 -0
  185. package/src/transport/dispatch/streaming/stream.ts +14 -0
  186. package/src/transport/dispatch/upstream.ts +9 -0
  187. package/src/transport/request/preparer.ts +7 -6
  188. package/src/transport/request/state.ts +9 -0
  189. package/src/transport/request/tenant-preferences.ts +60 -0
  190. package/src/transport/routing/route-catalog.ts +58 -10
  191. package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
  192. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +0 -1
  193. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
  194. package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
  195. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
  196. package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
  197. package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
  198. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +0 -1
  199. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
  200. package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
  201. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  202. package/dist/dashboard/assets/OverviewPage-B53YYMng.js +0 -1
  203. package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
  204. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +0 -3
  205. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
  206. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +0 -1
  207. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
  208. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +0 -7
  209. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
  210. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +0 -1
  211. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
  212. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +0 -1
  213. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
  214. package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
  215. package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
  216. package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
  217. package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
  218. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  219. package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
  220. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  221. package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
  222. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  223. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  224. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  225. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  226. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  227. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  228. package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
  229. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  230. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  231. package/dist/dashboard/assets/entry-CkryrIea.js +0 -4
  232. package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
  233. package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
  234. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  235. package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
  236. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  237. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  238. package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
  239. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  240. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  241. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  242. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  243. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  244. package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
  245. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  246. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  247. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
  248. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  249. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  250. package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
  251. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  252. package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
  253. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  254. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  255. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  256. package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
  257. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  258. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
  259. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  260. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  261. /package/dist/dashboard/assets/{app-CafWtVXA.js → app-DCQZRmHY.js} +0 -0
  262. /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-DCQZRmHY.js.gz} +0 -0
@@ -0,0 +1,442 @@
1
+ import { mkdir, open, readFile, rename, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ accumulate,
5
+ emptyBucket,
6
+ mergeBucketsInternal,
7
+ type CompactedBucket,
8
+ } from "./event-compaction";
9
+ import { TELEMETRY_EVENT_DIR, type TelemetryEventRecord } from "./event-log";
10
+
11
+ /**
12
+ * One sidecar index per day, beside that day's append-only event file.
13
+ *
14
+ * The event file grows all day and is never rewritten while it is open. The
15
+ * index is the thing the dashboard reads: it holds the same totals and the same
16
+ * breakdown counts the old per-day compaction produced, but they are maintained
17
+ * incrementally from a byte watermark rather than rebuilt by folding and
18
+ * deleting source files.
19
+ *
20
+ * That watermark is the whole reason this replaces the old scheme. Which file
21
+ * was still being written used to be decided by comparing file *names*, so the
22
+ * open file was periodically folded and unlinked mid-write, and its requests
23
+ * never reached the summary. A byte offset has no such ambiguity: everything
24
+ * below it is counted, everything above it is not, and re-reading the tail is
25
+ * idempotent.
26
+ */
27
+
28
+ /**
29
+ * An event whose tenant is known to be present.
30
+ *
31
+ * `tenantId` is optional on the record because a row can arrive without one,
32
+ * but the index is keyed by tenant, so only rows carrying one are folded.
33
+ */
34
+ type FoldedEvent = TelemetryEventRecord & { readonly tenantId: string };
35
+
36
+ /** Marker so a reader can tell an index from anything else in the directory. */
37
+ const INDEX_KIND = "telemetry-day-index" as const;
38
+
39
+ /** Version bumped whenever the shape changes; an unreadable version is ignored. */
40
+ const INDEX_VERSION = 1 as const;
41
+
42
+ /** The event file for one UTC day. */
43
+ export function dayEventFileName(day: string): string {
44
+ return `${day}.events.jsonl`;
45
+ }
46
+
47
+ /** The sidecar index for one UTC day. */
48
+ export function dayIndexFileName(day: string): string {
49
+ return `${day}.index.json`;
50
+ }
51
+
52
+ /** The UTC day (`2026-10-22`) a moment belongs to. */
53
+ export function utcDay(at: Date): string {
54
+ return at.toISOString().slice(0, 10);
55
+ }
56
+
57
+ /**
58
+ * One hour's share of a bucket.
59
+ *
60
+ * The Traffic chart asks how a day was spent, which the totals alone cannot
61
+ * answer. Twenty-four slots per bucket is small — four numbers each — and is
62
+ * held *inside* the index rather than as the per-hour files this replaced.
63
+ */
64
+ /**
65
+ * One named dimension's share of a bucket.
66
+ *
67
+ * The breakdown asks for tokens, errors and cost per model/provider/client/IP,
68
+ * not merely how many requests each took. Counts alone were enough for the old
69
+ * per-day totals, but the breakdown columns would all have read zero, so each
70
+ * named entry carries the same running sums the bucket does.
71
+ */
72
+ export interface IndexedDimension {
73
+ readonly requests: number;
74
+ readonly inputTokens: number;
75
+ readonly cachedTokens: number;
76
+ readonly outputTokens: number;
77
+ readonly errors: number;
78
+ readonly estimatedCostUsd: number;
79
+ readonly tokensPerSecSum: number;
80
+ readonly tokensPerSecCount: number;
81
+ }
82
+
83
+ export interface IndexedHour {
84
+ readonly requests: number;
85
+ readonly inputTokens: number;
86
+ readonly cachedTokens: number;
87
+ readonly outputTokens: number;
88
+ }
89
+
90
+ /** One tenant/key pair's running totals for the day. */
91
+ export interface IndexedBucket extends CompactedBucket {
92
+ readonly tenantId: string;
93
+ readonly apiKeyId: string | null;
94
+ /** Index 0-23, the UTC hour an event fell in. */
95
+ readonly hours: readonly IndexedHour[];
96
+ /** Per-name sums for each breakdown dimension. */
97
+ readonly dimModel: Readonly<Record<string, IndexedDimension>>;
98
+ readonly dimProvider: Readonly<Record<string, IndexedDimension>>;
99
+ readonly dimClient: Readonly<Record<string, IndexedDimension>>;
100
+ readonly dimClientIp: Readonly<Record<string, IndexedDimension>>;
101
+ }
102
+
103
+ /** The on-disk shape of a day index. */
104
+ export interface DayIndex {
105
+ readonly kind: typeof INDEX_KIND;
106
+ readonly version: typeof INDEX_VERSION;
107
+ readonly day: string;
108
+ /**
109
+ * Bytes of the event file already folded in.
110
+ *
111
+ * Everything past this offset is uncounted; a crash between append and index
112
+ * write only ever leaves it behind, never ahead, so the tail is re-read.
113
+ */
114
+ readonly watermarkBytes: number;
115
+ /** Totals per tenant/key. */
116
+ readonly buckets: readonly IndexedBucket[];
117
+ /** Wall-clock of the last fold, for operators reading the file by hand. */
118
+ readonly updatedAt: string;
119
+ }
120
+
121
+ function bucketKey(tenantId: string, apiKeyId: string | null): string {
122
+ return `${tenantId}|${apiKeyId ?? ""}`;
123
+ }
124
+
125
+ /** Twenty-four empty hour slots. */
126
+ function emptyHours(): IndexedHour[] {
127
+ return Array.from({ length: 24 }, () => ({
128
+ requests: 0,
129
+ inputTokens: 0,
130
+ cachedTokens: 0,
131
+ outputTokens: 0,
132
+ }));
133
+ }
134
+
135
+ /** The UTC hour (0-23) of an ISO timestamp. */
136
+ function hourOf(createdAt: string): number {
137
+ const at = Date.parse(createdAt);
138
+ if (!Number.isFinite(at)) return 0;
139
+ return Number.parseInt(createdAt.slice(11, 13), 10) || 0;
140
+ }
141
+
142
+ /** An empty per-name entry. */
143
+ function emptyDimension(): IndexedDimension {
144
+ return {
145
+ requests: 0,
146
+ inputTokens: 0,
147
+ cachedTokens: 0,
148
+ outputTokens: 0,
149
+ errors: 0,
150
+ estimatedCostUsd: 0,
151
+ tokensPerSecSum: 0,
152
+ tokensPerSecCount: 0,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * A cost as a number.
158
+ *
159
+ * Costs arrive as strings from `numeric` columns and from restored rows, and
160
+ * `0 + "0.000966"` concatenates rather than sums. Coerced once, here, so the
161
+ * per-name breakdown cannot accumulate a string the way it did.
162
+ */
163
+ function numericCost(value: unknown): number {
164
+ const asNumber = typeof value === "number" ? value : Number(value);
165
+ return Number.isFinite(asNumber) ? asNumber : 0;
166
+ }
167
+
168
+ /** Folds one event into one named dimension entry. */
169
+ function foldDimension(
170
+ into: Readonly<Record<string, IndexedDimension>> | undefined,
171
+ name: string | null | undefined,
172
+ event: FoldedEvent,
173
+ ): Record<string, IndexedDimension> {
174
+ const out: Record<string, IndexedDimension> = { ...(into ?? {}) };
175
+ const key = typeof name === "string" ? name.trim() : "";
176
+ if (key === "") return out;
177
+ const prior = out[key] ?? emptyDimension();
178
+ const failed = event.status === "failed" ? 1 : 0;
179
+ out[key] = {
180
+ requests: prior.requests + 1,
181
+ inputTokens: prior.inputTokens + (event.inputTokens ?? 0),
182
+ cachedTokens: prior.cachedTokens + (event.cachedInputTokens ?? 0),
183
+ outputTokens: prior.outputTokens + (event.outputTokens ?? 0),
184
+ errors: prior.errors + failed,
185
+ estimatedCostUsd: prior.estimatedCostUsd + numericCost(event.estimatedCostUsd),
186
+ tokensPerSecSum: prior.tokensPerSecSum + (event.tokensPerSec ?? 0),
187
+ tokensPerSecCount: prior.tokensPerSecCount + (event.tokensPerSec === null ? 0 : 1),
188
+ };
189
+ return out;
190
+ }
191
+
192
+ /** Adds one event's tokens into its hour slot. */
193
+ function withHour(
194
+ hours: readonly IndexedHour[],
195
+ event: FoldedEvent,
196
+ ): IndexedHour[] {
197
+ const base = hours.length === 24 ? [...hours] : emptyHours();
198
+ const index = hourOf(event.createdAt);
199
+ const prior = base[index] ?? { requests: 0, inputTokens: 0, cachedTokens: 0, outputTokens: 0 };
200
+ base[index] = {
201
+ requests: prior.requests + 1,
202
+ inputTokens: prior.inputTokens + (event.inputTokens ?? 0),
203
+ cachedTokens: prior.cachedTokens + (event.cachedInputTokens ?? 0),
204
+ outputTokens: prior.outputTokens + (event.outputTokens ?? 0),
205
+ };
206
+ return base;
207
+ }
208
+
209
+ /** An empty index for a day with nothing counted yet. */
210
+ export function emptyDayIndex(day: string): DayIndex {
211
+ return {
212
+ kind: INDEX_KIND,
213
+ version: INDEX_VERSION,
214
+ day,
215
+ watermarkBytes: 0,
216
+ buckets: [],
217
+ updatedAt: new Date().toISOString(),
218
+ };
219
+ }
220
+
221
+ /** True when the index is one this version can read. */
222
+ function isDayIndex(value: unknown): value is DayIndex {
223
+ if (typeof value !== "object" || value === null) return false;
224
+ const candidate = value as Partial<DayIndex>;
225
+ return (
226
+ candidate.kind === INDEX_KIND &&
227
+ candidate.version === INDEX_VERSION &&
228
+ typeof candidate.day === "string" &&
229
+ typeof candidate.watermarkBytes === "number" &&
230
+ Array.isArray(candidate.buckets)
231
+ );
232
+ }
233
+
234
+ /** Reads a day index, or an empty one when it is absent or unreadable. */
235
+ export async function readDayIndex(day: string): Promise<DayIndex> {
236
+ const text = await readFile(join(TELEMETRY_EVENT_DIR, dayIndexFileName(day)), "utf8").catch(
237
+ () => undefined,
238
+ );
239
+ if (text === undefined) return emptyDayIndex(day);
240
+ try {
241
+ const parsed: unknown = JSON.parse(text);
242
+ return isDayIndex(parsed) ? parsed : emptyDayIndex(day);
243
+ } catch {
244
+ // A torn index costs nothing to rebuild — the event file is the record — so
245
+ // a parse failure starts over rather than failing the read.
246
+ return emptyDayIndex(day);
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Writes an index atomically.
252
+ *
253
+ * Written to a pid-suffixed temp file and renamed, so a reader never sees a
254
+ * half-written index and a crash mid-write leaves the previous one in place.
255
+ */
256
+ export async function writeDayIndex(index: DayIndex): Promise<void> {
257
+ await mkdir(TELEMETRY_EVENT_DIR, { recursive: true });
258
+ const target = join(TELEMETRY_EVENT_DIR, dayIndexFileName(index.day));
259
+ const temp = `${target}.${process.pid}.tmp`;
260
+ await writeFile(temp, `${JSON.stringify(index)}\n`, "utf8");
261
+ await rename(temp, target);
262
+ }
263
+
264
+ /** Byte size of a day's event file; 0 when it does not exist. */
265
+ export async function dayEventFileBytes(day: string): Promise<number> {
266
+ const size = await stat(join(TELEMETRY_EVENT_DIR, dayEventFileName(day)))
267
+ .then((value) => value.size)
268
+ .catch(() => undefined);
269
+ return size ?? 0;
270
+ }
271
+
272
+ /**
273
+ * Reads the uncounted tail of a day's event file.
274
+ *
275
+ * Only bytes past `fromBytes` are read, and a partial line at the very end is
276
+ * dropped rather than counted: the writer may still be appending to it, and a
277
+ * line that is not yet terminated is not yet an event.
278
+ */
279
+ async function* readTail(
280
+ day: string,
281
+ fromBytes: number,
282
+ ): AsyncGenerator<{ readonly event: FoldedEvent; readonly endBytes: number }> {
283
+ const handle = await open(join(TELEMETRY_EVENT_DIR, dayEventFileName(day)), "r").catch(
284
+ () => undefined,
285
+ );
286
+ if (handle === undefined) return;
287
+ try {
288
+ const size = (await handle.stat().catch(() => undefined))?.size ?? 0;
289
+ if (size <= fromBytes) return;
290
+ const length = size - fromBytes;
291
+ const buffer = Buffer.alloc(length);
292
+ await handle.read(buffer, 0, length, fromBytes);
293
+ const text = buffer.toString("utf8");
294
+ const lines = text.split("\n");
295
+ // The final element is either empty or a line the writer has not finished;
296
+ // a unterminated line is not an event yet, so it is left for the next fold.
297
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
298
+ else lines.pop();
299
+ let consumed = 0;
300
+ for (const line of lines) {
301
+ consumed += Buffer.byteLength(line, "utf8") + 1;
302
+ if (line.length === 0) continue;
303
+ const event = parseEventLine(line);
304
+ if (event !== undefined) yield { event, endBytes: fromBytes + consumed };
305
+ }
306
+ } finally {
307
+ await handle.close().catch(() => undefined);
308
+ }
309
+ }
310
+
311
+ /** Parses one event line, or `undefined` when it is not a usable event. */
312
+ function parseEventLine(line: string): FoldedEvent | undefined {
313
+ try {
314
+ const parsed: unknown = JSON.parse(line);
315
+ if (typeof parsed !== "object" || parsed === null) return undefined;
316
+ const candidate = parsed as Record<string, unknown>;
317
+ if (typeof candidate["tenantId"] !== "string") return undefined;
318
+ if (typeof candidate["requestId"] !== "string") return undefined;
319
+ if (typeof candidate["createdAt"] !== "string") return undefined;
320
+ // `tenantId` is checked above but declared optional on the record, so the
321
+ // narrow shape is asserted here rather than at every use site.
322
+ return candidate as unknown as FoldedEvent;
323
+ } catch {
324
+ return undefined;
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Folds every event written since the watermark into the day index.
330
+ *
331
+ * Safe to call as often as you like: the watermark advances only after the
332
+ * index is on disk, so a crash means some events are folded twice at worst —
333
+ * and folding is a sum, so that is corrected by running again with the tail,
334
+ * not by any compensating logic.
335
+ */
336
+ export async function foldDayIndex(day: string): Promise<{ readonly folded: number }> {
337
+ const index = await readDayIndex(day);
338
+ const byKey = new Map<string, IndexedBucket>();
339
+ for (const bucket of index.buckets) {
340
+ byKey.set(bucketKey(bucket.tenantId, bucket.apiKeyId), bucket);
341
+ }
342
+
343
+ let watermark = index.watermarkBytes;
344
+ let folded = 0;
345
+ for await (const { event, endBytes } of readTail(day, index.watermarkBytes)) {
346
+ const key = bucketKey(event.tenantId, event.apiKeyId ?? null);
347
+ const prior = byKey.get(key);
348
+ const base: IndexedBucket =
349
+ prior ?? {
350
+ ...emptyBucket(),
351
+ tenantId: event.tenantId,
352
+ apiKeyId: event.apiKeyId ?? null,
353
+ hours: emptyHours(),
354
+ dimModel: {},
355
+ dimProvider: {},
356
+ dimClient: {},
357
+ dimClientIp: {},
358
+ };
359
+ byKey.set(key, {
360
+ ...accumulate(base, event),
361
+ tenantId: base.tenantId,
362
+ apiKeyId: base.apiKeyId,
363
+ hours: withHour(base.hours, event),
364
+ dimModel: foldDimension(base.dimModel, event.requestedModel ?? event.reportedModel, event),
365
+ dimProvider: foldDimension(base.dimProvider, event.providerId, event),
366
+ dimClient: foldDimension(base.dimClient, event.sourceSurface, event),
367
+ dimClientIp: foldDimension(base.dimClientIp, event.clientIp, event),
368
+ });
369
+ watermark = Math.max(watermark, endBytes);
370
+ folded += 1;
371
+ }
372
+
373
+ if (folded === 0) return { folded: 0 };
374
+ await writeDayIndex({
375
+ kind: INDEX_KIND,
376
+ version: INDEX_VERSION,
377
+ day,
378
+ watermarkBytes: watermark,
379
+ buckets: [...byKey.values()],
380
+ updatedAt: new Date().toISOString(),
381
+ });
382
+ return { folded };
383
+ }
384
+
385
+ /** The days whose index should be folded, newest last. */
386
+ export async function listEventDays(): Promise<readonly string[]> {
387
+ const { readdir } = await import("node:fs/promises");
388
+ const files = await readdir(TELEMETRY_EVENT_DIR).catch(() => [] as string[]);
389
+ const days = new Set<string>();
390
+ for (const file of files) {
391
+ if (file.endsWith(".events.jsonl")) days.add(file.slice(0, 10));
392
+ }
393
+ return [...days].sort();
394
+ }
395
+
396
+ /**
397
+ * Every bucket for one tenant, across the days a period covers.
398
+ *
399
+ * Reads only the index files for those days — a few small JSON reads — rather
400
+ * than parsing the event files, which is what made a usage query cost grow with
401
+ * traffic.
402
+ */
403
+ export async function queryIndexedBuckets(options: {
404
+ /** Omit to read every tenant — a caller that filters by key, not by tenant. */
405
+ readonly tenantId?: string | undefined;
406
+ readonly since?: Date | undefined;
407
+ /** Fold each day's tail first, so a read never reports a stale total. */
408
+ readonly foldFirst?: boolean;
409
+ }): Promise<readonly IndexedBucket[]> {
410
+ const days = await listEventDays();
411
+ const sinceDay = options.since === undefined ? undefined : utcDay(options.since);
412
+ const out: IndexedBucket[] = [];
413
+ for (const day of days) {
414
+ if (sinceDay !== undefined && day < sinceDay) continue;
415
+ if (options.foldFirst === true) await foldDayIndex(day).catch(() => ({ folded: 0 }));
416
+ const index = await readDayIndex(day);
417
+ for (const bucket of index.buckets) {
418
+ if (options.tenantId !== undefined && bucket.tenantId !== options.tenantId) continue;
419
+ out.push(bucket);
420
+ }
421
+ }
422
+ return out;
423
+ }
424
+
425
+ /** The days a period spans, oldest first. */
426
+ export function daysSince(since: Date | undefined, now = new Date()): readonly string[] {
427
+ const start = since === undefined ? now : since;
428
+ const out: string[] = [];
429
+ const cursor = new Date(Date.UTC(
430
+ start.getUTCFullYear(),
431
+ start.getUTCMonth(),
432
+ start.getUTCDate(),
433
+ ));
434
+ const end = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
435
+ while (cursor.getTime() <= end && out.length < 400) {
436
+ out.push(utcDay(cursor));
437
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
438
+ }
439
+ return out;
440
+ }
441
+
442
+ export { mergeBucketsInternal as mergeBuckets };
@@ -1,5 +1,7 @@
1
1
  import { open, readdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { resolveTelemetryRetentionDays } from "../config/telemetry";
4
+ import { resolveDataDir } from "../persistence/db-mode";
3
5
 
4
6
  /**
5
7
  * Per-request telemetry lives in hourly JSONL files, not in Postgres. The
@@ -7,7 +9,36 @@ import { join } from "node:path";
7
9
  * `telemetry_events` reads through here, so the file is the single source for
8
10
  * recent requests and the table is no longer written.
9
11
  */
10
- export const TELEMETRY_EVENT_DIR = "./data/telemetry-events";
12
+ /**
13
+ * Where hourly event files and their compacted summaries live.
14
+ *
15
+ * Resolved from the configured data directory, never relative to the working
16
+ * directory: the gateway can be started from anywhere, and a relative path
17
+ * makes the written history depend on where the operator happened to be —
18
+ * start it from another folder and every past request reads as zero, because
19
+ * the files were written somewhere else entirely.
20
+ */
21
+ export const TELEMETRY_EVENT_DIR = join(resolveDataDir(), "telemetry-events");
22
+
23
+ /**
24
+ * The source file an event lands in: one per closed `windowMinutes` bucket.
25
+ *
26
+ * The name is the bucket's own start (`YYYY-MM-DDTHH-MM`), so both the writer
27
+ * and the compactor derive it from a timestamp and cannot disagree. It is also
28
+ * what makes "is this window closed?" a name comparison rather than a read.
29
+ */
30
+ export function windowFileName(at: Date, windowMinutes: number): string {
31
+ // `2026-10-22T05-15.jsonl`: ISO to the minute with the colon replaced, so the
32
+ // name sorts chronologically and needs no separate timestamp column.
33
+ return `${bucketStart(at, windowMinutes).toISOString().slice(0, 16).replace(":", "-")}.jsonl`;
34
+ }
35
+
36
+ /** The start of the window containing `at`, floored to `windowMinutes`. */
37
+ export function bucketStart(at: Date, windowMinutes: number): Date {
38
+ const ms = Math.max(1, Math.floor(windowMinutes)) * 60_000;
39
+ return new Date(Math.floor(at.getTime() / ms) * ms);
40
+ }
41
+
11
42
 
12
43
  export interface TelemetryEventRecord {
13
44
  readonly id: string;
@@ -100,8 +131,17 @@ function normalize(raw: Record<string, unknown>): TelemetryEventRecord | undefin
100
131
  };
101
132
  }
102
133
 
134
+ /**
135
+ * The comparable hour of an event file's name.
136
+ *
137
+ * A name is either `2026-10-22T05-15.jsonl` (an older window file) or
138
+ * `2026-10-22.events.jsonl` (a day file). Both sort against `hourStamp`: only
139
+ * the leading date and hour are taken, so the day file's `.events` suffix cannot
140
+ * make it compare as later or earlier than the window files beside it.
141
+ */
103
142
  function eventFileHour(file: string): string {
104
- return file.slice(0, 13);
143
+ const stamp = file.endsWith(".events.jsonl") ? `${file.slice(0, 10)}T00` : file.slice(0, 13);
144
+ return stamp;
105
145
  }
106
146
 
107
147
  function hourStamp(date: Date): string {
@@ -111,6 +151,71 @@ function hourStamp(date: Date): string {
111
151
  /** Chunk size for the streaming scan: one line can be long, a file cannot. */
112
152
  const READ_CHUNK_BYTES = 256 * 1024;
113
153
 
154
+ /**
155
+ * The newest matching events in one file, read backwards from the end.
156
+ *
157
+ * Reads fixed blocks from the tail until the page is full, so a day file of any
158
+ * size costs one block per page rather than a full parse. A partial line at the
159
+ * very end is dropped: the writer may still be appending to it.
160
+ */
161
+ async function* readEventFileTail(
162
+ file: string,
163
+ tenantId: string | undefined,
164
+ want: number,
165
+ since: number | undefined,
166
+ until: number | undefined,
167
+ ): AsyncGenerator<TelemetryEventRecord> {
168
+ if (want <= 0) return;
169
+ const handle = await open(join(TELEMETRY_EVENT_DIR, file), "r").catch(() => undefined);
170
+ if (handle === undefined) return;
171
+ try {
172
+ const size = (await handle.stat().catch(() => undefined))?.size ?? 0;
173
+ if (size === 0) return;
174
+ const BLOCK = 64 * 1024;
175
+ let end = size;
176
+ let carry = "";
177
+ const found: TelemetryEventRecord[] = [];
178
+ while (end > 0 && found.length < want) {
179
+ const start = Math.max(0, end - BLOCK);
180
+ const length = end - start;
181
+ const buffer = Buffer.alloc(length);
182
+ await handle.read(buffer, 0, length, start);
183
+ const text = buffer.toString("utf8") + carry;
184
+ const lines = text.split("\n");
185
+ carry = lines.shift() ?? "";
186
+ // Newest first: the block was read from the end, so its lines run
187
+ // backwards through the file.
188
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
189
+ const line = lines[index] ?? "";
190
+ if (line.length === 0) continue;
191
+ if (tenantId !== undefined && !line.includes(tenantId)) continue;
192
+ let parsed: TelemetryEventRecord | undefined;
193
+ try {
194
+ const value: unknown = JSON.parse(line);
195
+ if (typeof value === "object" && value !== null) {
196
+ parsed = value as TelemetryEventRecord;
197
+ }
198
+ } catch {
199
+ continue;
200
+ }
201
+ if (parsed === undefined) continue;
202
+ if (parsed.tenantId !== tenantId) continue;
203
+ const at = Date.parse(parsed.createdAt);
204
+ if (Number.isFinite(at)) {
205
+ if (since !== undefined && at < since) continue;
206
+ if (until !== undefined && at > until) continue;
207
+ }
208
+ found.push(parsed);
209
+ if (found.length >= want) break;
210
+ }
211
+ end = start;
212
+ }
213
+ for (const event of found) yield event;
214
+ } finally {
215
+ await handle.close().catch(() => undefined);
216
+ }
217
+ }
218
+
114
219
  async function* readEventFiles(
115
220
  files: readonly string[],
116
221
  allow?: (line: string) => boolean,
@@ -213,8 +318,29 @@ function matches(event: TelemetryEventRecord, query: EventQuery, since: number |
213
318
 
214
319
  /** The events matching a query, already newest first. */
215
320
  export async function queryTelemetryEvents(query: EventQuery): Promise<TelemetryEventRecord[]> {
321
+ // Hourly request files only. A `.compacted.jsonl` also ends in `.jsonl`, and
322
+ // letting one through meant every summary row was parsed as if it were a
323
+ // request — a row with no `tenantId`/`createdAt` that then failed `matches`,
324
+ // but only after paying for the parse.
325
+ //
326
+ // The lower bound is the retention window, not just `since`: a caller that
327
+ // asks for all time still must not scan files that have already been aged
328
+ // out, and one that passes no `since` at all used to read every file ever
329
+ // written. Files older than retention are gone or compacted; either way they
330
+ // are not here.
331
+ const retentionFloor = hourStamp(
332
+ new Date(Date.now() - resolveTelemetryRetentionDays() * 24 * 60 * 60_000),
333
+ );
334
+ const sinceFloor =
335
+ query.since === undefined ? retentionFloor : hourStamp(query.since);
336
+ const floor = sinceFloor > retentionFloor ? sinceFloor : retentionFloor;
216
337
  const files = (await readdir(TELEMETRY_EVENT_DIR).catch(() => [] as string[]))
217
- .filter((file) => file.endsWith(".jsonl") && (query.since === undefined || eventFileHour(file) >= hourStamp(query.since)))
338
+ .filter(
339
+ (file) =>
340
+ file.endsWith(".jsonl") &&
341
+ !file.endsWith(".compacted.jsonl") &&
342
+ eventFileHour(file) >= floor,
343
+ )
218
344
  .sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
219
345
  const since = query.since?.getTime();
220
346
  const until = query.until?.getTime();
@@ -235,6 +361,27 @@ export async function queryTelemetryEvents(query: EventQuery): Promise<Telemetry
235
361
  // A single-row lookup: stop at the first (newest) match instead of parsing
236
362
  // the whole retention window.
237
363
  const oneRow = query.requestId !== undefined || query.id !== undefined;
364
+ // With one file per day, a plain page of the newest rows would otherwise
365
+ // parse the whole day to return fifty lines. When nothing filters on
366
+ // content — no status, no key, no account — the newest rows are the last
367
+ // lines, so they are read backwards from the end and the scan stops once the
368
+ // page is full.
369
+ const tailOnly =
370
+ query.limit !== undefined &&
371
+ !oneRow &&
372
+ query.httpStatus === undefined &&
373
+ query.apiKeyIds === undefined &&
374
+ query.accountIds === undefined;
375
+ if (tailOnly) {
376
+ const newest: TelemetryEventRecord[] = [];
377
+ for (const file of files) {
378
+ for await (const event of readEventFileTail(file, query.tenantId, query.limit - newest.length, since, until)) {
379
+ newest.push(event);
380
+ }
381
+ if (newest.length >= query.limit) break;
382
+ }
383
+ return newest.slice(0, query.limit);
384
+ }
238
385
  for (const file of files) {
239
386
  const matched: TelemetryEventRecord[] = [];
240
387
  for await (const event of readEventFiles([file], allow)) {
@@ -270,6 +417,8 @@ export function emptyTotals(): EventTotals {
270
417
  return { requests: 0, succeeded: 0, failed: 0, cancelled: 0, truncated: 0, inputTokens: 0, cachedTokens: 0, outputTokens: 0, estimatedCost: 0, unpriced: 0, latencySum: 0, latencyCount: 0, tokensPerSecSum: 0, tokensPerSecCount: 0 };
271
418
  }
272
419
 
420
+ /**
421
+
273
422
  /** Folds one event into a totals bucket. `failed` counts any non-completed terminal status. */
274
423
  export function accumulate(totals: EventTotals, event: TelemetryEventRecord): void {
275
424
  totals.requests += 1;
@@ -140,7 +140,14 @@ export interface StoredPayload {
140
140
  signals: PayloadSignals;
141
141
  }
142
142
 
143
- function payloadRetentionMs(): number {
143
+ /**
144
+ * How long a captured payload frame lives.
145
+ *
146
+ * Exported so surfaces that report on disk usage quote the same window the
147
+ * capture path enforces, rather than a second copy of the default that would
148
+ * drift the first time this one changes.
149
+ */
150
+ export function payloadRetentionMs(): number {
144
151
  const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_RETENTION_MS ?? 15 * 60_000);
145
152
  if (!Number.isFinite(raw) || raw < 1_000 || raw > 7 * 24 * 60 * 60_000) return 15 * 60_000;
146
153
  return Math.floor(raw);