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
@@ -1,1018 +1,1115 @@
1
- import { loadavg, cpus, totalmem } from "node:os";
2
- import { and, desc, eq, inArray } from "drizzle-orm";
3
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
4
- import type { RedisClient } from "../../persistence/redis";
5
- import { apiKeys, networkPools, telemetryPayloads } from "../../persistence/schema";
6
- import {
7
- queryTelemetryEvents,
8
- type TelemetryEventRecord,
9
- } from "../../observability/event-log";
10
- import { isGatewayError } from "../../observability/telemetry-status";
11
- import { cachedReadWithEtag, type CachedResult } from "../shared/read-through-cache";
12
- import { decodeDatedCursor, encodeCursor } from "../shared/cursor";
13
- import { CARTETHYIA_VERSION } from "../../version";
14
- import { CachedPreferencesReader, DrizzlePreferencesReader, type PreferencesReader } from "../../persistence/tenant-preferences";
15
- import type {
16
- ObservabilityStore,
17
- UsageDimension,
18
- SystemHealthResponse,
19
- TelemetryEventView,
20
- TelemetryEventDetail,
21
- TelemetryEventListPage,
22
- UsageByResponse,
23
- UsageCacheResponse,
24
- UsageChartBucket,
25
- UsageChartResponse,
26
- UsageRequestDetail,
27
- UsageRequestItem,
28
- UsageRequestsResponse,
29
- UsageResponse,
30
- UsageSummaryResponse,
31
- } from "./contracts";
32
- import { maskClientIp } from "../../observability/redaction";
33
- import { splitEndpointConfig } from "../../network/pool/agent";
34
- import { resolveDbMode } from "../../persistence/db-mode";
35
- import { payloadReferenceFromRow, readPayloadFrame } from "../../observability/payload-store";
36
- import { shouldMaskClientIp } from "../shared/ip-privacy";
37
-
38
- function mapTelemetryEventRow(event: TelemetryEventRecord): TelemetryEventView {
39
- return {
40
- id: event.id,
41
- createdAt: event.createdAt,
42
- tenantId: event.tenantId ?? "",
43
- requestId: event.requestId,
44
- ...(event.sourceSurface ? { sourceSurface: event.sourceSurface } : {}),
45
- ...(event.requestedModel ? { requestedModel: event.requestedModel } : {}),
46
- ...(event.reportedModel ? { reportedModel: event.reportedModel } : {}),
47
- ...(event.providerId ? { providerId: event.providerId } : {}),
48
- ...(event.latencyMs !== null ? { latencyMs: event.latencyMs ?? undefined } : {}),
49
- ...(event.status ? { status: event.status } : {}),
50
- ...(event.inputTokens !== null ? { inputTokens: event.inputTokens ?? undefined } : {}),
51
- ...(event.outputTokens !== null ? { outputTokens: event.outputTokens ?? undefined } : {}),
52
- ...(event.estimatedCostUsd !== null
53
- ? { estimatedCost: Number(event.estimatedCostUsd ?? 0) }
54
- : {}),
55
- ...(event.tokensPerSec !== null ? { tokensPerSec: Number(event.tokensPerSec) } : {}),
56
- ...(event.firstContentDeltaAtMs !== null ? { firstContentDeltaAtMs: event.firstContentDeltaAtMs } : {}),
57
- ...(event.lastEventAtMs !== null ? { lastEventAtMs: event.lastEventAtMs } : {}),
58
- };
59
- }
60
- /** The HTTP status a row renders as when the wire status was never recorded. */
61
- function effectiveHttpStatus(event: TelemetryEventRecord): number {
62
- if (event.httpStatus !== null) return event.httpStatus;
63
- if (event.status === "completed") return 200;
64
- if (event.status === "cancelled") return 499;
65
- if (event.status === "truncated") return 502;
66
- return 500;
67
- }
68
-
69
- /**
70
- * Opaque, monotonic (createdAt, id) cursor. `createdAt` alone is not unique
71
- * — two telemetry events in the same millisecond would silently skip one
72
- * row across pages — so we tie-break on id and encode both, matching
73
- * `DrizzleAuditReadStore`'s cursor.
74
- */
75
-
76
- /** Resolves a `usage` period token ("24h", "7d", "30d", "all") to its inclusive start date. */
77
- function periodStartDate(period: string): Date | undefined {
78
- const match = /^(\d+)([hd])$/.exec(period);
79
- if (!match) return undefined;
80
- const amount = Number(match[1]);
81
- const unitMs = match[2] === "h" ? 3_600_000 : 86_400_000;
82
- return new Date(Date.now() - amount * unitMs);
83
- }
84
-
85
-
86
- /**
87
- * Client identity straight from the stored `User-Agent`: the first RFC 9110
88
- * product token carries both the client and its version (`bun/1.2.3`,
89
- * `codex-cli/0.1.0`), so no prefix catalog can drift from what the client
90
- * actually sent. Browser agents start with the historical `Mozilla/5.0`
91
- * token, which names no real client, so they report as `Browser`.
92
- */
93
- export function clientNameFromUserAgent(userAgent: string | null | undefined): string {
94
- const raw = userAgent?.trim();
95
- if (!raw) return "unknown";
96
- if (/^mozilla\//i.test(raw)) return "Browser";
97
- const product = raw.split(/\s+/, 1)[0] ?? "";
98
- const slash = product.indexOf("/");
99
- const token = slash <= 0 ? product : product.slice(0, slash + 1).concat(product.slice(slash + 1).split(/[;,)]/, 1)[0] ?? "");
100
- return token || "unknown";
101
- }
102
- /** Time-series bucket width for the usage chart, chosen from the window length. */
103
- function chartBucketSeconds(period: string): number {
104
- const since = periodStartDate(period);
105
- if (!since) return 86_400;
106
- const windowMs = Date.now() - since.getTime();
107
- if (windowMs <= 2 * 3_600_000) return 300;
108
- if (windowMs <= 2 * 86_400_000) return 3_600;
109
- if (windowMs <= 10 * 86_400_000) return 21_600;
110
- return 86_400;
111
- }
112
-
113
- /**
114
- * The model id with the client's requested reasoning effort as a display
115
- * suffix — only when the request actually carried one.
116
- */
117
- function modelWithEffort(event: TelemetryEventRecord): string | undefined {
118
- const model = event.requestedModel;
119
- if (!model) return undefined;
120
- return event.requestedEffort ? `${model} (${event.requestedEffort})` : model;
121
- }
122
-
123
- function mapUsageRequestItem(
124
- event: TelemetryEventRecord,
125
- hideClientIp: boolean,
126
- ): UsageRequestItem {
127
- const input = event.inputTokens ?? 0;
128
- const output = event.outputTokens ?? 0;
129
- return {
130
- requestId: event.requestId,
131
- ...(event.sourceSurface ? { surface: event.sourceSurface } : {}),
132
- ...(event.providerId ? { providerId: event.providerId } : {}),
133
- ...(event.accountId ? { accountId: event.accountId } : {}),
134
- ...(modelWithEffort(event) === undefined ? {} : { model: modelWithEffort(event) as string }),
135
- ...(event.reportedModel ? { reportedModel: event.reportedModel } : {}),
136
- status: event.status ?? "unknown",
137
- ...(event.errorCategory ? { errorKind: event.errorCategory } : {}),
138
- ...(event.errorOrigin ? { errorOrigin: event.errorOrigin } : {}),
139
- httpStatus: effectiveHttpStatus(event),
140
- mode: event.stream ? "stream" : "non_stream",
141
- startedAt: event.createdAt,
142
- ...(event.latencyMs !== null ? { durationMs: event.latencyMs ?? undefined } : {}),
143
- ...(event.inputTokens !== null ? { inputTokens: event.inputTokens ?? undefined } : {}),
144
- ...(event.outputTokens !== null ? { outputTokens: event.outputTokens ?? undefined } : {}),
145
- ...(event.cachedInputTokens !== null
146
- ? { cachedTokens: event.cachedInputTokens ?? undefined }
147
- : {}),
148
- ...(event.reasoningTokens !== null
149
- ? { reasoningTokens: event.reasoningTokens ?? undefined }
150
- : {}),
151
- ...(event.endpoint ? { endpoint: event.endpoint } : {}),
152
- ...(event.apiKeyId ? { apiKeyId: event.apiKeyId } : {}),
153
- ...(event.userAgent ? { userAgent: event.userAgent } : {}),
154
- ...(event.userAgent ? { clientName: clientNameFromUserAgent(event.userAgent) } : {}),
155
- ...(event.clientIp
156
- ? { clientIp: hideClientIp ? maskClientIp(event.clientIp) : event.clientIp }
157
- : {}),
158
- ...(input + output > 0 ? { totalTokens: input + output } : {}),
159
- ...(event.ttfbMs !== null ? { ttfbMs: event.ttfbMs ?? undefined } : {}),
160
- ...(event.tokensPerSec !== null ? { tokensPerSec: Number(event.tokensPerSec) } : {}),
161
- ...(event.firstContentDeltaAtMs !== null ? { firstContentDeltaAtMs: event.firstContentDeltaAtMs } : {}),
162
- ...(event.lastEventAtMs !== null ? { lastEventAtMs: event.lastEventAtMs } : {}),
163
- ...(event.estimatedCostUsd !== null && event.estimatedCostUsd !== undefined
164
- ? { estimatedCost: Number(event.estimatedCostUsd) }
165
- : {}),
166
- ...(event.creditUsed !== null && event.creditUsed !== undefined
167
- ? { creditUsed: Number(event.creditUsed) }
168
- : {}),
169
- ...(event.resolveMs !== null && event.resolveMs !== undefined
170
- ? { resolveMs: event.resolveMs }
171
- : {}),
172
- // Why the drawer is empty for this request — see the contract field. This is
173
- // what makes the blank panels diagnosable without server logs.
174
- ...(event.payloadCaptureState ? { payloadCaptureState: event.payloadCaptureState } : {}),
175
- };
176
- }
177
- async function resolveProxyLabel(
178
- db: CartethyiaDatabase,
179
- tenantId: string,
180
- poolId: string,
181
- ): Promise<string> {
182
- const rows = await db
183
- .select({ id: networkPools.id, endpointConfig: networkPools.endpointConfig })
184
- .from(networkPools)
185
- .where(and(eq(networkPools.id, poolId), eq(networkPools.tenantId, tenantId)))
186
- .limit(1);
187
- const row = rows[0];
188
- if (!row) return `${poolId.slice(0, 8)}…`;
189
- const { endpoint, label } = splitEndpointConfig((row.endpointConfig ?? {}) as Record<string, unknown>);
190
- return label ?? endpoint ?? `${poolId.slice(0, 8)}…`;
191
- }
192
- const HEALTH_WINDOW_MS = 24 * 3_600_000;
193
- const TOP_N = 50;
194
- /**
195
- * How long a parsed window is shared. A dashboard poll fires ~8 usage
196
- * endpoints at once, and a slow parse used to outlive a 1.5 s window — so the
197
- * endpoints that missed it each re-read and re-parsed the same files, which is
198
- * how one page load became several full parses of the retention window.
199
- */
200
- const PERIOD_MEMO_TTL_MS = 5_000;
201
-
202
- /** Nearest-rank percentile over a sorted array, matching `percentile_cont` closely enough for a health gauge. */
203
- function percentile(sorted: readonly number[], fraction: number): number {
204
- if (sorted.length === 0) return 0;
205
- const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));
206
- return Math.round(sorted[index] ?? 0);
207
- }
208
-
209
- export interface UsagePerformanceSummary {
210
- avgResolveMs: number;
211
- avgDurationMs: number;
212
- latencyP50Ms: number;
213
- latencyP95Ms: number;
214
- latencyP99Ms: number;
215
- /** Events each series was computed from; see `UsageSummaryTotals`. */
216
- latencySampleCount: number;
217
- resolveSampleCount: number;
218
- }
219
-
220
- /** Summarizes routing delay and request latency for an already-filtered period. */
221
- export function summarizeUsagePerformance(
222
- events: readonly Pick<TelemetryEventRecord, "latencyMs" | "resolveMs">[],
223
- ): UsagePerformanceSummary {
224
- const latencies: number[] = [];
225
- let latencySum = 0;
226
- let resolveSum = 0;
227
- let resolveCount = 0;
228
- for (const event of events) {
229
- if (event.latencyMs !== null) {
230
- latencies.push(event.latencyMs);
231
- latencySum += event.latencyMs;
232
- }
233
- if (event.resolveMs !== null) {
234
- resolveSum += event.resolveMs;
235
- resolveCount += 1;
236
- }
237
- }
238
- latencies.sort((a, b) => a - b);
239
- return {
240
- avgResolveMs: resolveCount > 0 ? resolveSum / resolveCount : 0,
241
- avgDurationMs: latencies.length > 0 ? latencySum / latencies.length : 0,
242
- latencyP50Ms: percentile(latencies, 0.5),
243
- latencyP95Ms: percentile(latencies, 0.95),
244
- latencyP99Ms: percentile(latencies, 0.99),
245
- latencySampleCount: latencies.length,
246
- resolveSampleCount: resolveCount,
247
- };
248
- }
249
-
250
- /** The largest entries of a count map, capped. */
251
- function topCounts(counts: ReadonlyMap<string, number>, limit: number): Array<[string, number]> {
252
- return [...counts].sort((a, b) => b[1] - a[1]).slice(0, limit);
253
- }
254
-
255
- /** The grouping value for one usage breakdown dimension. */
256
- function dimensionName(dimension: UsageDimension, event: TelemetryEventRecord): string | null {
257
- if (dimension === "model") return event.requestedModel;
258
- if (dimension === "key") return event.apiKeyId;
259
- if (dimension === "client") return event.userAgent;
260
- if (dimension === "client_ip") return event.clientIp;
261
- return event.providerId;
262
- }
263
-
264
- interface BreakdownBucket {
265
- name: string;
266
- requests: number;
267
- input: number;
268
- output: number;
269
- cached: number;
270
- errors: number;
271
- cost: number | null;
272
- rateSum: number;
273
- rateCount: number;
274
- }
275
-
276
- function emptyBreakdown(name: string): BreakdownBucket {
277
- return { name, requests: 0, input: 0, output: 0, cached: 0, errors: 0, cost: null, rateSum: 0, rateCount: 0 };
278
- }
279
-
280
- /** Real Drizzle-backed observability repository used by the production console. */
281
- export class DrizzleObservabilityStore implements ObservabilityStore {
282
- /**
283
- * Per-(tenant, period) parsed event set, shared by the ~8 usage endpoints
284
- * one dashboard poll fires in parallel. 1.5 s window: a poll burst shares
285
- * one file parse; the next poll re-reads. In-flight shares one promise.
286
- */
287
- private readonly periodMemo = new Map<string, { at: number; promise: Promise<readonly TelemetryEventRecord[]> }>();
288
- constructor(
289
- private readonly db: CartethyiaDatabase,
290
- private readonly redis: RedisClient | undefined,
291
- /**
292
- * Tenant preferences, revision-cached. The Usage page reads the privacy
293
- * gate on the list, detail and breakdown paths, so a direct SELECT here
294
- * re-read the same row per request; the cache collapses that.
295
- */
296
- private readonly preferences: PreferencesReader = new CachedPreferencesReader(
297
- new DrizzlePreferencesReader(db),
298
- ),
299
- ) {}
300
-
301
- async health(tenantId: string): Promise<CachedResult<SystemHealthResponse>> {
302
- // The only usage-family read that used to bypass both the 5 s in-process
303
- // memo and the Redis read-through, so it re-parsed 24 hourly files every
304
- // page load. Same 10 s TTL as `/system/usage`.
305
- return this.readThroughWithEtag(
306
- this.usageCacheKey(tenantId, "24h", "health"),
307
- () => this.computeHealth(tenantId),
308
- );
309
- }
310
-
311
- /**
312
- * Same two numbers `computeHealth` reports, read fresh.
313
- *
314
- * Deliberately not `computeHealth`: that also scans 24 h of telemetry files,
315
- * which is exactly the cost the cache exists to avoid, and a memory watcher
316
- * polling every 10 s must not pay it. Kept in the store so the percentage
317
- * has one definition rather than two.
318
- */
319
- async liveMemory(): Promise<{ memory_bytes: number; memory_percent: number }> {
320
- const memory = process.memoryUsage();
321
- return {
322
- memory_bytes: memory.rss,
323
- memory_percent: Math.round((memory.rss / Math.max(totalmem(), 1)) * 10000) / 100,
324
- };
325
- }
326
-
327
- private async computeHealth(tenantId: string): Promise<SystemHealthResponse> {
328
- const memory = process.memoryUsage();
329
- // `loadavg()` is [0, 0, 0] on every Windows build — there is no such
330
- // syscall and Node reports a zeroed tuple rather than failing. Divided by
331
- // cores that is a confident-looking 0.0% on a busy gateway, which is worse
332
- // than saying nothing: an operator reads it as "idle" and stops looking.
333
- // Reported as null instead of as a measurement.
334
- //
335
- // A real zero load is *not* the same thing and is reported as zero: an
336
- // idle machine genuinely has nothing queued, and collapsing that into
337
- // "unavailable" would hide a number the platform did supply.
338
- const cpuPercent: number | null =
339
- process.platform === "win32"
340
- ? null
341
- : Math.round(((loadavg()[0] ?? 0) / Math.max(cpus().length, 1)) * 10000) / 100;
342
-
343
- let databaseHealthy = true;
344
- let total = 0;
345
- let errors = 0;
346
- let avg = 0;
347
- let p95 = 0;
348
- let p99 = 0;
349
- let cacheHitRate = 0;
350
- let avgTokensPerSec = 0;
351
- try {
352
- // Bounded 24h rolling window (matches the Overview copy). The file is the
353
- // source, so the percentiles are computed over the retained events.
354
- const since = new Date(Date.now() - HEALTH_WINDOW_MS);
355
- const events = await queryTelemetryEvents({ tenantId, since });
356
- const latencies: number[] = [];
357
- let latencySum = 0;
358
- let inputTokens = 0;
359
- let cachedTokens = 0;
360
- let outputTokens = 0;
361
- let tokensPerSecSum = 0;
362
- let tokensPerSecCount = 0;
363
- for (const event of events) {
364
- total += 1;
365
- if (isGatewayError(event.status, event.httpStatus)) errors += 1;
366
- if (event.latencyMs !== null) {
367
- latencies.push(event.latencyMs);
368
- latencySum += event.latencyMs;
369
- }
370
- if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
371
- inputTokens += event.inputTokens ?? 0;
372
- cachedTokens += event.inputTokens ?? 0;
373
- outputTokens += event.outputTokens ?? 0;
374
- }
375
- if (event.tokensPerSec !== null) {
376
- tokensPerSecSum += event.tokensPerSec;
377
- tokensPerSecCount += 1;
378
- }
379
- }
380
- latencies.sort((a, b) => a - b);
381
- avg = latencies.length > 0 ? Math.round(latencySum / latencies.length) : 0;
382
- p95 = percentile(latencies, 0.95);
383
- p99 = percentile(latencies, 0.99);
384
- // Cached share of the prompt: cached/input, the same honest definition
385
- // usageSummary uses, so the health card and the Usage page cannot
386
- // disagree. (This used to be prompt-share input/(input+output), which
387
- // read ~99% and was mislabeled a cache hit rate.)
388
- cacheHitRate = inputTokens > 0 ? Math.round((cachedTokens / inputTokens) * 10000) / 100 : 0;
389
- avgTokensPerSec = tokensPerSecCount > 0 ? tokensPerSecSum / tokensPerSecCount : 0;
390
- } catch {
391
- databaseHealthy = false;
392
- }
393
-
394
- // No client means the memory backend, which is healthy by construction.
395
- let redisHealthy = this.redis === undefined;
396
- if (this.redis !== undefined) {
397
- try {
398
- redisHealthy = (await this.redis.ping()) === "PONG";
399
- } catch {
400
- redisHealthy = false;
401
- }
402
- }
403
-
404
- const status: SystemHealthResponse["status"] = !databaseHealthy
405
- ? "unhealthy"
406
- : !redisHealthy
407
- ? "degraded"
408
- : "healthy";
409
-
410
- return {
411
- version: CARTETHYIA_VERSION,
412
- status,
413
- uptime_seconds: Math.floor(process.uptime()),
414
- database_healthy: databaseHealthy,
415
- redis_healthy: redisHealthy,
416
- redis_configured: this.redis !== undefined,
417
- memory_heap_bytes: memory.heapUsed,
418
- memory_external_bytes: memory.external,
419
- memory_bytes: memory.rss,
420
- memory_percent: Math.round((memory.rss / Math.max(totalmem(), 1)) * 10000) / 100,
421
- // Reported so the console can explain a large RSS instead of leaving the
422
- // operator to read it as the gateway alone. In lite mode this process
423
- // hosts the database too, so the resident set covers both.
424
- database_in_process: resolveDbMode() === "lite",
425
- cpu_percent: cpuPercent,
426
- cpu_cores: cpus().length,
427
- pid: process.pid,
428
- platform: `Bun ${Bun.version} · ${process.platform}`,
429
- request_count: total,
430
- error_count: errors,
431
- latency_avg_ms: avg,
432
- latency_p95_ms: p95,
433
- latency_p99_ms: p99,
434
- cache_hit_rate_percent: cacheHitRate,
435
- avg_tokens_per_sec: avgTokensPerSec,
436
- };
437
- }
438
- async usage(tenantId: string, period: string): Promise<CachedResult<UsageResponse>> {
439
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "usage"), () =>
440
- this.computeUsage(tenantId, period),
441
- );
442
- }
443
-
444
- /**
445
- * Cache key for one usage response. Versioned so a shape change can never be
446
- * read back from a deployment that still holds the old one.
447
- */
448
- private usageCacheKey(tenantId: string, period: string, shape: string): string {
449
- return `cartethyia:usage:v1:${shape}:${tenantId}:${period}`;
450
- }
451
-
452
- /**
453
- * Reads a usage response through Redis — computing when no cache is
454
- * configured — and returns the strong validator alongside it, so a repeated
455
- * poll can be answered with a `304` instead of the whole payload.
456
- */
457
- private readThroughWithEtag<T>(key: string, compute: () => Promise<T>): Promise<CachedResult<T>> {
458
- return cachedReadWithEtag(this.redis, key, compute);
459
- }
460
-
461
- private async computeUsage(tenantId: string, period: string): Promise<UsageResponse> {
462
- const events = await this.periodEvents(tenantId, period);
463
- let requestsTotal = 0;
464
- let requestsSucceeded = 0;
465
- let requestsFailed = 0;
466
- let tokensUsed = 0;
467
- let estimatedCost = 0;
468
- const models = new Map<string, number>();
469
- const providers = new Map<string, number>();
470
- for (const event of events) {
471
- requestsTotal += 1;
472
- if (event.status === "completed") requestsSucceeded += 1;
473
- if (isGatewayError(event.status, event.httpStatus)) requestsFailed += 1;
474
- tokensUsed += (event.inputTokens ?? 0) + (event.outputTokens ?? 0);
475
- estimatedCost += event.estimatedCostUsd ?? 0;
476
- if (event.requestedModel) models.set(event.requestedModel, (models.get(event.requestedModel) ?? 0) + 1);
477
- if (event.providerId) providers.set(event.providerId, (providers.get(event.providerId) ?? 0) + 1);
478
- }
479
- return {
480
- tenantId,
481
- period,
482
- requestsTotal,
483
- requestsSucceeded,
484
- requestsFailed,
485
- tokensUsed,
486
- estimatedCost,
487
- topModels: topCounts(models, TOP_N).map(([modelId, count]) => ({ modelId, count })),
488
- topProviders: topCounts(providers, TOP_N).map(([providerId, count]) => ({ providerId, count })),
489
- };
490
- }
491
-
492
- /** The tenant's retained events inside a usage period. `all` keeps everything. */
493
- private periodEvents(tenantId: string, period: string): Promise<readonly TelemetryEventRecord[]> {
494
- const key = `${tenantId}:${period}`;
495
- const now = Date.now();
496
- const hit = this.periodMemo.get(key);
497
- if (hit && now - hit.at < PERIOD_MEMO_TTL_MS) return hit.promise;
498
- const since = periodStartDate(period);
499
- const promise = queryTelemetryEvents(since === undefined ? { tenantId } : { tenantId, since });
500
- this.periodMemo.set(key, { at: now, promise });
501
- // A rejected parse must not poison the memo for the next poll.
502
- void promise.catch(() => {
503
- if (this.periodMemo.get(key)?.promise === promise) this.periodMemo.delete(key);
504
- });
505
- return promise;
506
- }
507
-
508
- async usageSummary(tenantId: string, period: string): Promise<CachedResult<UsageSummaryResponse>> {
509
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "summary"), () =>
510
- this.computeSummary(tenantId, period),
511
- );
512
- }
513
-
514
- private async computeSummary(tenantId: string, period: string): Promise<UsageSummaryResponse> {
515
- const events = await this.periodEvents(tenantId, period);
516
- const performance = summarizeUsagePerformance(events);
517
- let requests = 0;
518
- let inputTokens = 0;
519
- let cachedTokens = 0;
520
- let outputTokens = 0;
521
- let errors = 0;
522
- let cancelled = 0;
523
- let truncated = 0;
524
- let estimatedCostUsd = 0;
525
- let unpriced = 0;
526
- let tokensPerSecSum = 0;
527
- let tokensPerSecCount = 0;
528
- const statuses = new Map<number, number>();
529
- for (const event of events) {
530
- requests += 1;
531
- inputTokens += event.inputTokens ?? 0;
532
- outputTokens += event.outputTokens ?? 0;
533
- if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
534
- cachedTokens += event.inputTokens ?? 0;
535
- }
536
- if (isGatewayError(event.status, event.httpStatus)) errors += 1;
537
- if (event.status === "cancelled") cancelled += 1;
538
- if (event.status === "truncated") truncated += 1;
539
- if (event.estimatedCostUsd !== null) estimatedCostUsd += event.estimatedCostUsd;
540
- else if (event.status === "completed") unpriced += 1;
541
- if (event.tokensPerSec !== null) {
542
- tokensPerSecSum += event.tokensPerSec;
543
- tokensPerSecCount += 1;
544
- }
545
- const status = effectiveHttpStatus(event);
546
- statuses.set(status, (statuses.get(status) ?? 0) + 1);
547
- }
548
- return {
549
- period,
550
- totals: {
551
- statusCounts: [...statuses].sort(([a], [b]) => a - b).map(([status, count]) => ({ status, count })),
552
- requests,
553
- inputTokens,
554
- cachedTokens,
555
- outputTokens,
556
- errors,
557
- cancelled,
558
- truncated,
559
- ...performance,
560
- estimatedCostUsd,
561
- partial: unpriced > 0,
562
- cacheHitRate: inputTokens > 0 ? (cachedTokens / inputTokens) * 100 : 0,
563
- avgTokensPerSec: tokensPerSecCount > 0 ? tokensPerSecSum / tokensPerSecCount : 0,
564
- },
565
- };
566
- }
567
-
568
- async usageChart(tenantId: string, period: string): Promise<CachedResult<UsageChartResponse>> {
569
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "chart"), () =>
570
- this.computeChart(tenantId, period),
571
- );
572
- }
573
-
574
- private async computeChart(tenantId: string, period: string): Promise<UsageChartResponse> {
575
- const since = periodStartDate(period);
576
- const bucketSeconds = chartBucketSeconds(period);
577
- const stepMs = bucketSeconds * 1000;
578
- const events = await this.periodEvents(tenantId, period);
579
- const byTime = new Map<number, UsageChartBucket>();
580
- for (const event of events) {
581
- const at = new Date(event.createdAt).getTime();
582
- if (!Number.isFinite(at)) continue;
583
- const start = Math.floor(at / stepMs) * stepMs;
584
- const bucket = byTime.get(start) ?? { t: new Date(start).toISOString(), requests: 0, input: 0, cached: 0, output: 0 };
585
- bucket.requests += 1;
586
- bucket.input += event.inputTokens ?? 0;
587
- bucket.cached += event.cachedInputTokens !== null && event.cachedInputTokens > 0 ? (event.inputTokens ?? 0) : 0;
588
- bucket.output += event.outputTokens ?? 0;
589
- byTime.set(start, bucket);
590
- }
591
- const rows = [...byTime.keys()];
592
- const first = rows.length > 0 ? Math.min(...byTime.keys()) : undefined;
593
- const startMs = since ? since.getTime() : first;
594
- if (startMs === undefined) return { buckets: [] };
595
- const aligned = Math.floor(startMs / stepMs) * stepMs;
596
- const buckets: UsageChartBucket[] = [];
597
- for (let at = aligned; at <= Date.now(); at += stepMs) {
598
- buckets.push(
599
- byTime.get(at) ?? { t: new Date(at).toISOString(), requests: 0, input: 0, cached: 0, output: 0 },
600
- );
601
- }
602
- return { buckets };
603
- }
604
-
605
- async usageBy(
606
- tenantId: string,
607
- dimension: UsageDimension,
608
- period: string,
609
- ): Promise<CachedResult<UsageByResponse>> {
610
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, `${dimension}:${period}`, "by"), () =>
611
- this.computeBy(tenantId, dimension, period),
612
- );
613
- }
614
-
615
- private async computeBy(
616
- tenantId: string,
617
- dimension: UsageDimension,
618
- period: string,
619
- ): Promise<UsageByResponse> {
620
- const events = await this.periodEvents(tenantId, period);
621
- const groups = new Map<string, BreakdownBucket>();
622
- for (const event of events) {
623
- const raw = dimensionName(dimension, event);
624
- if (raw === null || raw.length === 0) continue;
625
- const name = dimension === "client" ? clientNameFromUserAgent(raw) : raw;
626
- const bucket = groups.get(name) ?? emptyBreakdown(name);
627
- bucket.requests += 1;
628
- bucket.input += event.inputTokens ?? 0;
629
- bucket.output += event.outputTokens ?? 0;
630
- if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
631
- bucket.cached += event.inputTokens ?? 0;
632
- }
633
- if (isGatewayError(event.status, event.httpStatus)) bucket.errors += 1;
634
- if (event.estimatedCostUsd !== null) bucket.cost = (bucket.cost ?? 0) + event.estimatedCostUsd;
635
- if (event.tokensPerSec !== null) {
636
- bucket.rateSum += event.tokensPerSec;
637
- bucket.rateCount += 1;
638
- }
639
- groups.set(name, bucket);
640
- }
641
- const mapped = [...groups.values()]
642
- .map((bucket) => ({
643
- name: bucket.name,
644
- requests: bucket.requests,
645
- input: bucket.input,
646
- output: bucket.output,
647
- cached: bucket.cached,
648
- total: bucket.input + bucket.output,
649
- errors: bucket.errors,
650
- costUsd: bucket.cost,
651
- cacheHitRate: bucket.input > 0 ? (bucket.cached / bucket.input) * 100 : 0,
652
- avgTokensPerSec: bucket.rateCount > 0 ? bucket.rateSum / bucket.rateCount : 0,
653
- }))
654
- .sort((a, b) => b.requests - a.requests)
655
- .slice(0, 100);
656
- if (dimension === "client_ip") {
657
- // Same fail-closed gate as the request list: storage keeps the raw
658
- // address, only presentation masks it.
659
- const hideClientIp = await shouldMaskClientIp(this.preferences, tenantId);
660
- if (!hideClientIp) return { dimension, rows: mapped };
661
- // Grouping happened on the raw address, so masking can collapse distinct
662
- // hosts into one display name (`203.0.113.7` and `203.0.113.9` both become
663
- // `203.0.113.xxx`). Re-aggregate by the masked name: two rows the operator
664
- // cannot tell apart are one row, and the totals must not be understated.
665
- const merged = new Map<string, (typeof mapped)[number]>();
666
- for (const row of mapped) {
667
- const masked = maskClientIp(row.name);
668
- const existing = merged.get(masked);
669
- if (existing === undefined) {
670
- merged.set(masked, { ...row, name: masked });
671
- continue;
672
- }
673
- const input = existing.input + row.input;
674
- const output = existing.output + row.output;
675
- merged.set(masked, {
676
- ...existing,
677
- requests: existing.requests + row.requests,
678
- input,
679
- output,
680
- cached: existing.cached + row.cached,
681
- total: input + output,
682
- errors: existing.errors + row.errors,
683
- costUsd:
684
- existing.costUsd === null && row.costUsd === null
685
- ? null
686
- : (existing.costUsd ?? 0) + (row.costUsd ?? 0),
687
- cacheHitRate: input > 0 ? (existing.cached + row.cached) / input * 100 : 0,
688
- });
689
- }
690
- return {
691
- dimension,
692
- rows: [...merged.values()].sort((a, b) => b.requests - a.requests),
693
- };
694
- }
695
- if (dimension !== "key") return { dimension, rows: mapped };
696
- const ids = [...new Set(mapped.map((row) => row.name))];
697
- const labels = ids.length > 0 ? await this.apiKeyLabels(tenantId, ids) : new Map<string, string>();
698
- return {
699
- dimension,
700
- rows: mapped.map((row) => {
701
- const label = labels.get(row.name);
702
- return label === undefined ? row : { ...row, label };
703
- }),
704
- };
705
- }
706
-
707
- private async apiKeyLabels(tenantId: string, ids: readonly string[]): Promise<Map<string, string>> {
708
- const unique = [...new Set(ids)];
709
- if (unique.length === 0) return new Map<string, string>();
710
- const keyRows = await this.db
711
- .select({ id: apiKeys.id, label: apiKeys.label })
712
- .from(apiKeys)
713
- .where(and(inArray(apiKeys.id, [...unique]), eq(apiKeys.tenantId, tenantId)));
714
- return new Map(keyRows.map((keyRow) => [keyRow.id, keyRow.label] as const));
715
- }
716
-
717
- async usageCache(tenantId: string, period: string): Promise<CachedResult<UsageCacheResponse>> {
718
- return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "cache"), () =>
719
- this.computeCache(tenantId, period),
720
- );
721
- }
722
-
723
- private async computeCache(tenantId: string, period: string): Promise<UsageCacheResponse> {
724
- const events = await this.periodEvents(tenantId, period);
725
- let inputTokens = 0;
726
- let cachedTokens = 0;
727
- let outputTokens = 0;
728
- for (const event of events) {
729
- inputTokens += event.inputTokens ?? 0;
730
- outputTokens += event.outputTokens ?? 0;
731
- if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
732
- cachedTokens += event.inputTokens ?? 0;
733
- }
734
- }
735
- const tokenTotal = inputTokens + outputTokens;
736
- return {
737
- period,
738
- inputTokens,
739
- cachedTokens,
740
- cacheWriteTokens: 0,
741
- hitRate: tokenTotal > 0 ? (cachedTokens / tokenTotal) * 100 : 0,
742
- };
743
- }
744
-
745
- async usageRequests(
746
- tenantId: string,
747
- period: string,
748
- limit: number,
749
- httpStatus?: number,
750
- ): Promise<CachedResult<UsageRequestsResponse>> {
751
- // Ask the reader for `limit` rows instead of folding the whole window and
752
- // slicing it. It stops at the newest file that fills the page, so the
753
- // console's most-polled endpoint no longer parses the retention window just
754
- // to show 50 rows.
755
- const since = periodStartDate(period);
756
- const events = await queryTelemetryEvents({
757
- tenantId,
758
- limit,
759
- ...(httpStatus === undefined ? {} : { httpStatus }),
760
- ...(since === undefined ? {} : { since }),
761
- });
762
- const rows = events.slice(0, limit);
763
- const hideClientIp = await shouldMaskClientIp(this.preferences, tenantId);
764
- // Cached like its siblings, so a repeated poll costs a `304` instead of
765
- // another read. The mask flag is part of the key: it is a privacy setting,
766
- // so a page must never be served from an entry computed under the other
767
- // value. The rows were already read above — this bounds the label lookup
768
- // and the mapping, not the telemetry read.
769
- return this.readThroughWithEtag(
770
- this.usageCacheKey(
771
- tenantId,
772
- `${period}:${limit}:${httpStatus ?? "*"}:${hideClientIp}`,
773
- "requests",
774
- ),
775
- async () => {
776
- const keyIds = [...new Set(rows.map((row) => row.apiKeyId).filter((id): id is string => id !== null))];
777
- const labels = await this.apiKeyLabels(tenantId, keyIds);
778
- // One indexed read for the whole page, not one per row: presence lives
779
- // on the payload row so the list can render it without inflating a
780
- // frame for every row it shows.
781
- const requestIds = [...new Set(rows.map((row) => row.requestId).filter((id): id is string => id !== null))];
782
- const stages = await this.payloadStages(tenantId, requestIds);
783
- return {
784
- items: rows.map((row) => {
785
- const item = mapUsageRequestItem(row, hideClientIp);
786
- const label = row.apiKeyId ? labels.get(row.apiKeyId) : undefined;
787
- const labeled = label === undefined ? item : { ...item, apiKeyLabel: label };
788
- const stage = stages.get(row.requestId);
789
- // Omitted rather than zero when the row is unknown: absence means
790
- // "no payload was captured", and 0 would say the same thing while
791
- // claiming it had been measured.
792
- return stage === undefined ? labeled : { ...labeled, payloadStages: stage };
793
- }),
794
- };
795
- },
796
- );
797
- }
798
-
799
- /**
800
- * Captured-stage bitmask per request, newest payload row winning.
801
- * One indexed read for a whole page. Presence is on the payload row
802
- * specifically so the list never has to inflate a frame to answer "does this
803
- * panel have content".
804
- */
805
- private async payloadStages(tenantId: string, requestIds: readonly string[]): Promise<Map<string, number>> {
806
- if (requestIds.length === 0) return new Map();
807
- const rows = await this.db
808
- .select({ requestId: telemetryPayloads.requestId, stages: telemetryPayloads.stages })
809
- .from(telemetryPayloads)
810
- .where(
811
- and(
812
- eq(telemetryPayloads.tenantId, tenantId),
813
- inArray(telemetryPayloads.requestId, [...requestIds]),
814
- ),
815
- )
816
- .orderBy(desc(telemetryPayloads.capturedAt));
817
- const byRequest = new Map<string, number>();
818
- for (const row of rows) {
819
- if (row.requestId === null || row.stages === null) continue;
820
- // Ordered newest-first, so the first hit is the one the drawer reads.
821
- if (!byRequest.has(row.requestId)) byRequest.set(row.requestId, row.stages);
822
- }
823
- return byRequest;
824
- }
825
-
826
- async usageRequestDetail(
827
- tenantId: string,
828
- requestId: string,
829
- ): Promise<UsageRequestDetail | undefined> {
830
- const rows = await queryTelemetryEvents({ tenantId, requestId });
831
- const event = rows[0];
832
- if (!event) return undefined;
833
- // The label, proxy, and mask lookups are independent: run them together
834
- // instead of three sequential round trips.
835
- const [hideClientIp, keyLabels, proxy] = await Promise.all([
836
- shouldMaskClientIp(this.preferences, tenantId),
837
- event.apiKeyId ? this.apiKeyLabels(tenantId, [event.apiKeyId]) : undefined,
838
- event.networkPoolId ? resolveProxyLabel(this.db, tenantId, event.networkPoolId) : ("direct" as const),
839
- ]);
840
- const item = mapUsageRequestItem(event, hideClientIp);
841
- const keyLabel = event.apiKeyId ? keyLabels?.get(event.apiKeyId) : undefined;
842
- const labeled = keyLabel === undefined ? item : { ...item, apiKeyLabel: keyLabel };
843
- const presented = { ...labeled, proxy };
844
- // Filter the payload table directly instead of joining `telemetry_events`
845
- // only to re-apply the tenant predicate: `telemetry_payloads` carries
846
- // `tenant_id` itself, so the join added a second table's rows to filter for
847
- // no additional constraint. This matches `getEvent` above and uses
848
- // `telemetry_payloads_tenant_request_idx`.
849
- const linkedPayloadRows = await this.db
850
- .select({ payload: telemetryPayloads })
851
- .from(telemetryPayloads)
852
- .where(
853
- and(
854
- eq(telemetryPayloads.requestId, requestId),
855
- eq(telemetryPayloads.tenantId, tenantId),
856
- ),
857
- )
858
- .orderBy(desc(telemetryPayloads.capturedAt))
859
- .limit(1);
860
- const payloadRow = linkedPayloadRows[0]?.payload;
861
- const captured = await readCapturedBodies(payloadRow);
862
- if (!captured) {
863
- return { ...presented, payloads: null };
864
- }
865
- return {
866
- ...presented,
867
- payloads: {
868
- ...(captured.request !== undefined ? { request: captured.request } : {}),
869
- ...(captured.response !== undefined ? { response: captured.response } : {}),
870
- ...(captured.clientResponse !== undefined ? { clientResponse: captured.clientResponse } : {}),
871
- ...(captured.providerRequest !== undefined
872
- ? { providerRequest: captured.providerRequest }
873
- : {}),
874
- ...(captured.providerResponse !== undefined
875
- ? { providerResponse: captured.providerResponse }
876
- : {}),
877
- },
878
- ...(captured.signals === undefined ? {} : { payloadSignals: captured.signals }),
879
- };
880
- }
881
-
882
- async listEvents(
883
- tenantId: string,
884
- limit: number,
885
- cursor?: string,
886
- ): Promise<TelemetryEventListPage> {
887
- const before = decodeDatedCursor(cursor);
888
- // The cursor becomes an `until` bound inside the query, so `limit` applies
889
- // to every page and not only the first. Filtering after the read forced the
890
- // previous shape: a cursor page had to fetch the whole window to know where
891
- // it ended, so `limit` was dropped the moment a cursor appeared.
892
- //
893
- // `until` is inclusive while the cursor is exclusive, so rows at the exact
894
- // cursor timestamp are still compared by id here — the cheap part of the
895
- // predicate, now over one timestamp's worth of rows instead of the window.
896
- const events = await queryTelemetryEvents({
897
- tenantId,
898
- limit: limit + 1,
899
- ...(before === undefined ? {} : { until: new Date(before.createdAt) }),
900
- });
901
- const rows = (before
902
- ? events.filter((event) => event.createdAt < before.createdAt || (event.createdAt === before.createdAt && event.id < before.id))
903
- : events
904
- ).slice(0, limit + 1);
905
- const overflow = rows.length > limit;
906
- const trimmed = overflow ? rows.slice(0, limit) : rows;
907
- const entries = trimmed.map(mapTelemetryEventRow);
908
- const lastEntry = entries[entries.length - 1];
909
- const nextCursor =
910
- overflow && lastEntry !== undefined
911
- ? encodeCursor({ createdAt: lastEntry.createdAt, id: lastEntry.id })
912
- : undefined;
913
- return nextCursor ? { entries, nextCursor } : { entries };
914
- }
915
-
916
- async getEvent(
917
- tenantId: string,
918
- eventId: string,
919
- includePayload = false,
920
- createdAt?: string,
921
- ): Promise<TelemetryEventDetail | undefined> {
922
- // Matched by `id`, which is the field the list cursor encodes — not by
923
- // `requestId`, which is a different column that merely defaults to the
924
- // same value. Predicating on it lets the reader stop at the first hit
925
- // instead of parsing the retention window to find one row.
926
- const events = await queryTelemetryEvents({ tenantId, id: eventId });
927
- const event = events.find(
928
- (candidate) =>
929
- candidate.id === eventId && (createdAt === undefined || candidate.createdAt === createdAt),
930
- );
931
- if (!event) return undefined;
932
- const base = mapTelemetryEventRow(event);
933
- if (!includePayload) return base;
934
- const payloadRows = await this.db
935
- .select()
936
- .from(telemetryPayloads)
937
- .where(
938
- and(
939
- eq(telemetryPayloads.requestId, event.requestId),
940
- eq(telemetryPayloads.tenantId, tenantId),
941
- ),
942
- )
943
- .orderBy(desc(telemetryPayloads.capturedAt))
944
- .limit(1);
945
- const payloadRow = payloadRows[0];
946
- if (!payloadRow) return base;
947
- const captured = await readCapturedBodies(payloadRow);
948
- if (!captured) return base;
949
- return {
950
- ...base,
951
- payload: {
952
- ...(captured.request !== undefined ? { requestBody: captured.request } : {}),
953
- ...(captured.response !== undefined ? { responseBody: captured.response } : {}),
954
- },
955
- };
956
- }
957
- }
958
-
959
- /**
960
- * Reads the captured bodies behind one `telemetry_payloads` row.
961
- *
962
- * The row stores typed frame-reference columns only; every body lives in the
963
- * frame file those columns name. Returns `undefined` when there is no row, no
964
- * usable reference, or the frame cannot be read — all three mean "nothing to
965
- * show", and the caller renders the request without a payload section. The raw
966
- * reference is never returned to a caller, so it cannot reach the dashboard.
967
- */
968
- async function readCapturedBodies(
969
- row:
970
- | {
971
- readonly storage: string;
972
- readonly file: string;
973
- readonly offset: number;
974
- readonly length: number;
975
- readonly checksum: string;
976
- readonly version: number;
977
- }
978
- | null
979
- | undefined,
980
- ): Promise<
981
- | {
982
- request: unknown;
983
- response: unknown;
984
- clientResponse: unknown;
985
- providerRequest: unknown;
986
- providerResponse: unknown;
987
- signals?: { toolCalls: number; images: number; attachments: number };
988
- }
989
- | undefined
990
- > {
991
- if (!row) return undefined;
992
- const reference = payloadReferenceFromRow(row);
993
- if (!reference) return undefined;
994
- try {
995
- const stored = await readPayloadFrame(reference);
996
- if (!stored || typeof stored !== "object") return undefined;
997
- const record = stored as Record<string, unknown>;
998
- const bodies = {
999
- request: record["request_body"] ?? undefined,
1000
- response: record["response_body"] ?? undefined,
1001
- clientResponse: record["client_response_body"] ?? undefined,
1002
- providerRequest: record["provider_request_body"] ?? undefined,
1003
- providerResponse: record["provider_response_body"] ?? undefined,
1004
- };
1005
- const signals =
1006
- typeof record["signals"] === "object" &&
1007
- record["signals"] !== null &&
1008
- !Array.isArray(record["signals"])
1009
- ? (record["signals"] as { toolCalls: number; images: number; attachments: number })
1010
- : undefined;
1011
- return {
1012
- ...bodies,
1013
- ...(signals === undefined ? {} : { signals }),
1014
- };
1015
- } catch {
1016
- return undefined;
1017
- }
1018
- }
1
+ import { loadavg, cpus, totalmem } from "node:os";
2
+ import { and, desc, eq, inArray } from "drizzle-orm";
3
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
4
+ import type { RedisClient } from "../../persistence/redis";
5
+ import { apiKeys, networkPools, telemetryPayloads } from "../../persistence/schema";
6
+ import {
7
+ queryTelemetryEvents,
8
+ type TelemetryEventRecord,
9
+ } from "../../observability/event-log";
10
+ import { isGatewayError } from "../../observability/telemetry-status";
11
+ import { queryIndexedBuckets, type IndexedBucket } from "../../observability/event-day-index";
12
+ import {
13
+ LATENCY_BUCKETS_MS,
14
+ percentileFromHistogram,
15
+ } from "../../observability/event-compaction";
16
+ import { cachedReadWithEtag, type CachedResult } from "../shared/read-through-cache";
17
+ import { decodeDatedCursor, encodeCursor } from "../shared/cursor";
18
+ import { CARTETHYIA_VERSION } from "../../version";
19
+ import { CachedPreferencesReader, DrizzlePreferencesReader, type PreferencesReader } from "../../persistence/tenant-preferences";
20
+ import type {
21
+ ObservabilityStore,
22
+ UsageDimension,
23
+ SystemHealthResponse,
24
+ TelemetryEventView,
25
+ TelemetryEventDetail,
26
+ TelemetryEventListPage,
27
+ UsageByResponse,
28
+ UsageCacheResponse,
29
+ UsageChartBucket,
30
+ UsageChartResponse,
31
+ UsageRequestDetail,
32
+ UsageRequestItem,
33
+ UsageRequestsResponse,
34
+ UsageResponse,
35
+ UsageSummaryResponse,
36
+ } from "./contracts";
37
+ import { maskClientIp } from "../../observability/redaction";
38
+ import { splitEndpointConfig } from "../../network/pool/agent";
39
+ import { resolveDbMode } from "../../persistence/db-mode";
40
+ import { payloadReferenceFromRow, readPayloadFrame } from "../../observability/payload-store";
41
+ import { shouldMaskClientIp } from "../shared/ip-privacy";
42
+
43
+ function mapTelemetryEventRow(event: TelemetryEventRecord): TelemetryEventView {
44
+ return {
45
+ id: event.id,
46
+ createdAt: event.createdAt,
47
+ tenantId: event.tenantId ?? "",
48
+ requestId: event.requestId,
49
+ ...(event.sourceSurface ? { sourceSurface: event.sourceSurface } : {}),
50
+ ...(event.requestedModel ? { requestedModel: event.requestedModel } : {}),
51
+ ...(event.reportedModel ? { reportedModel: event.reportedModel } : {}),
52
+ ...(event.providerId ? { providerId: event.providerId } : {}),
53
+ ...(event.latencyMs !== null ? { latencyMs: event.latencyMs ?? undefined } : {}),
54
+ ...(event.status ? { status: event.status } : {}),
55
+ ...(event.inputTokens !== null ? { inputTokens: event.inputTokens ?? undefined } : {}),
56
+ ...(event.outputTokens !== null ? { outputTokens: event.outputTokens ?? undefined } : {}),
57
+ ...(event.estimatedCostUsd !== null
58
+ ? { estimatedCost: Number(event.estimatedCostUsd ?? 0) }
59
+ : {}),
60
+ ...(event.tokensPerSec !== null ? { tokensPerSec: Number(event.tokensPerSec) } : {}),
61
+ ...(event.firstContentDeltaAtMs !== null ? { firstContentDeltaAtMs: event.firstContentDeltaAtMs } : {}),
62
+ ...(event.lastEventAtMs !== null ? { lastEventAtMs: event.lastEventAtMs } : {}),
63
+ };
64
+ }
65
+ /** The HTTP status a row renders as when the wire status was never recorded. */
66
+ function effectiveHttpStatus(event: TelemetryEventRecord): number {
67
+ if (event.httpStatus !== null) return event.httpStatus;
68
+ if (event.status === "completed") return 200;
69
+ if (event.status === "cancelled") return 499;
70
+ if (event.status === "truncated") return 502;
71
+ return 500;
72
+ }
73
+
74
+ /**
75
+ * Opaque, monotonic (createdAt, id) cursor. `createdAt` alone is not unique
76
+ * — two telemetry events in the same millisecond would silently skip one
77
+ * row across pages — so we tie-break on id and encode both, matching
78
+ * `DrizzleAuditReadStore`'s cursor.
79
+ */
80
+
81
+ /** Resolves a `usage` period token ("24h", "7d", "30d", "all") to its inclusive start date. */
82
+ function periodStartDate(period: string): Date | undefined {
83
+ const match = /^(\d+)([hd])$/.exec(period);
84
+ if (!match) return undefined;
85
+ const amount = Number(match[1]);
86
+ const unitMs = match[2] === "h" ? 3_600_000 : 86_400_000;
87
+ return new Date(Date.now() - amount * unitMs);
88
+ }
89
+
90
+
91
+ /**
92
+ * Client identity straight from the stored `User-Agent`: the first RFC 9110
93
+ * product token carries both the client and its version (`bun/1.2.3`,
94
+ * `codex-cli/0.1.0`), so no prefix catalog can drift from what the client
95
+ * actually sent. Browser agents start with the historical `Mozilla/5.0`
96
+ * token, which names no real client, so they report as `Browser`.
97
+ */
98
+ export function clientNameFromUserAgent(userAgent: string | null | undefined): string {
99
+ const raw = userAgent?.trim();
100
+ if (!raw) return "unknown";
101
+ if (/^mozilla\//i.test(raw)) return "Browser";
102
+ const product = raw.split(/\s+/, 1)[0] ?? "";
103
+ const slash = product.indexOf("/");
104
+ const token = slash <= 0 ? product : product.slice(0, slash + 1).concat(product.slice(slash + 1).split(/[;,)]/, 1)[0] ?? "");
105
+ return token || "unknown";
106
+ }
107
+ /** Time-series bucket width for the usage chart, chosen from the window length. */
108
+ function chartBucketSeconds(period: string): number {
109
+ const since = periodStartDate(period);
110
+ if (!since) return 86_400;
111
+ const windowMs = Date.now() - since.getTime();
112
+ if (windowMs <= 2 * 3_600_000) return 300;
113
+ if (windowMs <= 2 * 86_400_000) return 3_600;
114
+ if (windowMs <= 10 * 86_400_000) return 21_600;
115
+ return 86_400;
116
+ }
117
+
118
+ /**
119
+ * The model id with the client's requested reasoning effort as a display
120
+ * suffix — only when the request actually carried one.
121
+ */
122
+ function modelWithEffort(event: TelemetryEventRecord): string | undefined {
123
+ const model = event.requestedModel;
124
+ if (!model) return undefined;
125
+ return event.requestedEffort ? `${model} (${event.requestedEffort})` : model;
126
+ }
127
+
128
+ function mapUsageRequestItem(
129
+ event: TelemetryEventRecord,
130
+ hideClientIp: boolean,
131
+ ): UsageRequestItem {
132
+ const input = event.inputTokens ?? 0;
133
+ const output = event.outputTokens ?? 0;
134
+ return {
135
+ requestId: event.requestId,
136
+ ...(event.sourceSurface ? { surface: event.sourceSurface } : {}),
137
+ ...(event.providerId ? { providerId: event.providerId } : {}),
138
+ ...(event.accountId ? { accountId: event.accountId } : {}),
139
+ ...(modelWithEffort(event) === undefined ? {} : { model: modelWithEffort(event) as string }),
140
+ ...(event.reportedModel ? { reportedModel: event.reportedModel } : {}),
141
+ status: event.status ?? "unknown",
142
+ ...(event.errorCategory ? { errorKind: event.errorCategory } : {}),
143
+ ...(event.errorOrigin ? { errorOrigin: event.errorOrigin } : {}),
144
+ httpStatus: effectiveHttpStatus(event),
145
+ mode: event.stream ? "stream" : "non_stream",
146
+ startedAt: event.createdAt,
147
+ ...(event.latencyMs !== null ? { durationMs: event.latencyMs ?? undefined } : {}),
148
+ ...(event.inputTokens !== null ? { inputTokens: event.inputTokens ?? undefined } : {}),
149
+ ...(event.outputTokens !== null ? { outputTokens: event.outputTokens ?? undefined } : {}),
150
+ ...(event.cachedInputTokens !== null
151
+ ? { cachedTokens: event.cachedInputTokens ?? undefined }
152
+ : {}),
153
+ ...(event.reasoningTokens !== null
154
+ ? { reasoningTokens: event.reasoningTokens ?? undefined }
155
+ : {}),
156
+ ...(event.endpoint ? { endpoint: event.endpoint } : {}),
157
+ ...(event.apiKeyId ? { apiKeyId: event.apiKeyId } : {}),
158
+ ...(event.userAgent ? { userAgent: event.userAgent } : {}),
159
+ ...(event.userAgent ? { clientName: clientNameFromUserAgent(event.userAgent) } : {}),
160
+ ...(event.clientIp
161
+ ? { clientIp: hideClientIp ? maskClientIp(event.clientIp) : event.clientIp }
162
+ : {}),
163
+ ...(input + output > 0 ? { totalTokens: input + output } : {}),
164
+ ...(event.ttfbMs !== null ? { ttfbMs: event.ttfbMs ?? undefined } : {}),
165
+ ...(event.tokensPerSec !== null ? { tokensPerSec: Number(event.tokensPerSec) } : {}),
166
+ ...(event.firstContentDeltaAtMs !== null ? { firstContentDeltaAtMs: event.firstContentDeltaAtMs } : {}),
167
+ ...(event.lastEventAtMs !== null ? { lastEventAtMs: event.lastEventAtMs } : {}),
168
+ ...(event.estimatedCostUsd !== null && event.estimatedCostUsd !== undefined
169
+ ? { estimatedCost: Number(event.estimatedCostUsd) }
170
+ : {}),
171
+ ...(event.creditUsed !== null && event.creditUsed !== undefined
172
+ ? { creditUsed: Number(event.creditUsed) }
173
+ : {}),
174
+ ...(event.resolveMs !== null && event.resolveMs !== undefined
175
+ ? { resolveMs: event.resolveMs }
176
+ : {}),
177
+ // Why the drawer is empty for this request — see the contract field. This is
178
+ // what makes the blank panels diagnosable without server logs.
179
+ ...(event.payloadCaptureState ? { payloadCaptureState: event.payloadCaptureState } : {}),
180
+ };
181
+ }
182
+ async function resolveProxyLabel(
183
+ db: CartethyiaDatabase,
184
+ tenantId: string,
185
+ poolId: string,
186
+ ): Promise<string> {
187
+ const rows = await db
188
+ .select({ id: networkPools.id, endpointConfig: networkPools.endpointConfig })
189
+ .from(networkPools)
190
+ .where(and(eq(networkPools.id, poolId), eq(networkPools.tenantId, tenantId)))
191
+ .limit(1);
192
+ const row = rows[0];
193
+ if (!row) return `${poolId.slice(0, 8)}…`;
194
+ const { endpoint, label } = splitEndpointConfig((row.endpointConfig ?? {}) as Record<string, unknown>);
195
+ return label ?? endpoint ?? `${poolId.slice(0, 8)}…`;
196
+ }
197
+ const HEALTH_WINDOW_MS = 24 * 3_600_000;
198
+ const TOP_N = 50;
199
+ /**
200
+ * How long a parsed window is shared. A dashboard poll fires ~8 usage
201
+ * endpoints at once, and a slow parse used to outlive a 1.5 s window — so the
202
+ * endpoints that missed it each re-read and re-parsed the same files, which is
203
+ * how one page load became several full parses of the retention window.
204
+ */
205
+ const PERIOD_MEMO_TTL_MS = 5_000;
206
+
207
+ /** Nearest-rank percentile over a sorted array, matching `percentile_cont` closely enough for a health gauge. */
208
+ function percentile(sorted: readonly number[], fraction: number): number {
209
+ if (sorted.length === 0) return 0;
210
+ const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));
211
+ return Math.round(sorted[index] ?? 0);
212
+ }
213
+
214
+ export interface UsagePerformanceSummary {
215
+ avgResolveMs: number;
216
+ avgDurationMs: number;
217
+ latencyP50Ms: number;
218
+ latencyP95Ms: number;
219
+ latencyP99Ms: number;
220
+ /** Events each series was computed from; see `UsageSummaryTotals`. */
221
+ latencySampleCount: number;
222
+ resolveSampleCount: number;
223
+ }
224
+
225
+ /** Summarizes routing delay and request latency for an already-filtered period. */
226
+ export function summarizeUsagePerformance(
227
+ events: readonly Pick<TelemetryEventRecord, "latencyMs" | "resolveMs">[],
228
+ ): UsagePerformanceSummary {
229
+ const latencies: number[] = [];
230
+ let latencySum = 0;
231
+ let resolveSum = 0;
232
+ let resolveCount = 0;
233
+ for (const event of events) {
234
+ if (event.latencyMs !== null) {
235
+ latencies.push(event.latencyMs);
236
+ latencySum += event.latencyMs;
237
+ }
238
+ if (event.resolveMs !== null) {
239
+ resolveSum += event.resolveMs;
240
+ resolveCount += 1;
241
+ }
242
+ }
243
+ latencies.sort((a, b) => a - b);
244
+ return {
245
+ avgResolveMs: resolveCount > 0 ? resolveSum / resolveCount : 0,
246
+ avgDurationMs: latencies.length > 0 ? latencySum / latencies.length : 0,
247
+ latencyP50Ms: percentile(latencies, 0.5),
248
+ latencyP95Ms: percentile(latencies, 0.95),
249
+ latencyP99Ms: percentile(latencies, 0.99),
250
+ latencySampleCount: latencies.length,
251
+ resolveSampleCount: resolveCount,
252
+ };
253
+ }
254
+
255
+ /**
256
+ * The performance summary from indexed buckets rather than from events.
257
+ *
258
+ * The index keeps a latency sum, a sample count, and a histogram, so the mean
259
+ * stays exact and the percentiles stay bucketed — the same precision the
260
+ * per-event version had, without parsing the period's requests.
261
+ */
262
+ export function summarizeBucketPerformance(
263
+ buckets: readonly IndexedBucket[],
264
+ ): UsagePerformanceSummary {
265
+ let latencySum = 0;
266
+ let latencyCount = 0;
267
+ const histogram: number[] = [];
268
+ for (const bucket of buckets) {
269
+ latencySum += bucket.latencySum;
270
+ latencyCount += bucket.latencyCount;
271
+ for (let index = 0; index < bucket.latencyHistogram.length; index += 1) {
272
+ histogram[index] = (histogram[index] ?? 0) + (bucket.latencyHistogram[index] ?? 0);
273
+ }
274
+ }
275
+ const fill =
276
+ histogram.length > 0
277
+ ? histogram
278
+ : new Array<number>(LATENCY_BUCKETS_MS.length + 1).fill(0);
279
+ return {
280
+ avgResolveMs: 0,
281
+ avgDurationMs: latencyCount > 0 ? latencySum / latencyCount : 0,
282
+ latencyP50Ms: percentileFromHistogram(fill, 50) ?? 0,
283
+ latencyP95Ms: percentileFromHistogram(fill, 95) ?? 0,
284
+ latencyP99Ms: percentileFromHistogram(fill, 99) ?? 0,
285
+ latencySampleCount: latencyCount,
286
+ resolveSampleCount: 0,
287
+ };
288
+ }
289
+
290
+ /** The largest entries of a count map, capped. */
291
+ function topCounts(counts: ReadonlyMap<string, number>, limit: number): Array<[string, number]> {
292
+ return [...counts].sort((a, b) => b[1] - a[1]).slice(0, limit);
293
+ }
294
+
295
+ /** The grouping value for one usage breakdown dimension. */
296
+ function dimensionName(dimension: UsageDimension, event: TelemetryEventRecord): string | null {
297
+ if (dimension === "model") return event.requestedModel;
298
+ if (dimension === "key") return event.apiKeyId;
299
+ if (dimension === "client") return event.userAgent;
300
+ if (dimension === "client_ip") return event.clientIp;
301
+ return event.providerId;
302
+ }
303
+
304
+ interface BreakdownBucket {
305
+ name: string;
306
+ requests: number;
307
+ input: number;
308
+ output: number;
309
+ cached: number;
310
+ errors: number;
311
+ cost: number | null;
312
+ rateSum: number;
313
+ rateCount: number;
314
+ }
315
+
316
+ function emptyBreakdown(name: string): BreakdownBucket {
317
+ return { name, requests: 0, input: 0, output: 0, cached: 0, errors: 0, cost: null, rateSum: 0, rateCount: 0 };
318
+ }
319
+
320
+ /** Real Drizzle-backed observability repository used by the production console. */
321
+ export class DrizzleObservabilityStore implements ObservabilityStore {
322
+ /**
323
+ * Per-(tenant, period) parsed event set, shared by the ~8 usage endpoints
324
+ * one dashboard poll fires in parallel. 1.5 s window: a poll burst shares
325
+ * one file parse; the next poll re-reads. In-flight shares one promise.
326
+ */
327
+ private readonly periodMemo = new Map<string, { at: number; promise: Promise<readonly TelemetryEventRecord[]> }>();
328
+ private readonly periodBucketMemo = new Map<string, { at: number; promise: Promise<readonly IndexedBucket[]> }>();
329
+ constructor(
330
+ private readonly db: CartethyiaDatabase,
331
+ private readonly redis: RedisClient | undefined,
332
+ /**
333
+ * Tenant preferences, revision-cached. The Usage page reads the privacy
334
+ * gate on the list, detail and breakdown paths, so a direct SELECT here
335
+ * re-read the same row per request; the cache collapses that.
336
+ */
337
+ private readonly preferences: PreferencesReader = new CachedPreferencesReader(
338
+ new DrizzlePreferencesReader(db),
339
+ ),
340
+ ) {}
341
+
342
+ async health(tenantId: string): Promise<CachedResult<SystemHealthResponse>> {
343
+ // The only usage-family read that used to bypass both the 5 s in-process
344
+ // memo and the Redis read-through, so it re-parsed 24 hourly files every
345
+ // page load. Same 10 s TTL as `/system/usage`.
346
+ return this.readThroughWithEtag(
347
+ this.usageCacheKey(tenantId, "24h", "health"),
348
+ () => this.computeHealth(tenantId),
349
+ );
350
+ }
351
+
352
+ /**
353
+ * Same two numbers `computeHealth` reports, read fresh.
354
+ *
355
+ * Deliberately not `computeHealth`: that also scans 24 h of telemetry files,
356
+ * which is exactly the cost the cache exists to avoid, and a memory watcher
357
+ * polling every 10 s must not pay it. Kept in the store so the percentage
358
+ * has one definition rather than two.
359
+ */
360
+ async liveMemory(): Promise<{ memory_bytes: number; memory_percent: number }> {
361
+ const memory = process.memoryUsage();
362
+ return {
363
+ memory_bytes: memory.rss,
364
+ memory_percent: Math.round((memory.rss / Math.max(totalmem(), 1)) * 10000) / 100,
365
+ };
366
+ }
367
+
368
+ private async computeHealth(tenantId: string): Promise<SystemHealthResponse> {
369
+ const memory = process.memoryUsage();
370
+ // `loadavg()` is [0, 0, 0] on every Windows build — there is no such
371
+ // syscall and Node reports a zeroed tuple rather than failing. Divided by
372
+ // cores that is a confident-looking 0.0% on a busy gateway, which is worse
373
+ // than saying nothing: an operator reads it as "idle" and stops looking.
374
+ // Reported as null instead of as a measurement.
375
+ //
376
+ // A real zero load is *not* the same thing and is reported as zero: an
377
+ // idle machine genuinely has nothing queued, and collapsing that into
378
+ // "unavailable" would hide a number the platform did supply.
379
+ const cpuPercent: number | null =
380
+ process.platform === "win32"
381
+ ? null
382
+ : Math.round(((loadavg()[0] ?? 0) / Math.max(cpus().length, 1)) * 10000) / 100;
383
+
384
+ let databaseHealthy = true;
385
+ let total = 0;
386
+ let errors = 0;
387
+ let avg = 0;
388
+ let p95 = 0;
389
+ let p99 = 0;
390
+ let cacheHitRate = 0;
391
+ let avgTokensPerSec = 0;
392
+ try {
393
+ // Bounded 24h rolling window (matches the Overview copy). The file is the
394
+ // source, so the percentiles are computed over the retained events.
395
+ const since = new Date(Date.now() - HEALTH_WINDOW_MS);
396
+ const events = await queryTelemetryEvents({ tenantId, since });
397
+ const latencies: number[] = [];
398
+ let latencySum = 0;
399
+ let inputTokens = 0;
400
+ let cachedTokens = 0;
401
+ let outputTokens = 0;
402
+ let tokensPerSecSum = 0;
403
+ let tokensPerSecCount = 0;
404
+ for (const event of events) {
405
+ total += 1;
406
+ if (isGatewayError(event.status, event.httpStatus)) errors += 1;
407
+ if (event.latencyMs !== null) {
408
+ latencies.push(event.latencyMs);
409
+ latencySum += event.latencyMs;
410
+ }
411
+ if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
412
+ inputTokens += event.inputTokens ?? 0;
413
+ cachedTokens += event.inputTokens ?? 0;
414
+ outputTokens += event.outputTokens ?? 0;
415
+ }
416
+ if (event.tokensPerSec !== null) {
417
+ tokensPerSecSum += event.tokensPerSec;
418
+ tokensPerSecCount += 1;
419
+ }
420
+ }
421
+ latencies.sort((a, b) => a - b);
422
+ avg = latencies.length > 0 ? Math.round(latencySum / latencies.length) : 0;
423
+ p95 = percentile(latencies, 0.95);
424
+ p99 = percentile(latencies, 0.99);
425
+ // Cached share of the prompt: cached/input, the same honest definition
426
+ // usageSummary uses, so the health card and the Usage page cannot
427
+ // disagree. (This used to be prompt-share input/(input+output), which
428
+ // read ~99% and was mislabeled a cache hit rate.)
429
+ cacheHitRate = inputTokens > 0 ? Math.round((cachedTokens / inputTokens) * 10000) / 100 : 0;
430
+ avgTokensPerSec = tokensPerSecCount > 0 ? tokensPerSecSum / tokensPerSecCount : 0;
431
+ } catch {
432
+ databaseHealthy = false;
433
+ }
434
+
435
+ // No client means the memory backend, which is healthy by construction.
436
+ let redisHealthy = this.redis === undefined;
437
+ if (this.redis !== undefined) {
438
+ try {
439
+ redisHealthy = (await this.redis.ping()) === "PONG";
440
+ } catch {
441
+ redisHealthy = false;
442
+ }
443
+ }
444
+
445
+ const status: SystemHealthResponse["status"] = !databaseHealthy
446
+ ? "unhealthy"
447
+ : !redisHealthy
448
+ ? "degraded"
449
+ : "healthy";
450
+
451
+ return {
452
+ version: CARTETHYIA_VERSION,
453
+ status,
454
+ uptime_seconds: Math.floor(process.uptime()),
455
+ database_healthy: databaseHealthy,
456
+ redis_healthy: redisHealthy,
457
+ redis_configured: this.redis !== undefined,
458
+ memory_heap_bytes: memory.heapUsed,
459
+ memory_external_bytes: memory.external,
460
+ memory_bytes: memory.rss,
461
+ memory_percent: Math.round((memory.rss / Math.max(totalmem(), 1)) * 10000) / 100,
462
+ // Reported so the console can explain a large RSS instead of leaving the
463
+ // operator to read it as the gateway alone. In lite mode this process
464
+ // hosts the database too, so the resident set covers both.
465
+ database_in_process: resolveDbMode() === "lite",
466
+ cpu_percent: cpuPercent,
467
+ cpu_cores: cpus().length,
468
+ pid: process.pid,
469
+ platform: `Bun ${Bun.version} · ${process.platform}`,
470
+ request_count: total,
471
+ error_count: errors,
472
+ latency_avg_ms: avg,
473
+ latency_p95_ms: p95,
474
+ latency_p99_ms: p99,
475
+ cache_hit_rate_percent: cacheHitRate,
476
+ avg_tokens_per_sec: avgTokensPerSec,
477
+ };
478
+ }
479
+ async usage(tenantId: string, period: string): Promise<CachedResult<UsageResponse>> {
480
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "usage"), () =>
481
+ this.computeUsage(tenantId, period),
482
+ );
483
+ }
484
+
485
+ /**
486
+ * Cache key for one usage response. Versioned so a shape change can never be
487
+ * read back from a deployment that still holds the old one.
488
+ */
489
+ private usageCacheKey(tenantId: string, period: string, shape: string): string {
490
+ return `cartethyia:usage:v1:${shape}:${tenantId}:${period}`;
491
+ }
492
+
493
+ /**
494
+ * Reads a usage response through Redis — computing when no cache is
495
+ * configured — and returns the strong validator alongside it, so a repeated
496
+ * poll can be answered with a `304` instead of the whole payload.
497
+ */
498
+ private readThroughWithEtag<T>(key: string, compute: () => Promise<T>): Promise<CachedResult<T>> {
499
+ return cachedReadWithEtag(this.redis, key, compute);
500
+ }
501
+
502
+ private async computeUsage(tenantId: string, period: string): Promise<UsageResponse> {
503
+ const events = await this.periodEvents(tenantId, period);
504
+ let requestsTotal = 0;
505
+ let requestsSucceeded = 0;
506
+ let requestsFailed = 0;
507
+ let tokensUsed = 0;
508
+ let estimatedCost = 0;
509
+ const models = new Map<string, number>();
510
+ const providers = new Map<string, number>();
511
+ for (const event of events) {
512
+ requestsTotal += 1;
513
+ if (event.status === "completed") requestsSucceeded += 1;
514
+ if (isGatewayError(event.status, event.httpStatus)) requestsFailed += 1;
515
+ tokensUsed += (event.inputTokens ?? 0) + (event.outputTokens ?? 0);
516
+ estimatedCost += event.estimatedCostUsd ?? 0;
517
+ if (event.requestedModel) models.set(event.requestedModel, (models.get(event.requestedModel) ?? 0) + 1);
518
+ if (event.providerId) providers.set(event.providerId, (providers.get(event.providerId) ?? 0) + 1);
519
+ }
520
+ return {
521
+ tenantId,
522
+ period,
523
+ requestsTotal,
524
+ requestsSucceeded,
525
+ requestsFailed,
526
+ tokensUsed,
527
+ estimatedCost,
528
+ topModels: topCounts(models, TOP_N).map(([modelId, count]) => ({ modelId, count })),
529
+ topProviders: topCounts(providers, TOP_N).map(([providerId, count]) => ({ providerId, count })),
530
+ };
531
+ }
532
+
533
+ /** The tenant's retained events inside a usage period. `all` keeps everything. */
534
+ /**
535
+ * The indexed buckets covering a period, for one tenant.
536
+ *
537
+ * Reads the per-day index files instead of the event files. A usage query no
538
+ * longer parses every request in the window and filters it in memory, so its
539
+ * cost no longer grows with traffic.
540
+ */
541
+ private periodBuckets(tenantId: string, period: string): Promise<readonly IndexedBucket[]> {
542
+ const key = `${tenantId}:${period}`;
543
+ const now = Date.now();
544
+ const hit = this.periodBucketMemo.get(key);
545
+ if (hit && now - hit.at < PERIOD_MEMO_TTL_MS) return hit.promise;
546
+ const since = periodStartDate(period);
547
+ // Fold before reading. Events appended since the scheduler last ran are
548
+ // otherwise invisible, and a usage page that under-reports is worse than one
549
+ // that is a moment slow.
550
+ const promise = queryIndexedBuckets({
551
+ tenantId,
552
+ foldFirst: true,
553
+ ...(since === undefined ? {} : { since }),
554
+ });
555
+ this.periodBucketMemo.set(key, { at: now, promise });
556
+ void promise.catch(() => {
557
+ if (this.periodBucketMemo.get(key)?.promise === promise) this.periodBucketMemo.delete(key);
558
+ });
559
+ return promise;
560
+ }
561
+
562
+ private periodEvents(tenantId: string, period: string): Promise<readonly TelemetryEventRecord[]> {
563
+ const key = `${tenantId}:${period}`;
564
+ const now = Date.now();
565
+ const hit = this.periodMemo.get(key);
566
+ if (hit && now - hit.at < PERIOD_MEMO_TTL_MS) return hit.promise;
567
+ const since = periodStartDate(period);
568
+ const promise = queryTelemetryEvents(since === undefined ? { tenantId } : { tenantId, since });
569
+ this.periodMemo.set(key, { at: now, promise });
570
+ // A rejected parse must not poison the memo for the next poll.
571
+ void promise.catch(() => {
572
+ if (this.periodMemo.get(key)?.promise === promise) this.periodMemo.delete(key);
573
+ });
574
+ return promise;
575
+ }
576
+
577
+ async usageSummary(tenantId: string, period: string): Promise<CachedResult<UsageSummaryResponse>> {
578
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "summary"), () =>
579
+ this.computeSummary(tenantId, period),
580
+ );
581
+ }
582
+
583
+ private async computeSummary(tenantId: string, period: string): Promise<UsageSummaryResponse> {
584
+ const buckets = await this.periodBuckets(tenantId, period);
585
+ const performance = summarizeBucketPerformance(buckets);
586
+ let requests = 0;
587
+ let inputTokens = 0;
588
+ let cachedTokens = 0;
589
+ let outputTokens = 0;
590
+ let errors = 0;
591
+ let cancelled = 0;
592
+ let truncated = 0;
593
+ let estimatedCostUsd = 0;
594
+ let unpriced = 0;
595
+ let tokensPerSecSum = 0;
596
+ let tokensPerSecCount = 0;
597
+ const statuses = new Map<number, number>();
598
+ for (const row of buckets) {
599
+ requests += row.requests;
600
+ inputTokens += row.inputTokens;
601
+ outputTokens += row.outputTokens;
602
+ cachedTokens += row.cachedTokens;
603
+ errors += row.failed;
604
+ cancelled += row.cancelled;
605
+ truncated += row.truncated;
606
+ estimatedCostUsd += row.estimatedCostUsd;
607
+ unpriced += row.unpriced;
608
+ tokensPerSecSum += row.tokensPerSecSum;
609
+ tokensPerSecCount += row.tokensPerSecCount;
610
+ }
611
+ return {
612
+ period,
613
+ totals: {
614
+ statusCounts: [...statuses].sort(([a], [b]) => a - b).map(([status, count]) => ({ status, count })),
615
+ requests,
616
+ inputTokens,
617
+ cachedTokens,
618
+ outputTokens,
619
+ errors,
620
+ cancelled,
621
+ truncated,
622
+ ...performance,
623
+ estimatedCostUsd,
624
+ partial: unpriced > 0,
625
+ cacheHitRate: inputTokens > 0 ? (cachedTokens / inputTokens) * 100 : 0,
626
+ avgTokensPerSec: tokensPerSecCount > 0 ? tokensPerSecSum / tokensPerSecCount : 0,
627
+ },
628
+ };
629
+ }
630
+
631
+ async usageChart(tenantId: string, period: string): Promise<CachedResult<UsageChartResponse>> {
632
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "chart"), () =>
633
+ this.computeChart(tenantId, period),
634
+ );
635
+ }
636
+
637
+ private async computeChart(tenantId: string, period: string): Promise<UsageChartResponse> {
638
+ const since = periodStartDate(period);
639
+ const bucketSeconds = chartBucketSeconds(period);
640
+ const stepMs = bucketSeconds * 1000;
641
+ const events = await this.periodEvents(tenantId, period);
642
+ const byTime = new Map<number, UsageChartBucket>();
643
+ for (const event of events) {
644
+ const at = new Date(event.createdAt).getTime();
645
+ if (!Number.isFinite(at)) continue;
646
+ const start = Math.floor(at / stepMs) * stepMs;
647
+ const bucket = byTime.get(start) ?? { t: new Date(start).toISOString(), requests: 0, input: 0, cached: 0, output: 0 };
648
+ bucket.requests += 1;
649
+ bucket.input += event.inputTokens ?? 0;
650
+ bucket.cached += event.cachedInputTokens !== null && event.cachedInputTokens > 0 ? (event.inputTokens ?? 0) : 0;
651
+ bucket.output += event.outputTokens ?? 0;
652
+ byTime.set(start, bucket);
653
+ }
654
+ const rows = [...byTime.keys()];
655
+ const first = rows.length > 0 ? Math.min(...byTime.keys()) : undefined;
656
+ const startMs = since ? since.getTime() : first;
657
+ if (startMs === undefined) return { buckets: [] };
658
+ const aligned = Math.floor(startMs / stepMs) * stepMs;
659
+ const buckets: UsageChartBucket[] = [];
660
+ for (let at = aligned; at <= Date.now(); at += stepMs) {
661
+ buckets.push(
662
+ byTime.get(at) ?? { t: new Date(at).toISOString(), requests: 0, input: 0, cached: 0, output: 0 },
663
+ );
664
+ }
665
+ return { buckets };
666
+ }
667
+
668
+ async usageBy(
669
+ tenantId: string,
670
+ dimension: UsageDimension,
671
+ period: string,
672
+ ): Promise<CachedResult<UsageByResponse>> {
673
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, `${dimension}:${period}`, "by"), () =>
674
+ this.computeBy(tenantId, dimension, period),
675
+ );
676
+ }
677
+
678
+ private async computeBy(
679
+ tenantId: string,
680
+ dimension: UsageDimension,
681
+ period: string,
682
+ ): Promise<UsageByResponse> {
683
+ // The breakdown is read from the per-name sums the day index maintains, not
684
+ // from the events: the console asks how much went to each model, and the
685
+ // index already carries exactly that. Parsing the window to recount it was
686
+ // the whole cost of this endpoint.
687
+ const buckets = await this.periodBuckets(tenantId, period);
688
+ const groups = new Map<string, BreakdownBucket>();
689
+ // Fall back to the events only when the index carries nothing for the
690
+ // period — an install that has not folded yet, or a day whose index is
691
+ // missing. Never alongside it: the same request is in both, and counting it
692
+ // twice would double every column.
693
+ if (buckets.length === 0) {
694
+ for (const event of await this.periodEvents(tenantId, period)) {
695
+ const raw = dimensionName(dimension, event);
696
+ if (raw === null || raw.length === 0) continue;
697
+ const name = dimension === "client" ? clientNameFromUserAgent(raw) : raw;
698
+ const bucket = groups.get(name) ?? emptyBreakdown(name);
699
+ bucket.requests += 1;
700
+ bucket.input += event.inputTokens ?? 0;
701
+ bucket.output += event.outputTokens ?? 0;
702
+ if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
703
+ bucket.cached += event.inputTokens ?? 0;
704
+ }
705
+ if (isGatewayError(event.status, event.httpStatus)) bucket.errors += 1;
706
+ if (event.estimatedCostUsd !== null) bucket.cost = (bucket.cost ?? 0) + event.estimatedCostUsd;
707
+ if (event.tokensPerSec !== null) {
708
+ bucket.rateSum += event.tokensPerSec;
709
+ bucket.rateCount += 1;
710
+ }
711
+ groups.set(name, bucket);
712
+ }
713
+ }
714
+ for (const indexed of buckets) {
715
+ const source =
716
+ dimension === "model"
717
+ ? indexed.dimModel
718
+ : dimension === "provider"
719
+ ? indexed.dimProvider
720
+ : dimension === "client"
721
+ ? indexed.dimClient
722
+ : indexed.dimClientIp;
723
+ for (const [raw, entry] of Object.entries(source)) {
724
+ if (raw.length === 0) continue;
725
+ const name = dimension === "client" ? clientNameFromUserAgent(raw) : raw;
726
+ const bucket = groups.get(name) ?? emptyBreakdown(name);
727
+ bucket.requests += entry.requests;
728
+ bucket.input += entry.inputTokens;
729
+ bucket.output += entry.outputTokens;
730
+ bucket.cached += entry.cachedTokens;
731
+ bucket.errors += entry.errors;
732
+ bucket.cost = (bucket.cost ?? 0) + entry.estimatedCostUsd;
733
+ bucket.rateSum += entry.tokensPerSecSum;
734
+ bucket.rateCount += entry.tokensPerSecCount;
735
+ groups.set(name, bucket);
736
+ }
737
+ }
738
+ const mapped = [...groups.values()]
739
+ .map((bucket) => ({
740
+ name: bucket.name,
741
+ requests: bucket.requests,
742
+ input: bucket.input,
743
+ output: bucket.output,
744
+ cached: bucket.cached,
745
+ total: bucket.input + bucket.output,
746
+ errors: bucket.errors,
747
+ costUsd: bucket.cost,
748
+ cacheHitRate: bucket.input > 0 ? (bucket.cached / bucket.input) * 100 : 0,
749
+ avgTokensPerSec: bucket.rateCount > 0 ? bucket.rateSum / bucket.rateCount : 0,
750
+ }))
751
+ .sort((a, b) => b.requests - a.requests)
752
+ .slice(0, 100);
753
+ if (dimension === "client_ip") {
754
+ // Same fail-closed gate as the request list: storage keeps the raw
755
+ // address, only presentation masks it.
756
+ const hideClientIp = await shouldMaskClientIp(this.preferences, tenantId);
757
+ if (!hideClientIp) return { dimension, rows: mapped };
758
+ // Grouping happened on the raw address, so masking can collapse distinct
759
+ // hosts into one display name (`203.0.113.7` and `203.0.113.9` both become
760
+ // `203.0.113.xxx`). Re-aggregate by the masked name: two rows the operator
761
+ // cannot tell apart are one row, and the totals must not be understated.
762
+ const merged = new Map<string, (typeof mapped)[number]>();
763
+ for (const row of mapped) {
764
+ const masked = maskClientIp(row.name);
765
+ const existing = merged.get(masked);
766
+ if (existing === undefined) {
767
+ merged.set(masked, { ...row, name: masked });
768
+ continue;
769
+ }
770
+ const input = existing.input + row.input;
771
+ const output = existing.output + row.output;
772
+ merged.set(masked, {
773
+ ...existing,
774
+ requests: existing.requests + row.requests,
775
+ input,
776
+ output,
777
+ cached: existing.cached + row.cached,
778
+ total: input + output,
779
+ errors: existing.errors + row.errors,
780
+ costUsd:
781
+ existing.costUsd === null && row.costUsd === null
782
+ ? null
783
+ : (existing.costUsd ?? 0) + (row.costUsd ?? 0),
784
+ cacheHitRate: input > 0 ? (existing.cached + row.cached) / input * 100 : 0,
785
+ });
786
+ }
787
+ return {
788
+ dimension,
789
+ rows: [...merged.values()].sort((a, b) => b.requests - a.requests),
790
+ };
791
+ }
792
+ if (dimension !== "key") return { dimension, rows: mapped };
793
+ const ids = [...new Set(mapped.map((row) => row.name))];
794
+ const labels = ids.length > 0 ? await this.apiKeyLabels(tenantId, ids) : new Map<string, string>();
795
+ return {
796
+ dimension,
797
+ rows: mapped.map((row) => {
798
+ const label = labels.get(row.name);
799
+ return label === undefined ? row : { ...row, label };
800
+ }),
801
+ };
802
+ }
803
+
804
+ private async apiKeyLabels(tenantId: string, ids: readonly string[]): Promise<Map<string, string>> {
805
+ const unique = [...new Set(ids)];
806
+ if (unique.length === 0) return new Map<string, string>();
807
+ const keyRows = await this.db
808
+ .select({ id: apiKeys.id, label: apiKeys.label })
809
+ .from(apiKeys)
810
+ .where(and(inArray(apiKeys.id, [...unique]), eq(apiKeys.tenantId, tenantId)));
811
+ return new Map(keyRows.map((keyRow) => [keyRow.id, keyRow.label] as const));
812
+ }
813
+
814
+ async usageCache(tenantId: string, period: string): Promise<CachedResult<UsageCacheResponse>> {
815
+ return this.readThroughWithEtag(this.usageCacheKey(tenantId, period, "cache"), () =>
816
+ this.computeCache(tenantId, period),
817
+ );
818
+ }
819
+
820
+ private async computeCache(tenantId: string, period: string): Promise<UsageCacheResponse> {
821
+ const events = await this.periodEvents(tenantId, period);
822
+ let inputTokens = 0;
823
+ let cachedTokens = 0;
824
+ let outputTokens = 0;
825
+ for (const event of events) {
826
+ inputTokens += event.inputTokens ?? 0;
827
+ outputTokens += event.outputTokens ?? 0;
828
+ if (event.cachedInputTokens !== null && event.cachedInputTokens > 0) {
829
+ cachedTokens += event.inputTokens ?? 0;
830
+ }
831
+ }
832
+ const tokenTotal = inputTokens + outputTokens;
833
+ return {
834
+ period,
835
+ inputTokens,
836
+ cachedTokens,
837
+ cacheWriteTokens: 0,
838
+ hitRate: tokenTotal > 0 ? (cachedTokens / tokenTotal) * 100 : 0,
839
+ };
840
+ }
841
+
842
+ async usageRequests(
843
+ tenantId: string,
844
+ period: string,
845
+ limit: number,
846
+ httpStatus?: number,
847
+ ): Promise<CachedResult<UsageRequestsResponse>> {
848
+ // Ask the reader for `limit` rows instead of folding the whole window and
849
+ // slicing it. It stops at the newest file that fills the page, so the
850
+ // console's most-polled endpoint no longer parses the retention window just
851
+ // to show 50 rows.
852
+ const since = periodStartDate(period);
853
+ const events = await queryTelemetryEvents({
854
+ tenantId,
855
+ limit,
856
+ ...(httpStatus === undefined ? {} : { httpStatus }),
857
+ ...(since === undefined ? {} : { since }),
858
+ });
859
+ const rows = events.slice(0, limit);
860
+ const hideClientIp = await shouldMaskClientIp(this.preferences, tenantId);
861
+ // Cached like its siblings, so a repeated poll costs a `304` instead of
862
+ // another read. The mask flag is part of the key: it is a privacy setting,
863
+ // so a page must never be served from an entry computed under the other
864
+ // value. The rows were already read above — this bounds the label lookup
865
+ // and the mapping, not the telemetry read.
866
+ return this.readThroughWithEtag(
867
+ this.usageCacheKey(
868
+ tenantId,
869
+ `${period}:${limit}:${httpStatus ?? "*"}:${hideClientIp}`,
870
+ "requests",
871
+ ),
872
+ async () => {
873
+ const keyIds = [...new Set(rows.map((row) => row.apiKeyId).filter((id): id is string => id !== null))];
874
+ const labels = await this.apiKeyLabels(tenantId, keyIds);
875
+ // One indexed read for the whole page, not one per row: presence lives
876
+ // on the payload row so the list can render it without inflating a
877
+ // frame for every row it shows.
878
+ const requestIds = [...new Set(rows.map((row) => row.requestId).filter((id): id is string => id !== null))];
879
+ const stages = await this.payloadStages(tenantId, requestIds);
880
+ return {
881
+ items: rows.map((row) => {
882
+ const item = mapUsageRequestItem(row, hideClientIp);
883
+ const label = row.apiKeyId ? labels.get(row.apiKeyId) : undefined;
884
+ const labeled = label === undefined ? item : { ...item, apiKeyLabel: label };
885
+ const stage = stages.get(row.requestId);
886
+ // Omitted rather than zero when the row is unknown: absence means
887
+ // "no payload was captured", and 0 would say the same thing while
888
+ // claiming it had been measured.
889
+ return stage === undefined ? labeled : { ...labeled, payloadStages: stage };
890
+ }),
891
+ };
892
+ },
893
+ );
894
+ }
895
+
896
+ /**
897
+ * Captured-stage bitmask per request, newest payload row winning.
898
+ * One indexed read for a whole page. Presence is on the payload row
899
+ * specifically so the list never has to inflate a frame to answer "does this
900
+ * panel have content".
901
+ */
902
+ private async payloadStages(tenantId: string, requestIds: readonly string[]): Promise<Map<string, number>> {
903
+ if (requestIds.length === 0) return new Map();
904
+ const rows = await this.db
905
+ .select({ requestId: telemetryPayloads.requestId, stages: telemetryPayloads.stages })
906
+ .from(telemetryPayloads)
907
+ .where(
908
+ and(
909
+ eq(telemetryPayloads.tenantId, tenantId),
910
+ inArray(telemetryPayloads.requestId, [...requestIds]),
911
+ ),
912
+ )
913
+ .orderBy(desc(telemetryPayloads.capturedAt));
914
+ const byRequest = new Map<string, number>();
915
+ for (const row of rows) {
916
+ if (row.requestId === null || row.stages === null) continue;
917
+ // Ordered newest-first, so the first hit is the one the drawer reads.
918
+ if (!byRequest.has(row.requestId)) byRequest.set(row.requestId, row.stages);
919
+ }
920
+ return byRequest;
921
+ }
922
+
923
+ async usageRequestDetail(
924
+ tenantId: string,
925
+ requestId: string,
926
+ ): Promise<UsageRequestDetail | undefined> {
927
+ const rows = await queryTelemetryEvents({ tenantId, requestId });
928
+ const event = rows[0];
929
+ if (!event) return undefined;
930
+ // The label, proxy, and mask lookups are independent: run them together
931
+ // instead of three sequential round trips.
932
+ const [hideClientIp, keyLabels, proxy] = await Promise.all([
933
+ shouldMaskClientIp(this.preferences, tenantId),
934
+ event.apiKeyId ? this.apiKeyLabels(tenantId, [event.apiKeyId]) : undefined,
935
+ event.networkPoolId ? resolveProxyLabel(this.db, tenantId, event.networkPoolId) : ("direct" as const),
936
+ ]);
937
+ const item = mapUsageRequestItem(event, hideClientIp);
938
+ const keyLabel = event.apiKeyId ? keyLabels?.get(event.apiKeyId) : undefined;
939
+ const labeled = keyLabel === undefined ? item : { ...item, apiKeyLabel: keyLabel };
940
+ const presented = { ...labeled, proxy };
941
+ // Filter the payload table directly instead of joining `telemetry_events`
942
+ // only to re-apply the tenant predicate: `telemetry_payloads` carries
943
+ // `tenant_id` itself, so the join added a second table's rows to filter for
944
+ // no additional constraint. This matches `getEvent` above and uses
945
+ // `telemetry_payloads_tenant_request_idx`.
946
+ const linkedPayloadRows = await this.db
947
+ .select({ payload: telemetryPayloads })
948
+ .from(telemetryPayloads)
949
+ .where(
950
+ and(
951
+ eq(telemetryPayloads.requestId, requestId),
952
+ eq(telemetryPayloads.tenantId, tenantId),
953
+ ),
954
+ )
955
+ .orderBy(desc(telemetryPayloads.capturedAt))
956
+ .limit(1);
957
+ const payloadRow = linkedPayloadRows[0]?.payload;
958
+ const captured = await readCapturedBodies(payloadRow);
959
+ if (!captured) {
960
+ return { ...presented, payloads: null };
961
+ }
962
+ return {
963
+ ...presented,
964
+ payloads: {
965
+ ...(captured.request !== undefined ? { request: captured.request } : {}),
966
+ ...(captured.response !== undefined ? { response: captured.response } : {}),
967
+ ...(captured.clientResponse !== undefined ? { clientResponse: captured.clientResponse } : {}),
968
+ ...(captured.providerRequest !== undefined
969
+ ? { providerRequest: captured.providerRequest }
970
+ : {}),
971
+ ...(captured.providerResponse !== undefined
972
+ ? { providerResponse: captured.providerResponse }
973
+ : {}),
974
+ },
975
+ ...(captured.signals === undefined ? {} : { payloadSignals: captured.signals }),
976
+ };
977
+ }
978
+
979
+ async listEvents(
980
+ tenantId: string,
981
+ limit: number,
982
+ cursor?: string,
983
+ ): Promise<TelemetryEventListPage> {
984
+ const before = decodeDatedCursor(cursor);
985
+ // The cursor becomes an `until` bound inside the query, so `limit` applies
986
+ // to every page and not only the first. Filtering after the read forced the
987
+ // previous shape: a cursor page had to fetch the whole window to know where
988
+ // it ended, so `limit` was dropped the moment a cursor appeared.
989
+ //
990
+ // `until` is inclusive while the cursor is exclusive, so rows at the exact
991
+ // cursor timestamp are still compared by id here — the cheap part of the
992
+ // predicate, now over one timestamp's worth of rows instead of the window.
993
+ const events = await queryTelemetryEvents({
994
+ tenantId,
995
+ limit: limit + 1,
996
+ ...(before === undefined ? {} : { until: new Date(before.createdAt) }),
997
+ });
998
+ const rows = (before
999
+ ? events.filter((event) => event.createdAt < before.createdAt || (event.createdAt === before.createdAt && event.id < before.id))
1000
+ : events
1001
+ ).slice(0, limit + 1);
1002
+ const overflow = rows.length > limit;
1003
+ const trimmed = overflow ? rows.slice(0, limit) : rows;
1004
+ const entries = trimmed.map(mapTelemetryEventRow);
1005
+ const lastEntry = entries[entries.length - 1];
1006
+ const nextCursor =
1007
+ overflow && lastEntry !== undefined
1008
+ ? encodeCursor({ createdAt: lastEntry.createdAt, id: lastEntry.id })
1009
+ : undefined;
1010
+ return nextCursor ? { entries, nextCursor } : { entries };
1011
+ }
1012
+
1013
+ async getEvent(
1014
+ tenantId: string,
1015
+ eventId: string,
1016
+ includePayload = false,
1017
+ createdAt?: string,
1018
+ ): Promise<TelemetryEventDetail | undefined> {
1019
+ // Matched by `id`, which is the field the list cursor encodes — not by
1020
+ // `requestId`, which is a different column that merely defaults to the
1021
+ // same value. Predicating on it lets the reader stop at the first hit
1022
+ // instead of parsing the retention window to find one row.
1023
+ const events = await queryTelemetryEvents({ tenantId, id: eventId });
1024
+ const event = events.find(
1025
+ (candidate) =>
1026
+ candidate.id === eventId && (createdAt === undefined || candidate.createdAt === createdAt),
1027
+ );
1028
+ if (!event) return undefined;
1029
+ const base = mapTelemetryEventRow(event);
1030
+ if (!includePayload) return base;
1031
+ const payloadRows = await this.db
1032
+ .select()
1033
+ .from(telemetryPayloads)
1034
+ .where(
1035
+ and(
1036
+ eq(telemetryPayloads.requestId, event.requestId),
1037
+ eq(telemetryPayloads.tenantId, tenantId),
1038
+ ),
1039
+ )
1040
+ .orderBy(desc(telemetryPayloads.capturedAt))
1041
+ .limit(1);
1042
+ const payloadRow = payloadRows[0];
1043
+ if (!payloadRow) return base;
1044
+ const captured = await readCapturedBodies(payloadRow);
1045
+ if (!captured) return base;
1046
+ return {
1047
+ ...base,
1048
+ payload: {
1049
+ ...(captured.request !== undefined ? { requestBody: captured.request } : {}),
1050
+ ...(captured.response !== undefined ? { responseBody: captured.response } : {}),
1051
+ },
1052
+ };
1053
+ }
1054
+ }
1055
+
1056
+ /**
1057
+ * Reads the captured bodies behind one `telemetry_payloads` row.
1058
+ *
1059
+ * The row stores typed frame-reference columns only; every body lives in the
1060
+ * frame file those columns name. Returns `undefined` when there is no row, no
1061
+ * usable reference, or the frame cannot be read — all three mean "nothing to
1062
+ * show", and the caller renders the request without a payload section. The raw
1063
+ * reference is never returned to a caller, so it cannot reach the dashboard.
1064
+ */
1065
+ async function readCapturedBodies(
1066
+ row:
1067
+ | {
1068
+ readonly storage: string;
1069
+ readonly file: string;
1070
+ readonly offset: number;
1071
+ readonly length: number;
1072
+ readonly checksum: string;
1073
+ readonly version: number;
1074
+ }
1075
+ | null
1076
+ | undefined,
1077
+ ): Promise<
1078
+ | {
1079
+ request: unknown;
1080
+ response: unknown;
1081
+ clientResponse: unknown;
1082
+ providerRequest: unknown;
1083
+ providerResponse: unknown;
1084
+ signals?: { toolCalls: number; images: number; attachments: number };
1085
+ }
1086
+ | undefined
1087
+ > {
1088
+ if (!row) return undefined;
1089
+ const reference = payloadReferenceFromRow(row);
1090
+ if (!reference) return undefined;
1091
+ try {
1092
+ const stored = await readPayloadFrame(reference);
1093
+ if (!stored || typeof stored !== "object") return undefined;
1094
+ const record = stored as Record<string, unknown>;
1095
+ const bodies = {
1096
+ request: record["request_body"] ?? undefined,
1097
+ response: record["response_body"] ?? undefined,
1098
+ clientResponse: record["client_response_body"] ?? undefined,
1099
+ providerRequest: record["provider_request_body"] ?? undefined,
1100
+ providerResponse: record["provider_response_body"] ?? undefined,
1101
+ };
1102
+ const signals =
1103
+ typeof record["signals"] === "object" &&
1104
+ record["signals"] !== null &&
1105
+ !Array.isArray(record["signals"])
1106
+ ? (record["signals"] as { toolCalls: number; images: number; attachments: number })
1107
+ : undefined;
1108
+ return {
1109
+ ...bodies,
1110
+ ...(signals === undefined ? {} : { signals }),
1111
+ };
1112
+ } catch {
1113
+ return undefined;
1114
+ }
1115
+ }