tapimo 0.2.0 → 0.3.0

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 (313) hide show
  1. package/README.md +31 -3
  2. package/dist/ApiKey.d.ts +1 -0
  3. package/dist/ApiKey.d.ts.map +1 -1
  4. package/dist/ApiKey.js +5 -2
  5. package/dist/ApiKey.js.map +1 -1
  6. package/dist/ApiKeys.d.ts +28 -1
  7. package/dist/ApiKeys.d.ts.map +1 -1
  8. package/dist/ApiKeys.js +34 -1
  9. package/dist/ApiKeys.js.map +1 -1
  10. package/dist/App.d.ts +7167 -4507
  11. package/dist/App.d.ts.map +1 -1
  12. package/dist/App.js +8 -0
  13. package/dist/App.js.map +1 -1
  14. package/dist/Scope.d.ts +11 -2
  15. package/dist/Scope.d.ts.map +1 -1
  16. package/dist/Scope.js +14 -3
  17. package/dist/Scope.js.map +1 -1
  18. package/dist/admin/App.d.ts +128 -118
  19. package/dist/admin/App.d.ts.map +1 -1
  20. package/dist/admin/apps/api-keys.d.ts +7 -0
  21. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  22. package/dist/admin/apps/scopes.d.ts +8 -0
  23. package/dist/admin/apps/scopes.d.ts.map +1 -1
  24. package/dist/admin/apps/scopes.js +3 -0
  25. package/dist/admin/apps/scopes.js.map +1 -1
  26. package/dist/admin/apps/verified-tokens.d.ts +57 -57
  27. package/dist/admin/ui.generated.d.ts +1 -1
  28. package/dist/admin/ui.generated.js +13 -13
  29. package/dist/admin/ui.generated.js.map +1 -1
  30. package/dist/analytics/Analytics.d.ts +151 -0
  31. package/dist/analytics/Analytics.d.ts.map +1 -0
  32. package/dist/analytics/Analytics.js +212 -0
  33. package/dist/analytics/Analytics.js.map +1 -0
  34. package/dist/analytics/tables/requestEvents.d.ts +215 -0
  35. package/dist/analytics/tables/requestEvents.d.ts.map +1 -0
  36. package/dist/analytics/tables/requestEvents.js +264 -0
  37. package/dist/analytics/tables/requestEvents.js.map +1 -0
  38. package/dist/apps/data/App.d.ts +1024 -624
  39. package/dist/apps/data/App.d.ts.map +1 -1
  40. package/dist/apps/data/App.js +36 -0
  41. package/dist/apps/data/App.js.map +1 -1
  42. package/dist/apps/data/routes/balances.d.ts +45 -45
  43. package/dist/apps/data/routes/coingecko.d.ts +176 -176
  44. package/dist/apps/data/routes/exchanges.d.ts +455 -455
  45. package/dist/apps/data/routes/fee-amm.d.ts +101 -101
  46. package/dist/apps/data/routes/funding.d.ts +765 -0
  47. package/dist/apps/data/routes/funding.d.ts.map +1 -0
  48. package/dist/apps/data/routes/funding.js +383 -0
  49. package/dist/apps/data/routes/funding.js.map +1 -0
  50. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  51. package/dist/apps/data/routes/receipts.js +2 -8
  52. package/dist/apps/data/routes/receipts.js.map +1 -1
  53. package/dist/apps/data/routes/rpc.d.ts +29 -36
  54. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  55. package/dist/apps/data/routes/rpc.js +29 -1
  56. package/dist/apps/data/routes/rpc.js.map +1 -1
  57. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  58. package/dist/apps/data/routes/transfers.d.ts +56 -56
  59. package/dist/apps/data/routes/verified-tokens.d.ts +36 -36
  60. package/dist/apps/data/routes/webhooks.d.ts +45 -45
  61. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  62. package/dist/apps/data/routes/webhooks.js +8 -8
  63. package/dist/apps/data/routes/webhooks.js.map +1 -1
  64. package/dist/apps/docs/App.d.ts +4 -2
  65. package/dist/apps/docs/App.d.ts.map +1 -1
  66. package/dist/apps/docs/App.js +27 -95
  67. package/dist/apps/docs/App.js.map +1 -1
  68. package/dist/apps/docs/reference.d.ts +21 -0
  69. package/dist/apps/docs/reference.d.ts.map +1 -0
  70. package/dist/apps/docs/reference.js +112 -0
  71. package/dist/apps/docs/reference.js.map +1 -0
  72. package/dist/apps/index.d.ts +1 -0
  73. package/dist/apps/index.d.ts.map +1 -1
  74. package/dist/apps/index.js +1 -0
  75. package/dist/apps/index.js.map +1 -1
  76. package/dist/apps/mcp.d.ts +76 -0
  77. package/dist/apps/mcp.d.ts.map +1 -0
  78. package/dist/apps/mcp.js +188 -0
  79. package/dist/apps/mcp.js.map +1 -0
  80. package/dist/apps/platform/App.d.ts +3005 -728
  81. package/dist/apps/platform/App.d.ts.map +1 -1
  82. package/dist/apps/platform/App.js +13 -3
  83. package/dist/apps/platform/App.js.map +1 -1
  84. package/dist/apps/platform/Billing.d.ts +279 -0
  85. package/dist/apps/platform/Billing.d.ts.map +1 -0
  86. package/dist/apps/platform/Billing.js +477 -0
  87. package/dist/apps/platform/Billing.js.map +1 -0
  88. package/dist/apps/platform/index.d.ts +1 -0
  89. package/dist/apps/platform/index.d.ts.map +1 -1
  90. package/dist/apps/platform/index.js +1 -0
  91. package/dist/apps/platform/index.js.map +1 -1
  92. package/dist/apps/platform/routes/api-keys.d.ts +341 -49
  93. package/dist/apps/platform/routes/api-keys.d.ts.map +1 -1
  94. package/dist/apps/platform/routes/api-keys.js +115 -13
  95. package/dist/apps/platform/routes/api-keys.js.map +1 -1
  96. package/dist/apps/platform/routes/billing.d.ts +1520 -0
  97. package/dist/apps/platform/routes/billing.d.ts.map +1 -0
  98. package/dist/apps/platform/routes/billing.js +795 -0
  99. package/dist/apps/platform/routes/billing.js.map +1 -0
  100. package/dist/apps/platform/routes/invitations.d.ts +201 -83
  101. package/dist/apps/platform/routes/invitations.d.ts.map +1 -1
  102. package/dist/apps/platform/routes/invitations.js +58 -0
  103. package/dist/apps/platform/routes/invitations.js.map +1 -1
  104. package/dist/apps/platform/routes/me.d.ts +17 -17
  105. package/dist/apps/platform/routes/orgs.d.ts +50 -36
  106. package/dist/apps/platform/routes/orgs.d.ts.map +1 -1
  107. package/dist/apps/platform/routes/orgs.js +14 -3
  108. package/dist/apps/platform/routes/orgs.js.map +1 -1
  109. package/dist/apps/platform/routes/projects.d.ts +97 -97
  110. package/dist/apps/platform/routes/scopes.d.ts +107 -0
  111. package/dist/apps/platform/routes/scopes.d.ts.map +1 -0
  112. package/dist/apps/platform/routes/scopes.js +66 -0
  113. package/dist/apps/platform/routes/scopes.js.map +1 -0
  114. package/dist/apps/platform/routes/usage.d.ts +588 -0
  115. package/dist/apps/platform/routes/usage.d.ts.map +1 -0
  116. package/dist/apps/platform/routes/usage.js +391 -0
  117. package/dist/apps/platform/routes/usage.js.map +1 -0
  118. package/dist/apps/relay/App.d.ts +4 -1
  119. package/dist/apps/relay/App.d.ts.map +1 -1
  120. package/dist/apps/relay/App.js +118 -10
  121. package/dist/apps/relay/App.js.map +1 -1
  122. package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
  123. package/dist/apps/relay/Sponsorships.js +2 -1
  124. package/dist/apps/relay/Sponsorships.js.map +1 -1
  125. package/dist/cli/Cli.d.ts +8 -1
  126. package/dist/cli/Cli.d.ts.map +1 -1
  127. package/dist/cli/Cli.js +11 -3
  128. package/dist/cli/Cli.js.map +1 -1
  129. package/dist/cli/commands/admin.d.ts +4 -0
  130. package/dist/cli/commands/admin.d.ts.map +1 -1
  131. package/dist/cli/commands/admin.js +80 -1
  132. package/dist/cli/commands/admin.js.map +1 -1
  133. package/dist/db/Db.d.ts +7 -0
  134. package/dist/db/Db.d.ts.map +1 -1
  135. package/dist/db/Db.js +126 -0
  136. package/dist/db/Db.js.map +1 -1
  137. package/dist/db/Schema.d.ts +79 -0
  138. package/dist/db/Schema.d.ts.map +1 -1
  139. package/dist/db/Schema.js +88 -2
  140. package/dist/db/Schema.js.map +1 -1
  141. package/dist/db/tables/billingSettings.d.ts +55 -0
  142. package/dist/db/tables/billingSettings.d.ts.map +1 -0
  143. package/dist/db/tables/billingSettings.js +70 -0
  144. package/dist/db/tables/billingSettings.js.map +1 -0
  145. package/dist/db/tables/invitations.js +2 -2
  146. package/dist/db/tables/invitations.js.map +1 -1
  147. package/dist/db/tables/organizations.d.ts +6 -2
  148. package/dist/db/tables/organizations.d.ts.map +1 -1
  149. package/dist/db/tables/organizations.js +10 -4
  150. package/dist/db/tables/organizations.js.map +1 -1
  151. package/dist/db/tables/projects.js +2 -2
  152. package/dist/db/tables/projects.js.map +1 -1
  153. package/dist/db/tables/requestUsage.d.ts +121 -0
  154. package/dist/db/tables/requestUsage.d.ts.map +1 -0
  155. package/dist/db/tables/requestUsage.js +150 -0
  156. package/dist/db/tables/requestUsage.js.map +1 -0
  157. package/dist/db/tables/sponsoredTransactions.d.ts +119 -0
  158. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  159. package/dist/db/tables/sponsoredTransactions.js +155 -2
  160. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  161. package/dist/db/tables/stripeCustomers.d.ts +81 -0
  162. package/dist/db/tables/stripeCustomers.d.ts.map +1 -0
  163. package/dist/db/tables/stripeCustomers.js +120 -0
  164. package/dist/db/tables/stripeCustomers.js.map +1 -0
  165. package/dist/db/tables/users.js +2 -2
  166. package/dist/db/tables/users.js.map +1 -1
  167. package/dist/handlers/internal/sponsorship.d.ts +5 -2
  168. package/dist/handlers/internal/sponsorship.d.ts.map +1 -1
  169. package/dist/handlers/internal/sponsorship.js +19 -5
  170. package/dist/handlers/internal/sponsorship.js.map +1 -1
  171. package/dist/handlers/internal/utils.d.ts +10 -3
  172. package/dist/handlers/internal/utils.d.ts.map +1 -1
  173. package/dist/handlers/internal/utils.js +14 -6
  174. package/dist/handlers/internal/utils.js.map +1 -1
  175. package/dist/index.d.ts +1 -0
  176. package/dist/index.d.ts.map +1 -1
  177. package/dist/index.js +1 -0
  178. package/dist/index.js.map +1 -1
  179. package/dist/internal/Auth.d.ts +18 -4
  180. package/dist/internal/Auth.d.ts.map +1 -1
  181. package/dist/internal/Auth.js +148 -76
  182. package/dist/internal/Auth.js.map +1 -1
  183. package/dist/internal/Cache.d.ts.map +1 -1
  184. package/dist/internal/Cache.js +4 -1
  185. package/dist/internal/Cache.js.map +1 -1
  186. package/dist/internal/EdgeCache.d.ts +10 -0
  187. package/dist/internal/EdgeCache.d.ts.map +1 -1
  188. package/dist/internal/EdgeCache.js +39 -8
  189. package/dist/internal/EdgeCache.js.map +1 -1
  190. package/dist/internal/Fees.d.ts +27 -0
  191. package/dist/internal/Fees.d.ts.map +1 -0
  192. package/dist/internal/Fees.js +48 -0
  193. package/dist/internal/Fees.js.map +1 -0
  194. package/dist/internal/FundingRoutes.d.ts +183 -0
  195. package/dist/internal/FundingRoutes.d.ts.map +1 -0
  196. package/dist/internal/FundingRoutes.js +490 -0
  197. package/dist/internal/FundingRoutes.js.map +1 -0
  198. package/dist/internal/Id.d.ts +15 -0
  199. package/dist/internal/Id.d.ts.map +1 -0
  200. package/dist/internal/Id.js +22 -0
  201. package/dist/internal/Id.js.map +1 -0
  202. package/dist/internal/Log.d.ts +2 -0
  203. package/dist/internal/Log.d.ts.map +1 -1
  204. package/dist/internal/Log.js +3 -0
  205. package/dist/internal/Log.js.map +1 -1
  206. package/dist/internal/OpenApi.d.ts +10 -1
  207. package/dist/internal/OpenApi.d.ts.map +1 -1
  208. package/dist/internal/OpenApi.js +8 -1
  209. package/dist/internal/OpenApi.js.map +1 -1
  210. package/dist/internal/RateLimit.d.ts +25 -0
  211. package/dist/internal/RateLimit.d.ts.map +1 -1
  212. package/dist/internal/RateLimit.js +51 -0
  213. package/dist/internal/RateLimit.js.map +1 -1
  214. package/dist/internal/Response.d.ts +1 -1
  215. package/dist/internal/Response.d.ts.map +1 -1
  216. package/dist/internal/Schema.d.ts +8 -0
  217. package/dist/internal/Schema.d.ts.map +1 -1
  218. package/dist/internal/Schema.js +7 -0
  219. package/dist/internal/Schema.js.map +1 -1
  220. package/dist/internal/Webhooks.d.ts.map +1 -1
  221. package/dist/internal/Webhooks.js +3 -3
  222. package/dist/internal/Webhooks.js.map +1 -1
  223. package/package.json +4 -3
  224. package/src/ApiKey.ts +9 -2
  225. package/src/ApiKeys.test.ts +51 -0
  226. package/src/ApiKeys.ts +50 -1
  227. package/src/App.test.ts +33 -86718
  228. package/src/App.ts +18 -0
  229. package/src/Client.test-d.ts +351 -0
  230. package/src/Scope.ts +16 -3
  231. package/src/admin/apps/scopes.test.ts +6 -0
  232. package/src/admin/apps/scopes.ts +3 -0
  233. package/src/admin/ui/src/components/Page.tsx +2 -3
  234. package/src/admin/ui/src/routes/__root.tsx +2 -2
  235. package/src/admin/ui/src/routes/api-keys.tsx +2 -2
  236. package/src/admin/ui.generated.ts +13 -13
  237. package/src/analytics/Analytics.test.ts +527 -0
  238. package/src/analytics/Analytics.ts +318 -0
  239. package/src/analytics/tables/requestEvents.ts +523 -0
  240. package/src/apps/data/App.test.ts +72 -0
  241. package/src/apps/data/App.ts +35 -0
  242. package/src/apps/data/routes/funding.test.ts +214 -0
  243. package/src/apps/data/routes/funding.ts +584 -0
  244. package/src/apps/data/routes/receipts.ts +2 -9
  245. package/src/apps/data/routes/rpc.test.ts +78 -2
  246. package/src/apps/data/routes/rpc.ts +31 -1
  247. package/src/apps/data/routes/webhooks.test.ts +1 -1
  248. package/src/apps/data/routes/webhooks.ts +11 -8
  249. package/src/apps/docs/App.ts +29 -100
  250. package/src/apps/docs/reference.ts +132 -0
  251. package/src/apps/index.ts +1 -0
  252. package/src/apps/mcp.test.ts +206 -0
  253. package/src/apps/mcp.ts +250 -0
  254. package/src/apps/platform/App.ts +43 -4
  255. package/src/apps/platform/Billing.test.ts +530 -0
  256. package/src/apps/platform/Billing.ts +682 -0
  257. package/src/apps/platform/index.ts +1 -0
  258. package/src/apps/platform/routes/api-keys.test.ts +172 -9
  259. package/src/apps/platform/routes/api-keys.ts +136 -20
  260. package/src/apps/platform/routes/billing.test.ts +1100 -0
  261. package/src/apps/platform/routes/billing.ts +966 -0
  262. package/src/apps/platform/routes/invitations.test.ts +55 -7
  263. package/src/apps/platform/routes/invitations.ts +68 -0
  264. package/src/apps/platform/routes/me.test.ts +230 -0
  265. package/src/apps/platform/routes/orgs.ts +26 -4
  266. package/src/apps/platform/routes/scopes.test.ts +29 -0
  267. package/src/apps/platform/routes/scopes.ts +85 -0
  268. package/src/apps/platform/routes/usage.test.ts +422 -0
  269. package/src/apps/platform/routes/usage.ts +527 -0
  270. package/src/apps/relay/App.test.ts +457 -8
  271. package/src/apps/relay/App.ts +120 -12
  272. package/src/apps/relay/Sponsorships.test.ts +1 -1
  273. package/src/apps/relay/Sponsorships.ts +2 -1
  274. package/src/cli/Cli.test.ts +25 -2
  275. package/src/cli/Cli.ts +17 -2
  276. package/src/cli/commands/admin.test.ts +83 -0
  277. package/src/cli/commands/admin.ts +89 -1
  278. package/src/db/Db.ts +137 -0
  279. package/src/db/Schema.ts +104 -2
  280. package/src/db/tables/billingSettings.test.ts +87 -0
  281. package/src/db/tables/billingSettings.ts +108 -0
  282. package/src/db/tables/invitations.ts +2 -2
  283. package/src/db/tables/organizations.test.ts +1 -1
  284. package/src/db/tables/organizations.ts +15 -5
  285. package/src/db/tables/projects.ts +2 -2
  286. package/src/db/tables/requestUsage.ts +231 -0
  287. package/src/db/tables/sponsoredTransactions.test.ts +108 -1
  288. package/src/db/tables/sponsoredTransactions.ts +230 -2
  289. package/src/db/tables/stripeCustomers.test.ts +109 -0
  290. package/src/db/tables/stripeCustomers.ts +162 -0
  291. package/src/db/tables/users.ts +2 -2
  292. package/src/handlers/internal/sponsorship.ts +34 -7
  293. package/src/handlers/internal/utils.ts +19 -7
  294. package/src/index.ts +1 -0
  295. package/src/internal/Auth.test.ts +175 -25
  296. package/src/internal/Auth.ts +175 -86
  297. package/src/internal/Cache.ts +5 -1
  298. package/src/internal/EdgeCache.test.ts +27 -4
  299. package/src/internal/EdgeCache.ts +47 -9
  300. package/src/internal/Fees.test.ts +32 -0
  301. package/src/internal/Fees.ts +51 -0
  302. package/src/internal/FundingRoutes.ts +583 -0
  303. package/src/internal/Id.test.ts +17 -0
  304. package/src/internal/Id.ts +24 -0
  305. package/src/internal/Log.test.ts +1 -0
  306. package/src/internal/Log.ts +5 -0
  307. package/src/internal/OpenApi.ts +12 -1
  308. package/src/internal/RateLimit.test.ts +84 -0
  309. package/src/internal/RateLimit.ts +70 -0
  310. package/src/internal/Response.ts +1 -1
  311. package/src/internal/Schema.ts +8 -0
  312. package/src/internal/Webhooks.test.ts +3 -3
  313. package/src/internal/Webhooks.ts +3 -3
@@ -0,0 +1,527 @@
1
+ import type * as Log from '../internal/Log.js'
2
+ import * as Analytics from './Analytics.js'
3
+ import * as RequestEvents from './tables/requestEvents.js'
4
+
5
+ const context = { enabled: true, environment: 'test', service: 'api' }
6
+
7
+ const entry = {
8
+ duration: 12.3,
9
+ level: 'info',
10
+ method: 'GET',
11
+ path: '/v1/transactions',
12
+ requestId: 'req-1',
13
+ route: '/v1/transactions',
14
+ status: 200,
15
+ } satisfies Log.Entry
16
+
17
+ const clickHouse = {
18
+ database: 'tempo_api',
19
+ password: 'secret',
20
+ url: 'https://clickhouse.test',
21
+ user: 'reader',
22
+ }
23
+
24
+ /** Baseline stored row for insert/queue tests. */
25
+ const row = {
26
+ billing_active: null,
27
+ duration_ms: 12.3,
28
+ environment: 'test',
29
+ error_code: null,
30
+ key_environment: null,
31
+ key_id: null,
32
+ method: 'GET',
33
+ org_id: null,
34
+ project_id: null,
35
+ principal_type: 'unknown',
36
+ rate_limit_scope: null,
37
+ request_id: 'req-1',
38
+ route: '/v1/transactions',
39
+ service: 'api',
40
+ status: 200,
41
+ timestamp: '2026-01-01 00:00:00.000',
42
+ } satisfies Analytics.Event
43
+
44
+ afterEach(() => vi.unstubAllGlobals())
45
+
46
+ describe('clickhouse', () => {
47
+ test('insert posts NDJSON rows scoped to the database', async () => {
48
+ const fetch = vi
49
+ .fn<typeof globalThis.fetch>()
50
+ .mockResolvedValue(new Response('', { status: 200 }))
51
+ vi.stubGlobal('fetch', fetch)
52
+
53
+ await Analytics.clickhouse(clickHouse).insert('request_events', [row])
54
+
55
+ const url = new URL(String(fetch.mock.calls[0]![0]))
56
+ expect(url.searchParams.get('database')).toBe('tempo_api')
57
+ expect(url.searchParams.get('query')).toBe('INSERT INTO `request_events` FORMAT JSONEachRow')
58
+ const init = fetch.mock.calls[0]![1]
59
+ expect(init?.body).toBe(`${JSON.stringify(row)}\n`)
60
+ expect(init?.headers).toMatchObject({
61
+ Authorization: `Basic ${btoa('reader:secret')}`,
62
+ 'Content-Type': 'application/x-ndjson',
63
+ })
64
+ })
65
+
66
+ test('insert skips empty batches', async () => {
67
+ const fetch = vi.fn<typeof globalThis.fetch>()
68
+ vi.stubGlobal('fetch', fetch)
69
+
70
+ await Analytics.clickhouse(clickHouse).insert('request_events', [])
71
+
72
+ expect(fetch).not.toHaveBeenCalled()
73
+ })
74
+
75
+ test('insert throws InsertError on a non-2xx response', async () => {
76
+ vi.stubGlobal(
77
+ 'fetch',
78
+ vi
79
+ .fn<typeof globalThis.fetch>()
80
+ .mockResolvedValue(new Response('table missing', { status: 404 })),
81
+ )
82
+
83
+ await expect(
84
+ Analytics.clickhouse(clickHouse).insert('request_events', [row]),
85
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
86
+ `[Analytics.InsertError: ClickHouse insert failed with status 404: table missing]`,
87
+ )
88
+ })
89
+
90
+ test('query posts the statement in the body and returns rows', async () => {
91
+ const fetch = vi
92
+ .fn<typeof globalThis.fetch>()
93
+ .mockResolvedValue(new Response(JSON.stringify({ data: [{ requests: 1 }] }), { status: 200 })) // prettier-ignore
94
+ vi.stubGlobal('fetch', fetch)
95
+
96
+ const rows = await Analytics.clickhouse(clickHouse).query('SELECT count() AS requests')
97
+
98
+ expect(rows).toEqual([{ requests: 1 }])
99
+ const url = new URL(String(fetch.mock.calls[0]![0]))
100
+ expect(url.searchParams.get('database')).toBe('tempo_api')
101
+ expect(url.searchParams.get('query')).toBeNull()
102
+ expect(String(fetch.mock.calls[0]![1]?.body)).toBe('SELECT count() AS requests FORMAT JSON')
103
+ })
104
+
105
+ test('query throws QueryError on a non-2xx response', async () => {
106
+ vi.stubGlobal(
107
+ 'fetch',
108
+ vi
109
+ .fn<typeof globalThis.fetch>()
110
+ .mockResolvedValue(new Response('syntax error', { status: 400 })),
111
+ )
112
+
113
+ await expect(
114
+ Analytics.clickhouse(clickHouse).query('SELECT'),
115
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
116
+ `[Analytics.QueryError: ClickHouse query failed with status 400: syntax error]`,
117
+ )
118
+ })
119
+
120
+ test('rejects invalid table identifiers', async () => {
121
+ vi.stubGlobal('fetch', vi.fn<typeof globalThis.fetch>())
122
+
123
+ await expect(
124
+ Analytics.clickhouse(clickHouse).insert('bad`name' as 'request_events', [row]),
125
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
126
+ `[Analytics.ConfigError: Invalid ClickHouse identifier: bad\`name]`,
127
+ )
128
+ })
129
+
130
+ test('migrate applies every migration when guards are unsatisfied', async () => {
131
+ // Guards report their columns missing, so every migration's DDL applies.
132
+ const fetch = vi.fn<typeof globalThis.fetch>().mockImplementation(async (_url, init) => {
133
+ if (String(init?.body).includes('system.columns'))
134
+ return new Response(JSON.stringify({ data: [{ applied: 0 }] }), { status: 200 })
135
+ return new Response('', { status: 200 })
136
+ })
137
+ vi.stubGlobal('fetch', fetch)
138
+
139
+ await Analytics.clickhouse(clickHouse).migrate()
140
+
141
+ const url = new URL(String(fetch.mock.calls[0]![0]))
142
+ expect(url.searchParams.get('database')).toBe('tempo_api')
143
+ const bodies = fetch.mock.calls.map((call) => String(call[1]?.body))
144
+ for (const migration of Analytics.migrations) expect(bodies).toContain(migration.sql)
145
+ })
146
+
147
+ test('migrate skips a guarded migration when its guard is satisfied', async () => {
148
+ // Guards report their columns present (fresh DB from the baseline CREATE),
149
+ // so the guarded ALTER is skipped, sparing a scoped user the ALTER grant.
150
+ const fetch = vi.fn<typeof globalThis.fetch>().mockImplementation(async (_url, init) => {
151
+ if (String(init?.body).includes('system.columns'))
152
+ return new Response(JSON.stringify({ data: [{ applied: 1 }] }), { status: 200 })
153
+ return new Response('', { status: 200 })
154
+ })
155
+ vi.stubGlobal('fetch', fetch)
156
+
157
+ await Analytics.clickhouse(clickHouse).migrate()
158
+
159
+ const bodies = fetch.mock.calls.map((call) => String(call[1]?.body))
160
+ const guarded = Analytics.migrations.find((migration) => migration.guard)!
161
+ expect(bodies).not.toContain(guarded.sql)
162
+ })
163
+
164
+ test('migrate throws MigrateError on a non-2xx response', async () => {
165
+ vi.stubGlobal(
166
+ 'fetch',
167
+ vi.fn<typeof globalThis.fetch>().mockResolvedValue(new Response('denied', { status: 403 })),
168
+ )
169
+
170
+ await expect(Analytics.clickhouse(clickHouse).migrate()).rejects.toThrow(Analytics.MigrateError)
171
+ })
172
+ })
173
+
174
+ describe('migrations', () => {
175
+ test('the migrations declare every request_events column', () => {
176
+ // The baseline CREATE declares every column; later ALTERs re-patch tables
177
+ // that predate a column (e.g. billing columns for existing production).
178
+ const sql = Analytics.migrations.map((migration) => migration.sql).join('\n')
179
+ for (const column of Object.keys(row)) expect(sql).toContain(column)
180
+ })
181
+ })
182
+
183
+ describe('get', () => {
184
+ test('resolves factories and passes stores through', () => {
185
+ const store = Analytics.clickhouse(clickHouse)
186
+ expect(Analytics.get(store)).toBe(store)
187
+ expect(Analytics.get(() => store)).toBe(store)
188
+ })
189
+ })
190
+
191
+ describe('createQueueSink', () => {
192
+ test('enqueues one row per entry', async () => {
193
+ const send = vi.fn<(message: Analytics.Event) => Promise<void>>().mockResolvedValue(undefined)
194
+ const sink = Analytics.createQueueSink({ send })(context)
195
+
196
+ await sink(entry)
197
+
198
+ expect(send).toHaveBeenCalledTimes(1)
199
+ const row = send.mock.calls[0]![0]
200
+ expect({
201
+ billing_active: row.billing_active,
202
+ duration_ms: row.duration_ms,
203
+ environment: row.environment,
204
+ error_code: row.error_code,
205
+ key_environment: row.key_environment,
206
+ key_id: row.key_id,
207
+ method: row.method,
208
+ org_id: row.org_id,
209
+ principal_type: row.principal_type,
210
+ project_id: row.project_id,
211
+ rate_limit_scope: row.rate_limit_scope,
212
+ request_id: row.request_id,
213
+ route: row.route,
214
+ service: row.service,
215
+ status: row.status,
216
+ timestamp: '<timestamp>',
217
+ }).toMatchInlineSnapshot(`
218
+ {
219
+ "billing_active": null,
220
+ "duration_ms": 12.3,
221
+ "environment": "test",
222
+ "error_code": null,
223
+ "key_environment": null,
224
+ "key_id": null,
225
+ "method": "GET",
226
+ "org_id": null,
227
+ "principal_type": "unknown",
228
+ "project_id": null,
229
+ "rate_limit_scope": null,
230
+ "request_id": "req-1",
231
+ "route": "/v1/transactions",
232
+ "service": "api",
233
+ "status": 200,
234
+ "timestamp": "<timestamp>",
235
+ }
236
+ `)
237
+ })
238
+
239
+ test('retries a failed send once with the same row', async () => {
240
+ const send = vi
241
+ .fn<(message: Analytics.Event) => Promise<void>>()
242
+ .mockRejectedValueOnce(new Error('Unknown Internal Error (15000)'))
243
+ .mockResolvedValueOnce(undefined)
244
+ const sink = Analytics.createQueueSink({ send })(context)
245
+
246
+ await sink(entry)
247
+
248
+ expect(send).toHaveBeenCalledTimes(2)
249
+ expect(send.mock.calls[1]![0]).toBe(send.mock.calls[0]![0])
250
+ })
251
+
252
+ test('records API-key attribution for API-key principals', async () => {
253
+ const send = vi.fn<(message: Analytics.Event) => Promise<void>>().mockResolvedValue(undefined)
254
+ const sink = Analytics.createQueueSink({ send })(context)
255
+
256
+ await sink({
257
+ ...entry,
258
+ principal: {
259
+ billingActive: true,
260
+ environment: 'production',
261
+ id: 'key_1',
262
+ orgId: 'org_1',
263
+ projectId: 'prj_1',
264
+ type: 'api_key',
265
+ },
266
+ })
267
+
268
+ expect(send.mock.calls[0]![0]).toMatchObject({
269
+ billing_active: 1,
270
+ key_environment: 'production',
271
+ project_id: 'prj_1',
272
+ })
273
+ })
274
+
275
+ test('rethrows when the retry also fails', async () => {
276
+ const send = vi
277
+ .fn<(message: Analytics.Event) => Promise<void>>()
278
+ .mockRejectedValue(new Error('Unknown Internal Error (15000)'))
279
+ const sink = Analytics.createQueueSink({ send })(context)
280
+
281
+ await expect(sink(entry)).rejects.toThrowErrorMatchingInlineSnapshot(
282
+ `[Error: Unknown Internal Error (15000)]`,
283
+ )
284
+ expect(send).toHaveBeenCalledTimes(2)
285
+ })
286
+
287
+ test('drops entries when disabled', async () => {
288
+ const send = vi.fn<(message: Analytics.Event) => Promise<void>>()
289
+ const sink = Analytics.createQueueSink({ send })({ ...context, enabled: false })
290
+
291
+ await sink(entry)
292
+
293
+ expect(send).not.toHaveBeenCalled()
294
+ })
295
+ })
296
+
297
+ describe('handleQueue', () => {
298
+ /** Fake store and one queued message with ack/retry spies. */
299
+ function fixture() {
300
+ const insert = vi.fn((_table: 'request_events', _rows: readonly Analytics.Event[]) =>
301
+ Promise.resolve(),
302
+ )
303
+ const store = { insert, migrate: () => Promise.resolve(), query: () => Promise.resolve([]) }
304
+ const message = { ack: vi.fn(), body: row, retry: vi.fn() }
305
+ return { insert, message, store }
306
+ }
307
+
308
+ test('ignores batches from other queues', async () => {
309
+ const { insert, message, store } = fixture()
310
+
311
+ const handled = await Analytics.handleQueue(store, {
312
+ messages: [message],
313
+ queue: 'webhook-deliveries',
314
+ })
315
+
316
+ expect(handled).toBe(false)
317
+ expect(insert).not.toHaveBeenCalled()
318
+ expect(message.ack).not.toHaveBeenCalled()
319
+ })
320
+
321
+ test('matches an overridden queue name instead of the default', async () => {
322
+ const { insert, message, store } = fixture()
323
+
324
+ const handled = await Analytics.handleQueue(
325
+ store,
326
+ { messages: [message], queue: 'api-pr-7-request-analytics' },
327
+ { queue: 'api-pr-7-request-analytics' },
328
+ )
329
+
330
+ expect(handled).toBe(true)
331
+ expect(insert).toHaveBeenCalledWith('request_events', [row])
332
+
333
+ const defaultNamed = await Analytics.handleQueue(
334
+ store,
335
+ { messages: [message], queue: Analytics.queueName },
336
+ { queue: 'api-pr-7-request-analytics' },
337
+ )
338
+
339
+ expect(defaultNamed).toBe(false)
340
+ expect(insert).toHaveBeenCalledTimes(1)
341
+ })
342
+
343
+ test('inserts the batch and acks each message', async () => {
344
+ const { insert, message, store } = fixture()
345
+
346
+ const handled = await Analytics.handleQueue(store, {
347
+ messages: [message],
348
+ queue: Analytics.queueName,
349
+ })
350
+
351
+ expect(handled).toBe(true)
352
+ expect(insert).toHaveBeenCalledWith('request_events', [row])
353
+ expect(message.ack).toHaveBeenCalledTimes(1)
354
+ expect(message.retry).not.toHaveBeenCalled()
355
+ })
356
+
357
+ test('retries every message when the insert fails', async () => {
358
+ vi.spyOn(console, 'error').mockImplementation(() => {})
359
+ const { insert, message, store } = fixture()
360
+ insert.mockRejectedValueOnce(new Error('clickhouse down'))
361
+
362
+ const handled = await Analytics.handleQueue(store, {
363
+ messages: [message],
364
+ queue: Analytics.queueName,
365
+ })
366
+
367
+ expect(handled).toBe(true)
368
+ expect(message.ack).not.toHaveBeenCalled()
369
+ expect(message.retry).toHaveBeenCalledTimes(1)
370
+ })
371
+
372
+ test('resolves factory sources at the leaf', async () => {
373
+ const { insert, message, store } = fixture()
374
+
375
+ await Analytics.handleQueue(() => store, { messages: [message], queue: Analytics.queueName })
376
+
377
+ expect(insert).toHaveBeenCalledTimes(1)
378
+ })
379
+ })
380
+
381
+ describe('readProjectUsage', () => {
382
+ test('scopes attributed project rows without current key inventory', async () => {
383
+ const fetch = vi
384
+ .fn<typeof globalThis.fetch>()
385
+ .mockImplementation(async () => new Response(JSON.stringify({ data: [] }), { status: 200 }))
386
+ vi.stubGlobal('fetch', fetch)
387
+
388
+ const usage = await RequestEvents.readProjectUsage(Analytics.clickhouse(clickHouse), {
389
+ attributions: [{ environment: 'sandbox', projectId: 'prj_1' }],
390
+ from: '2026-01-01T00:00:00.000Z',
391
+ interval: 'day',
392
+ orgId: 'org_1',
393
+ to: '2026-01-02T00:00:00.000Z',
394
+ })
395
+
396
+ const query = String(fetch.mock.calls[0]![1]?.body)
397
+ expect(query).toContain(`org_id = 'org_1'`)
398
+ expect(query).toContain(`project_id = 'prj_1'`)
399
+ expect(query).toContain(`key_environment = 'sandbox'`)
400
+ expect(query).not.toContain('key_id IN')
401
+ expect(query).toContain('LIMIT 100')
402
+ expect(new URL(String(fetch.mock.calls[0]![0])).searchParams.get('query')).toBeNull()
403
+ expect(usage).toMatchObject({
404
+ from: '2026-01-01T00:00:00.000Z',
405
+ interval: 'day',
406
+ to: '2026-01-02T00:00:00.000Z',
407
+ totals: { averageDurationMs: 0, errors: 0, requests: 0 },
408
+ })
409
+ })
410
+
411
+ test('keeps legacy rows scoped to project keys without global org filtering', async () => {
412
+ const fetch = vi
413
+ .fn<typeof globalThis.fetch>()
414
+ .mockImplementation(async () => new Response(JSON.stringify({ data: [] }), { status: 200 }))
415
+ vi.stubGlobal('fetch', fetch)
416
+
417
+ await RequestEvents.readProjectUsage(Analytics.clickhouse(clickHouse), {
418
+ attributions: [
419
+ { environment: 'sandbox', projectId: 'prj_1' },
420
+ { apiKeyIds: ['key_1', 'key_2'], environment: 'sandbox', projectId: null },
421
+ ],
422
+ from: '2026-01-01T00:00:00.000Z',
423
+ interval: 'hour',
424
+ orgId: 'org_1',
425
+ to: '2026-01-01T01:00:00.000Z',
426
+ })
427
+
428
+ const query = String(fetch.mock.calls[0]![1]?.body)
429
+ expect(query).toContain(`(org_id = 'org_1' AND project_id = 'prj_1'`)
430
+ expect(query).toContain(`(project_id IS NULL AND key_id IN ('key_1', 'key_2')`)
431
+ expect(query).toContain(`(key_environment = 'sandbox' OR key_environment IS NULL)`)
432
+ expect(query).not.toContain(`org_id = 'org_1' AND project_id IS NULL`)
433
+ })
434
+
435
+ test('scopes to the whole organization when a project id is absent', async () => {
436
+ const fetch = vi
437
+ .fn<typeof globalThis.fetch>()
438
+ .mockImplementation(async () => new Response(JSON.stringify({ data: [] }), { status: 200 }))
439
+ vi.stubGlobal('fetch', fetch)
440
+
441
+ await RequestEvents.readProjectUsage(Analytics.clickhouse(clickHouse), {
442
+ attributions: [
443
+ { environment: 'sandbox' },
444
+ { apiKeyIds: ['key_1'], environment: 'sandbox', projectId: null },
445
+ ],
446
+ from: '2026-01-01T00:00:00.000Z',
447
+ interval: 'day',
448
+ orgId: 'org_1',
449
+ to: '2026-01-02T00:00:00.000Z',
450
+ })
451
+
452
+ const query = String(fetch.mock.calls[0]![1]?.body)
453
+ // Org-wide term matches any project without a `project_id` predicate.
454
+ expect(query).toContain(`(org_id = 'org_1' AND key_environment = 'sandbox')`)
455
+ expect(query).toContain(`(project_id IS NULL AND key_id IN ('key_1')`)
456
+ expect(query).not.toContain(`project_id = 'prj`)
457
+ })
458
+
459
+ test.each(['production', 'sandbox'] as const)(
460
+ 'readBillableCounts dedupes on request_id and gates %s correctly',
461
+ async (environment) => {
462
+ const fetch = vi
463
+ .fn<typeof globalThis.fetch>()
464
+ .mockResolvedValue(new Response(JSON.stringify({ data: [] }), { status: 200 }))
465
+ vi.stubGlobal('fetch', fetch)
466
+
467
+ await RequestEvents.readBillableCounts(Analytics.clickhouse(clickHouse), {
468
+ environment,
469
+ from: '2026-01-01T00:00:00.000Z',
470
+ to: '2026-01-01T12:00:00.000Z',
471
+ })
472
+
473
+ const query = String(fetch.mock.calls[0]![1]?.body)
474
+ // Deduped, never a raw count(), so queue retries never over-bill.
475
+ expect(query).toContain('uniqExact(request_id)')
476
+ expect(query).toContain(`principal_type = 'api_key'`)
477
+ expect(query).toContain(`key_environment = '${environment}'`)
478
+ // Sandbox rows bill only when billing was active at request time.
479
+ if (environment === 'sandbox') expect(query).toContain('billing_active = 1')
480
+ else expect(query).not.toContain('billing_active = 1')
481
+ },
482
+ )
483
+
484
+ test('maps grouped rows into the usage response', async () => {
485
+ const fetch = vi.fn<typeof globalThis.fetch>().mockImplementation(async (_url, init) => {
486
+ const sql = String(init?.body)
487
+ const data = (() => {
488
+ if (sql.includes('GROUP BY key_id'))
489
+ return [{ averageDurationMs: '12.3', errors: '1', key_id: 'key_1', requests: '10' }]
490
+ if (sql.includes('GROUP BY route'))
491
+ return [{ averageDurationMs: 12.3, errors: 1, requests: 10, route: '/v1/tokens' }]
492
+ if (sql.includes('GROUP BY status'))
493
+ return [
494
+ { requests: 9, status: 200 },
495
+ { requests: '1', status: '500' },
496
+ ]
497
+ if (sql.includes('formatDateTime'))
498
+ return [{ errors: 1, requests: 10, time: '2026-01-01T00:00:00.000Z' }]
499
+ return [{ averageDurationMs: '12.3', errors: '1', requests: '10' }]
500
+ })()
501
+ return new Response(JSON.stringify({ data }), { status: 200 })
502
+ })
503
+ vi.stubGlobal('fetch', fetch)
504
+
505
+ const usage = await RequestEvents.readProjectUsage(Analytics.clickhouse(clickHouse), {
506
+ attributions: [{ projectId: 'prj_1' }],
507
+ from: '2026-01-01T00:00:00.000Z',
508
+ interval: 'day',
509
+ orgId: 'org_1',
510
+ to: '2026-01-02T00:00:00.000Z',
511
+ })
512
+
513
+ expect(usage).toEqual({
514
+ byKey: [{ apiKeyId: 'key_1', averageDurationMs: 12.3, errors: 1, requests: 10 }],
515
+ byRoute: [{ averageDurationMs: 12.3, errors: 1, requests: 10, route: '/v1/tokens' }],
516
+ byStatus: [
517
+ { requests: 9, status: 200 },
518
+ { requests: 1, status: 500 },
519
+ ],
520
+ from: '2026-01-01T00:00:00.000Z',
521
+ interval: 'day',
522
+ series: [{ errors: 1, requests: 10, time: '2026-01-01T00:00:00.000Z' }],
523
+ to: '2026-01-02T00:00:00.000Z',
524
+ totals: { averageDurationMs: 12.3, errors: 1, requests: 10 },
525
+ })
526
+ })
527
+ })