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.
- package/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-hfCzi-Ux.js} +1 -1
- package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-BO9nKg9w.js → CliToolDetailPage-DLGqYEwI.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-DEDyKBlC.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-CfuYiQ8Z.js} +1 -1
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-bsLuZmYH.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-_UWhwziJ.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-qomXThxX.js → HealthEventsModal-Cjfc5WFS.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-DDVIfZGW.js} +1 -1
- package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
- package/dist/dashboard/assets/{OverviewPage-C-ExCy1V.js → OverviewPage-B53YYMng.js} +1 -1
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-BS9IEpvv.js → ProvidersPage-Dx31DgIO.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-Rn_qXScb.js → ProxyPage-BpnR4kXT.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BihkgQSq.js → QuotaPage-BpOQCnun.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +1 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-D6pmQ86L.js} +1 -1
- package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-DWvKiuSl.js} +1 -1
- package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-CmkUrgdj.js} +1 -1
- package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-B47uOydr.js} +1 -1
- package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
- package/dist/dashboard/assets/{entry-Dj9AR0gY.js → entry-CkryrIea.js} +3 -3
- package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
- package/dist/dashboard/assets/{index-DCBtROoK.js → index-BydyKaDz.js} +2 -2
- package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
- package/dist/dashboard/assets/{select-DsX5QT8K.js → select-BO6h8yNv.js} +1 -1
- package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +21 -21
- package/package.json +1 -1
- package/scripts/cli/command/console-menu.ts +248 -246
- package/scripts/cli/command/tray.ts +208 -204
- package/src/config/spec.ts +26 -2
- package/src/console/backup/routes.ts +52 -1
- package/src/console/backup/service.ts +60 -4
- package/src/console/share/share-router.ts +6 -9
- package/src/observability/payload-capture.ts +366 -344
- package/src/observability/payload-store.ts +12 -2
- package/src/observability/telemetry-status.ts +28 -16
- package/src/persistence/stores/api-key-store.ts +6 -0
- package/src/persistence/stores/share-store.ts +583 -556
- package/src/persistence/stores/telemetry-store.ts +1 -1
- package/src/protocol/response/gemini.ts +18 -4
- package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
- package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
- package/src/providers/integrations/cline/cline-quota.ts +8 -11
- package/src/providers/integrations/opencode/provider.ts +532 -532
- package/src/providers/integrations/tokenharbor.ts +2 -2
- package/src/providers/integrations/trae/trae-catalog.ts +5 -3
- package/src/providers/operations/account-health-service.ts +23 -0
- package/src/providers/operations/client-versions.ts +9 -2
- package/src/providers/provider-metadata.ts +7 -1
- package/src/transport/dispatch/attempt-finalize.ts +46 -5
- package/src/transport/dispatch/proxy-request.ts +9 -0
- package/src/transport/dispatch/streaming/stream.ts +16 -2
- package/src/transport/routing/route-catalog.ts +806 -799
- package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
- package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
- package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
- package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
- 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
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* retention window.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
+
}
|