cartethyia 2.5.2 → 2.5.3

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 (96) hide show
  1. package/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-hfCzi-Ux.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
  3. package/dist/dashboard/assets/{CliToolDetailPage-BO9nKg9w.js → CliToolDetailPage-DLGqYEwI.js} +1 -1
  4. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-DEDyKBlC.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-CfuYiQ8Z.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-bsLuZmYH.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-_UWhwziJ.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
  13. package/dist/dashboard/assets/{HealthEventsModal-qomXThxX.js → HealthEventsModal-Cjfc5WFS.js} +1 -1
  14. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-DDVIfZGW.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
  17. package/dist/dashboard/assets/{OverviewPage-C-ExCy1V.js → OverviewPage-B53YYMng.js} +1 -1
  18. package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
  19. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +3 -0
  20. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
  21. package/dist/dashboard/assets/{ProvidersPage-BS9IEpvv.js → ProvidersPage-Dx31DgIO.js} +1 -1
  22. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
  23. package/dist/dashboard/assets/{ProxyPage-Rn_qXScb.js → ProxyPage-BpnR4kXT.js} +1 -1
  24. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
  25. package/dist/dashboard/assets/{QuotaPage-BihkgQSq.js → QuotaPage-BpOQCnun.js} +1 -1
  26. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
  27. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +1 -0
  28. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
  29. package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-D6pmQ86L.js} +1 -1
  30. package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
  31. package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-DWvKiuSl.js} +1 -1
  32. package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
  33. package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-CmkUrgdj.js} +1 -1
  34. package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
  35. package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-B47uOydr.js} +1 -1
  36. package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
  37. package/dist/dashboard/assets/{entry-Dj9AR0gY.js → entry-CkryrIea.js} +3 -3
  38. package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
  39. package/dist/dashboard/assets/{index-DCBtROoK.js → index-BydyKaDz.js} +2 -2
  40. package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
  41. package/dist/dashboard/assets/{select-DsX5QT8K.js → select-BO6h8yNv.js} +1 -1
  42. package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
  43. package/dist/dashboard/index.html +1 -1
  44. package/dist/dashboard/index.html.gz +0 -0
  45. package/dist/dashboard/precompressed-assets.json +21 -21
  46. package/package.json +1 -1
  47. package/scripts/cli/command/console-menu.ts +248 -246
  48. package/scripts/cli/command/tray.ts +208 -204
  49. package/src/config/spec.ts +26 -2
  50. package/src/console/backup/routes.ts +52 -1
  51. package/src/console/backup/service.ts +60 -4
  52. package/src/console/share/share-router.ts +6 -9
  53. package/src/observability/payload-capture.ts +366 -344
  54. package/src/observability/payload-store.ts +12 -2
  55. package/src/observability/telemetry-status.ts +28 -16
  56. package/src/persistence/stores/api-key-store.ts +6 -0
  57. package/src/persistence/stores/share-store.ts +583 -556
  58. package/src/persistence/stores/telemetry-store.ts +1 -1
  59. package/src/protocol/response/gemini.ts +18 -4
  60. package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
  61. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
  62. package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
  63. package/src/providers/integrations/cline/cline-quota.ts +8 -11
  64. package/src/providers/integrations/opencode/provider.ts +532 -532
  65. package/src/providers/integrations/tokenharbor.ts +2 -2
  66. package/src/providers/integrations/trae/trae-catalog.ts +5 -3
  67. package/src/providers/operations/account-health-service.ts +23 -0
  68. package/src/providers/operations/client-versions.ts +9 -2
  69. package/src/providers/provider-metadata.ts +7 -1
  70. package/src/transport/dispatch/attempt-finalize.ts +46 -5
  71. package/src/transport/dispatch/proxy-request.ts +9 -0
  72. package/src/transport/dispatch/streaming/stream.ts +16 -2
  73. package/src/transport/routing/route-catalog.ts +806 -799
  74. package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
  75. package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
  76. package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
  77. package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
  78. package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
  79. package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
  80. package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
  81. package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
  82. package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
  83. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
  84. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
  85. package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
  86. package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
  87. package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
  88. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
  89. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
  90. package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
  91. package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
  92. package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
  93. package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
  94. package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
  95. package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
  96. package/dist/dashboard/assets/select-DsX5QT8K.js.gz +0 -0
@@ -1,344 +1,366 @@
1
- import type { CartethyiaDatabase } from "../persistence/db-handle";
2
- import { DrizzleTelemetryStore } from "../persistence/stores/telemetry-store";
3
- import { prunePayloadFrames, writePayloadFrame } from "./payload-store";
4
- import { resolveTelemetryPayloadMaxBytes } from "../config/telemetry";
5
- import { preferencesReaderFor } from "../persistence/tenant-preferences";
6
- import type { TelemetryPayloadDepth, TelemetryPayloadMode } from "../console/settings/contracts";
7
- export type CaptureScope = "tenant" | "debug_session" | "operator_flag";
8
-
9
- /**
10
- * Settings-gated payload capture. Request-event metadata is always retained;
11
- * tenants opt into drawer capture through Settings → Privacy: `metadata`
12
- * keeps only the Proxy→Provider request line, `full` keeps redacted bodies
13
- * at the tenant's chosen depth. Fail-closed on error.
14
- */
15
- export async function resolvePayloadCapture(
16
- db: CartethyiaDatabase,
17
- tenantId: string | null,
18
- ): Promise<
19
- | { readonly ok: true; readonly mode: TelemetryPayloadMode; readonly depth: TelemetryPayloadDepth }
20
- | { readonly ok: false }
21
- > {
22
- if (!tenantId) return { ok: false };
23
- try {
24
- const prefs = await preferencesReaderFor(db).readPreferences(tenantId);
25
- const mode = prefs?.telemetryPayloads;
26
- const depth = prefs?.telemetryPayloadDepth;
27
- return {
28
- ok: true,
29
- mode: mode === "full" || mode === "metadata" || mode === "none" ? mode : "none",
30
- depth:
31
- depth === "minimum" || depth === "moderate" || depth === "maximum"
32
- ? depth
33
- : depth === "medium"
34
- ? "minimum"
35
- : depth === "high"
36
- ? "moderate"
37
- : depth === "full"
38
- ? "maximum"
39
- : "minimum",
40
- };
41
- } catch {
42
- // A preference read that fails must not invent capture. Off keeps nothing.
43
- // Reported as its own state rather than silently as `none`: an operator
44
- // whose capture is off by choice and one whose preference read is failing
45
- // need different fixes, and both used to look like "not configured".
46
- return { ok: false };
47
- }
48
- }
49
-
50
- /**
51
- * Whether the tenant's capture mode keeps bodies (`full`). Both the
52
- * provider-response tee and the ingress-body release key off this: `none` and
53
- * `metadata` store no client request body, so the parsed ingress body can be
54
- * dropped once the canonical request exists instead of being held until
55
- * finalize — which, for a streaming response, is the whole stream.
56
- */
57
- export async function payloadCaptureKeepsBodies(
58
- db: CartethyiaDatabase,
59
- tenantId: string | null,
60
- ): Promise<boolean> {
61
- const resolved = await resolvePayloadCapture(db, tenantId);
62
- return resolved.ok && resolved.mode === "full";
63
- }
64
-
65
- /**
66
- * Why one request's drawer payloads are empty.
67
- *
68
- * Every reason the drawer can read empty is named here, because from the
69
- * client the four panels look identical in all of them: an operator who turned
70
- * capture off and an operator whose capture is broken both see four blank
71
- * panels. Recording the reason is what makes remote diagnosis possible without
72
- * access to the server's logs.
73
- */
74
- export type PayloadCaptureState =
75
- /** Bodies were captured for this request. */
76
- | "captured"
77
- /** Only the Proxy→Provider request line was kept (mode `metadata`). */
78
- | "captured_metadata"
79
- /** The tenant has capture switched off; request metadata is still retained. */
80
- | "skipped_mode_none"
81
- /** The request had no tenant, so no capture preference could be read. */
82
- | "skipped_no_tenant"
83
- /** The attempt was not the terminal one; the terminal attempt captures. */
84
- | "skipped_not_terminal"
85
- /** The preference read failed, and capture fails closed. */
86
- | "skipped_preference_read_failed"
87
- /** The storage write failed after the decision to capture. */
88
- | "failed_write";
89
-
90
- export interface PayloadCaptureInput {
91
- tenantId: string;
92
- requestBody: unknown;
93
- responseBody: unknown;
94
- clientResponseBody?: unknown;
95
- providerRequestBody?: unknown;
96
- providerResponseBody?: unknown;
97
- /**
98
- * Combined body cap override for this capture (Settings → capture depth).
99
- * Defaults to the configured capture limit.
100
- */
101
- maxBytes?: number;
102
- scope: CaptureScope;
103
- /** Links the row to `telemetry_events.request_id`. */
104
- requestId?: string;
105
- // optional opt-in flags — exactly one must be true for `scope`, see `isCaptureAllowed`
106
- tenantOptIn?: boolean;
107
- debugSessionOptIn?: boolean;
108
- operatorFlagOptIn?: boolean;
109
- }
110
-
111
- export interface PayloadSignals {
112
- readonly toolCalls: number;
113
- readonly images: number;
114
- readonly attachments: number;
115
- }
116
-
117
- export interface StoredPayload {
118
- id: string;
119
- /**
120
- * JSON text of the five body surfaces, in drawer order, built while the
121
- * record was measured. Present only on a record that has not been persisted
122
- * yet; the frame writer consumes it so the bodies are not serialized twice.
123
- */
124
- _serialized?: readonly string[];
125
- request_id: string | null;
126
- captured_at: Date;
127
- expires_at: Date;
128
- request_body: unknown;
129
- response_body: unknown;
130
- client_response_body: unknown;
131
- provider_request_body: unknown;
132
- provider_response_body: unknown;
133
- /**
134
- * Bitmask of the stages whose body was kept: 1 request, 2 provider request,
135
- * 4 provider response, 8 client response. Written to its own column so the
136
- * Usage list can show which panels have content without inflating a frame
137
- * per row.
138
- */
139
- stages: number;
140
- signals: PayloadSignals;
141
- }
142
-
143
- function payloadRetentionMs(): number {
144
- const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_RETENTION_MS ?? 15 * 60_000);
145
- if (!Number.isFinite(raw) || raw < 1_000 || raw > 7 * 24 * 60 * 60_000) return 15 * 60_000;
146
- return Math.floor(raw);
147
- }
148
- function isCaptureAllowed(input: PayloadCaptureInput): boolean {
149
- // Exactly one opt-in must be set, and it must be the one `scope` names:
150
- // two flags at once would silently widen capture beyond the declared scope.
151
- const optIns = [input.tenantOptIn, input.debugSessionOptIn, input.operatorFlagOptIn];
152
- if (optIns.filter(Boolean).length !== 1) return false;
153
- if (input.scope === "tenant") return input.tenantOptIn === true;
154
- if (input.scope === "debug_session") return input.debugSessionOptIn === true;
155
- if (input.scope === "operator_flag") return input.operatorFlagOptIn === true;
156
- return false;
157
- }
158
-
159
- function payloadSignals(values: readonly unknown[]): PayloadSignals {
160
- let toolCalls = 0;
161
- let images = 0;
162
- let attachments = 0;
163
- const seen = new Set<object>();
164
- const visit = (value: unknown, depth: number): void => {
165
- if (depth > 8 || value === null || typeof value !== "object") return;
166
- if (seen.has(value)) return;
167
- seen.add(value);
168
- if (Array.isArray(value)) {
169
- for (const entry of value) visit(entry, depth + 1);
170
- return;
171
- }
172
- const record = value as Record<string, unknown>;
173
- const type = typeof record["type"] === "string" ? record["type"].toLowerCase() : "";
174
- if (type === "tool_call" || type === "tool_use" || type === "function_call") toolCalls += 1;
175
- if (type === "image" || type === "input_image" || type === "output_image") images += 1;
176
- if (type === "file" || type === "document" || type === "attachment") attachments += 1;
177
- for (const [key, child] of Object.entries(record)) {
178
- const normalizedKey = key.toLowerCase();
179
- if (normalizedKey === "tool_calls" || normalizedKey === "toolcalls") {
180
- if (Array.isArray(child)) toolCalls += child.length;
181
- } else if (normalizedKey === "attachments" || normalizedKey === "files") {
182
- if (Array.isArray(child)) attachments += child.length;
183
- } else if (
184
- (normalizedKey === "image_url" ||
185
- normalizedKey === "input_image" ||
186
- normalizedKey === "output_image") &&
187
- !type.includes("image")
188
- ) {
189
- images += 1;
190
- }
191
- visit(child, depth + 1);
192
- }
193
- };
194
- for (const value of values) visit(value, 0);
195
- return { toolCalls, images, attachments };
196
- }
197
-
198
- /**
199
- * Shapes a captured payload without persisting it — a pure helper reused by
200
- * `TelemetryPayloadCapture.capture` and exercised directly in tests. Bodies
201
- * larger than `maxBytes` (combined, post-redaction, defaulting to the
202
- * configured capture limit) are dropped and replaced with a truncation
203
- * marker — no object store exists, so an oversized body is never referenced,
204
- * only accounted. Payloads remain redacted and bounded during the 15-minute
205
- * retention window.
206
- */
207
- export function buildPayloadRecord(
208
- input: PayloadCaptureInput,
209
- now = new Date(),
210
- maxBytes = resolveTelemetryPayloadMaxBytes(),
211
- ): Omit<StoredPayload, "id"> {
212
- const expiresAt = new Date(now.getTime() + payloadRetentionMs());
213
- // Each body is serialized once, in drawer order, and only while the running
214
- // total still fits the cap. The old path serialized every body up front to
215
- // measure them and then serialized the whole record again to store it, so a
216
- // capture held two copies of every body at once — measured at 23 MB of heap
217
- // for 8 MB of bodies. A body that would cross the cap is never touched: its
218
- // text is not built and its object is not walked for signals.
219
- const surfaces: ReadonlyArray<readonly [keyof StoredPayload, unknown]> = [
220
- ["request_body", input.requestBody],
221
- ["response_body", input.responseBody],
222
- ["client_response_body", input.clientResponseBody],
223
- ["provider_request_body", input.providerRequestBody],
224
- ["provider_response_body", input.providerResponseBody],
225
- ];
226
- const stored: Partial<Record<keyof StoredPayload, unknown>> = {};
227
- const serialized: string[] = [];
228
- const kept: unknown[] = [];
229
- let accounted = 0;
230
- let truncated = false;
231
- for (const [key, body] of surfaces) {
232
- if (truncated || body === undefined || body === null) {
233
- stored[key] = null;
234
- serialized.push("null");
235
- continue;
236
- }
237
- const text = JSON.stringify(body);
238
- if (accounted + text.length > maxBytes) {
239
- truncated = true;
240
- stored[key] = null;
241
- serialized.push("null");
242
- continue;
243
- }
244
- accounted += text.length;
245
- stored[key] = body;
246
- serialized.push(text);
247
- kept.push(body);
248
- }
249
- const marker = truncated
250
- ? {
251
- _truncated: true,
252
- _original_bytes: accounted,
253
- _hint: "Truncated by the capture cap — raise Settings → Telemetry payloads → Capture depth to keep more.",
254
- }
255
- : undefined;
256
- if (marker) {
257
- for (const [key] of surfaces) {
258
- if (stored[key] === null) stored[key] = marker;
259
- }
260
- }
261
- return {
262
- request_id: input.requestId ?? null,
263
- captured_at: now,
264
- expires_at: expiresAt,
265
- request_body: stored.request_body ?? null,
266
- response_body: stored.response_body ?? null,
267
- client_response_body: stored.client_response_body ?? null,
268
- provider_request_body: stored.provider_request_body ?? null,
269
- provider_response_body: stored.provider_response_body ?? null,
270
- // Presence, not size: a stage counts as captured when a body was kept for
271
- // it. `response_body` is the legacy alias of the client response, so it
272
- // folds into that stage — it never names a panel of its own.
273
- stages:
274
- (stored.request_body != null ? 1 : 0) |
275
- (stored.provider_request_body != null ? 2 : 0) |
276
- (stored.provider_response_body != null ? 4 : 0) |
277
- (stored.client_response_body != null || stored.response_body != null ? 8 : 0),
278
- signals: payloadSignals(kept),
279
- _serialized: serialized,
280
- };
281
- }
282
-
283
- /**
284
- * Postgres-backed redacted request/response payload capture. Payloads have a
285
- * 15-minute retention window and are automatically pruned.
286
-
287
- */
288
- export class TelemetryPayloadCapture {
289
- private readonly store: DrizzleTelemetryStore;
290
-
291
- constructor(db: CartethyiaDatabase) {
292
- this.store = new DrizzleTelemetryStore(db);
293
- }
294
-
295
- async capture(input: PayloadCaptureInput): Promise<{ id: string; expiresAt: Date }> {
296
- if (!isCaptureAllowed(input)) {
297
- throw Object.assign(new Error("payload capture not opted in"), {
298
- code: "capture_not_opted_in",
299
- });
300
- }
301
- const record = buildPayloadRecord(input, new Date(), input.maxBytes ?? resolveTelemetryPayloadMaxBytes());
302
- const reference = await writePayloadFrame(record, record.expires_at, record._serialized
303
- ? {
304
- requestId: record.request_id,
305
- capturedAt: record.captured_at.toISOString(),
306
- signals: record.signals,
307
- bodies: record._serialized,
308
- }
309
- : undefined);
310
- // Typed columns only — bodies live in the frame file. The schema has no
311
- // jsonb body column, so a body cannot be written to Postgres here.
312
- return this.store.insertPayload({
313
- tenantId: input.tenantId,
314
- requestId: input.requestId ?? null,
315
- capturedAt: record.captured_at,
316
- expiresAt: record.expires_at,
317
- storage: reference.storage,
318
- file: reference.file,
319
- offset: reference.offset,
320
- length: reference.length,
321
- checksum: reference.checksum,
322
- version: reference.version,
323
- // Presence travels with the row so the Usage list can read it without
324
- // inflating the frame. Built by `buildPayloadRecord`, which always sets
325
- // it; the guard keeps the insert honest if a caller supplies a record
326
- // from before the field existed.
327
- ...(Number.isInteger(record.stages) ? { stages: record.stages } : {}),
328
- });
329
- }
330
-
331
- async cleanupExpired(batchSize = 5000, now = new Date()): Promise<{ deleted: number }> {
332
- let deleted = 0;
333
- for (let run = 0; run < 40; run += 1) {
334
- const batch = await this.store.deleteExpiredPayloads(batchSize, now);
335
- deleted += batch;
336
- if (batch < batchSize) break;
337
- }
338
- // Every frame expires `payloadRetentionMs()` after it is written, so a file
339
- // last written before that window closed cannot still hold a live frame.
340
- // That bound is what makes a damaged, unparsable file reclaimable at all.
341
- deleted += await prunePayloadFrames(now, new Date(now.getTime() - payloadRetentionMs()));
342
- return { deleted };
343
- }
344
- }
1
+ import type { CartethyiaDatabase } from "../persistence/db-handle";
2
+ import { DrizzleTelemetryStore } from "../persistence/stores/telemetry-store";
3
+ import { prunePayloadFrames, writePayloadFrame } from "./payload-store";
4
+ import { resolveTelemetryPayloadMaxBytes } from "../config/telemetry";
5
+ import { preferencesReaderFor } from "../persistence/tenant-preferences";
6
+ import type { TelemetryPayloadDepth, TelemetryPayloadMode } from "../console/settings/contracts";
7
+ export type CaptureScope = "tenant" | "debug_session" | "operator_flag";
8
+
9
+ /**
10
+ * Settings-gated payload capture. Request-event metadata is always retained;
11
+ * tenants opt into drawer capture through Settings → Privacy: `metadata`
12
+ * keeps only the Proxy→Provider request line, `full` keeps redacted bodies
13
+ * at the tenant's chosen depth. Fail-closed on error.
14
+ */
15
+ export async function resolvePayloadCapture(
16
+ db: CartethyiaDatabase,
17
+ tenantId: string | null,
18
+ ): Promise<
19
+ | { readonly ok: true; readonly mode: TelemetryPayloadMode; readonly depth: TelemetryPayloadDepth }
20
+ | { readonly ok: false }
21
+ > {
22
+ if (!tenantId) return { ok: false };
23
+ try {
24
+ const prefs = await preferencesReaderFor(db).readPreferences(tenantId);
25
+ const mode = prefs?.telemetryPayloads;
26
+ const depth = prefs?.telemetryPayloadDepth;
27
+ return {
28
+ ok: true,
29
+ mode: mode === "full" || mode === "metadata" || mode === "none" ? mode : "none",
30
+ depth:
31
+ depth === "minimum" || depth === "moderate" || depth === "maximum"
32
+ ? depth
33
+ : depth === "medium"
34
+ ? "minimum"
35
+ : depth === "high"
36
+ ? "moderate"
37
+ : depth === "full"
38
+ ? "maximum"
39
+ : "minimum",
40
+ };
41
+ } catch {
42
+ // A preference read that fails must not invent capture. Off keeps nothing.
43
+ // Reported as its own state rather than silently as `none`: an operator
44
+ // whose capture is off by choice and one whose preference read is failing
45
+ // need different fixes, and both used to look like "not configured".
46
+ return { ok: false };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Whether the tenant's capture mode keeps bodies (`full`). Both the
52
+ * provider-response tee and the ingress-body release key off this: `none` and
53
+ * `metadata` store no client request body, so the parsed ingress body can be
54
+ * dropped once the canonical request exists instead of being held until
55
+ * finalize — which, for a streaming response, is the whole stream.
56
+ */
57
+ export async function payloadCaptureKeepsBodies(
58
+ db: CartethyiaDatabase,
59
+ tenantId: string | null,
60
+ ): Promise<boolean> {
61
+ const resolved = await resolvePayloadCapture(db, tenantId);
62
+ return resolved.ok && resolved.mode === "full";
63
+ }
64
+
65
+ /**
66
+ * Why one request's drawer payloads are empty.
67
+ *
68
+ * Every reason the drawer can read empty is named here, because from the
69
+ * client the four panels look identical in all of them: an operator who turned
70
+ * capture off and an operator whose capture is broken both see four blank
71
+ * panels. Recording the reason is what makes remote diagnosis possible without
72
+ * access to the server's logs.
73
+ */
74
+ export type PayloadCaptureState =
75
+ /** Bodies were captured for this request. */
76
+ | "captured"
77
+ /** Only the Proxy→Provider request line was kept (mode `metadata`). */
78
+ | "captured_metadata"
79
+ /** The tenant has capture switched off; request metadata is still retained. */
80
+ | "skipped_mode_none"
81
+ /** The request had no tenant, so no capture preference could be read. */
82
+ | "skipped_no_tenant"
83
+ /** The attempt was not the terminal one; the terminal attempt captures. */
84
+ | "skipped_not_terminal"
85
+ /** The preference read failed, and capture fails closed. */
86
+ | "skipped_preference_read_failed"
87
+ /** The storage write failed after the decision to capture. */
88
+ | "failed_write";
89
+
90
+ export interface PayloadCaptureInput {
91
+ tenantId: string;
92
+ requestBody: unknown;
93
+ responseBody: unknown;
94
+ clientResponseBody?: unknown;
95
+ providerRequestBody?: unknown;
96
+ providerResponseBody?: unknown;
97
+ /**
98
+ * Combined body cap override for this capture (Settings → capture depth).
99
+ * Defaults to the configured capture limit.
100
+ */
101
+ maxBytes?: number;
102
+ scope: CaptureScope;
103
+ /** Links the row to `telemetry_events.request_id`. */
104
+ requestId?: string;
105
+ // optional opt-in flags — exactly one must be true for `scope`, see `isCaptureAllowed`
106
+ tenantOptIn?: boolean;
107
+ debugSessionOptIn?: boolean;
108
+ operatorFlagOptIn?: boolean;
109
+ }
110
+
111
+ export interface PayloadSignals {
112
+ readonly toolCalls: number;
113
+ readonly images: number;
114
+ readonly attachments: number;
115
+ }
116
+
117
+ export interface StoredPayload {
118
+ id: string;
119
+ /**
120
+ * JSON text of the five body surfaces, in drawer order, built while the
121
+ * record was measured. Present only on a record that has not been persisted
122
+ * yet; the frame writer consumes it so the bodies are not serialized twice.
123
+ */
124
+ _serialized?: readonly string[];
125
+ request_id: string | null;
126
+ captured_at: Date;
127
+ expires_at: Date;
128
+ request_body: unknown;
129
+ response_body: unknown;
130
+ client_response_body: unknown;
131
+ provider_request_body: unknown;
132
+ provider_response_body: unknown;
133
+ /**
134
+ * Bitmask of the stages whose body was kept: 1 request, 2 provider request,
135
+ * 4 provider response, 8 client response. Written to its own column so the
136
+ * Usage list can show which panels have content without inflating a frame
137
+ * per row.
138
+ */
139
+ stages: number;
140
+ signals: PayloadSignals;
141
+ }
142
+
143
+ function payloadRetentionMs(): number {
144
+ const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_RETENTION_MS ?? 15 * 60_000);
145
+ if (!Number.isFinite(raw) || raw < 1_000 || raw > 7 * 24 * 60 * 60_000) return 15 * 60_000;
146
+ return Math.floor(raw);
147
+ }
148
+ function isCaptureAllowed(input: PayloadCaptureInput): boolean {
149
+ // Exactly one opt-in must be set, and it must be the one `scope` names:
150
+ // two flags at once would silently widen capture beyond the declared scope.
151
+ const optIns = [input.tenantOptIn, input.debugSessionOptIn, input.operatorFlagOptIn];
152
+ if (optIns.filter(Boolean).length !== 1) return false;
153
+ if (input.scope === "tenant") return input.tenantOptIn === true;
154
+ if (input.scope === "debug_session") return input.debugSessionOptIn === true;
155
+ if (input.scope === "operator_flag") return input.operatorFlagOptIn === true;
156
+ return false;
157
+ }
158
+
159
+ function payloadSignals(values: readonly unknown[]): PayloadSignals {
160
+ let toolCalls = 0;
161
+ let images = 0;
162
+ let attachments = 0;
163
+ const seen = new Set<object>();
164
+ const visit = (value: unknown, depth: number): void => {
165
+ if (depth > 8 || value === null || typeof value !== "object") return;
166
+ if (seen.has(value)) return;
167
+ seen.add(value);
168
+ if (Array.isArray(value)) {
169
+ for (const entry of value) visit(entry, depth + 1);
170
+ return;
171
+ }
172
+ const record = value as Record<string, unknown>;
173
+ const type = typeof record["type"] === "string" ? record["type"].toLowerCase() : "";
174
+ if (type === "tool_call" || type === "tool_use" || type === "function_call") toolCalls += 1;
175
+ if (type === "image" || type === "input_image" || type === "output_image") images += 1;
176
+ if (type === "file" || type === "document" || type === "attachment") attachments += 1;
177
+ for (const [key, child] of Object.entries(record)) {
178
+ const normalizedKey = key.toLowerCase();
179
+ if (normalizedKey === "tool_calls" || normalizedKey === "toolcalls") {
180
+ if (Array.isArray(child)) toolCalls += child.length;
181
+ } else if (normalizedKey === "attachments" || normalizedKey === "files") {
182
+ if (Array.isArray(child)) attachments += child.length;
183
+ } else if (
184
+ (normalizedKey === "image_url" ||
185
+ normalizedKey === "input_image" ||
186
+ normalizedKey === "output_image") &&
187
+ !type.includes("image")
188
+ ) {
189
+ images += 1;
190
+ }
191
+ visit(child, depth + 1);
192
+ }
193
+ };
194
+ for (const value of values) visit(value, 0);
195
+ return { toolCalls, images, attachments };
196
+ }
197
+
198
+ /**
199
+ * Shapes a captured payload without persisting it — a pure helper reused by
200
+ * `TelemetryPayloadCapture.capture` and exercised directly in tests. Bodies
201
+ * are accounted in *drawer order*, and the combined total is bounded by
202
+ * `maxBytes` (post-redaction, defaulting to the configured capture limit): a
203
+ * body that no longer fits is replaced with a truncation marker — no object
204
+ * store exists, so an oversized body is never referenced, only accounted.
205
+ * Payloads remain redacted and bounded during the 15-minute retention window.
206
+ *
207
+ * Drawer order is the allocation order, and that is deliberate: the cap is a
208
+ * single shared budget, so whatever is accounted first is what survives a
209
+ * large request. `response_body` is the legacy alias of the client response
210
+ * and names no panel of its own, so it is accounted **last** — while it was
211
+ * second, a large client request plus its own duplicate consumed the whole
212
+ * budget before the Provider Request and Provider Response panels were
213
+ * reached, and those two read empty for a request that did reach an upstream.
214
+ */
215
+ export function buildPayloadRecord(
216
+ input: PayloadCaptureInput,
217
+ now = new Date(),
218
+ maxBytes = resolveTelemetryPayloadMaxBytes(),
219
+ ): Omit<StoredPayload, "id"> {
220
+ const expiresAt = new Date(now.getTime() + payloadRetentionMs());
221
+ // Each body is serialized once, in drawer order, and only while the running
222
+ // total still fits the cap. The old path serialized every body up front to
223
+ // measure them and then serialized the whole record again to store it, so a
224
+ // capture held two copies of every body at once — measured at 23 MB of heap
225
+ // for 8 MB of bodies. A body that would cross the cap is never touched: its
226
+ // text is not built and its object is not walked for signals.
227
+ const surfaces: ReadonlyArray<readonly [keyof StoredPayload, unknown]> = [
228
+ ["request_body", input.requestBody],
229
+ ["provider_request_body", input.providerRequestBody],
230
+ ["provider_response_body", input.providerResponseBody],
231
+ ["client_response_body", input.clientResponseBody],
232
+ ["response_body", input.responseBody],
233
+ ];
234
+ const stored: Partial<Record<keyof StoredPayload, unknown>> = {};
235
+ const serialized: string[] = [];
236
+ const kept: unknown[] = [];
237
+ let accounted = 0;
238
+ const truncatedKeys = new Set<keyof StoredPayload>();
239
+ /**
240
+ * Whether a surface kept a *body* — not whether it is merely non-null.
241
+ *
242
+ * A surface that lost its body to the cap carries a truncation marker, and
243
+ * a marker is not content: the drawer renders it in place of a body, but the
244
+ * Usage list ticks a stage to say "this panel has something to open".
245
+ */
246
+ const keptStage = (value: unknown, key: keyof StoredPayload): boolean =>
247
+ value != null && !truncatedKeys.has(key);
248
+ for (const [key, body] of surfaces) {
249
+ if (body === undefined || body === null) {
250
+ stored[key] = null;
251
+ serialized.push("null");
252
+ continue;
253
+ }
254
+ const text = JSON.stringify(body);
255
+ if (accounted + text.length > maxBytes) {
256
+ truncatedKeys.add(key);
257
+ stored[key] = null;
258
+ serialized.push("null");
259
+ continue;
260
+ }
261
+ accounted += text.length;
262
+ stored[key] = body;
263
+ serialized.push(text);
264
+ kept.push(body);
265
+ }
266
+ // Only a surface that *had* a body and lost it to the cap is marked. The
267
+ // previous form stamped the marker onto every null surface, including ones
268
+ // that never carried anything — a request that never reached an upstream
269
+ // then reported "truncated" for a provider panel that was never captured,
270
+ // and the stage bitmask counted a marker as captured content.
271
+ const marker = (originalBytes: number): unknown => ({
272
+ _truncated: true,
273
+ _original_bytes: originalBytes,
274
+ _hint: "Truncated by the capture cap — raise Settings → Telemetry payloads → Capture depth to keep more.",
275
+ });
276
+ for (const key of truncatedKeys) stored[key] = marker(accounted);
277
+ return {
278
+ request_id: input.requestId ?? null,
279
+ captured_at: now,
280
+ expires_at: expiresAt,
281
+ request_body: stored.request_body ?? null,
282
+ response_body: stored.response_body ?? null,
283
+ client_response_body: stored.client_response_body ?? null,
284
+ provider_request_body: stored.provider_request_body ?? null,
285
+ provider_response_body: stored.provider_response_body ?? null,
286
+ // Presence, not size: a stage counts as captured when a body was kept for
287
+ // it. `response_body` is the legacy alias of the client response, so it
288
+ // folds into that stage — it never names a panel of its own.
289
+ // A truncation marker is deliberately not presence: the drawer renders it
290
+ // in place of a body, but the Usage list ticks stages to say "this panel
291
+ // has content", and a panel whose body was dropped has none.
292
+ stages:
293
+ (keptStage(stored.request_body, "request_body") ? 1 : 0) |
294
+ (keptStage(stored.provider_request_body, "provider_request_body") ? 2 : 0) |
295
+ (keptStage(stored.provider_response_body, "provider_response_body") ? 4 : 0) |
296
+ (keptStage(stored.client_response_body, "client_response_body") ||
297
+ keptStage(stored.response_body, "response_body")
298
+ ? 8
299
+ : 0),
300
+ signals: payloadSignals(kept),
301
+ _serialized: serialized,
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Postgres-backed redacted request/response payload capture. Payloads have a
307
+ * 15-minute retention window and are automatically pruned.
308
+
309
+ */
310
+ export class TelemetryPayloadCapture {
311
+ private readonly store: DrizzleTelemetryStore;
312
+
313
+ constructor(db: CartethyiaDatabase) {
314
+ this.store = new DrizzleTelemetryStore(db);
315
+ }
316
+
317
+ async capture(input: PayloadCaptureInput): Promise<{ id: string; expiresAt: Date }> {
318
+ if (!isCaptureAllowed(input)) {
319
+ throw Object.assign(new Error("payload capture not opted in"), {
320
+ code: "capture_not_opted_in",
321
+ });
322
+ }
323
+ const record = buildPayloadRecord(input, new Date(), input.maxBytes ?? resolveTelemetryPayloadMaxBytes());
324
+ const reference = await writePayloadFrame(record, record.expires_at, record._serialized
325
+ ? {
326
+ requestId: record.request_id,
327
+ capturedAt: record.captured_at.toISOString(),
328
+ signals: record.signals,
329
+ bodies: record._serialized,
330
+ }
331
+ : undefined);
332
+ // Typed columns only — bodies live in the frame file. The schema has no
333
+ // jsonb body column, so a body cannot be written to Postgres here.
334
+ return this.store.insertPayload({
335
+ tenantId: input.tenantId,
336
+ requestId: input.requestId ?? null,
337
+ capturedAt: record.captured_at,
338
+ expiresAt: record.expires_at,
339
+ storage: reference.storage,
340
+ file: reference.file,
341
+ offset: reference.offset,
342
+ length: reference.length,
343
+ checksum: reference.checksum,
344
+ version: reference.version,
345
+ // Presence travels with the row so the Usage list can read it without
346
+ // inflating the frame. Built by `buildPayloadRecord`, which always sets
347
+ // it; the guard keeps the insert honest if a caller supplies a record
348
+ // from before the field existed.
349
+ ...(Number.isInteger(record.stages) ? { stages: record.stages } : {}),
350
+ });
351
+ }
352
+
353
+ async cleanupExpired(batchSize = 5000, now = new Date()): Promise<{ deleted: number }> {
354
+ let deleted = 0;
355
+ for (let run = 0; run < 40; run += 1) {
356
+ const batch = await this.store.deleteExpiredPayloads(batchSize, now);
357
+ deleted += batch;
358
+ if (batch < batchSize) break;
359
+ }
360
+ // Every frame expires `payloadRetentionMs()` after it is written, so a file
361
+ // last written before that window closed cannot still hold a live frame.
362
+ // That bound is what makes a damaged, unparsable file reclaimable at all.
363
+ deleted += await prunePayloadFrames(now, new Date(now.getTime() - payloadRetentionMs()));
364
+ return { deleted };
365
+ }
366
+ }