cartethyia 2.5.9 → 2.6.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.
- package/.env.example +15 -42
- package/README.md +11 -10
- package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
- package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
- package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
- package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
- package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
- package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
- package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
- package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
- package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
- package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/{app-BIt446yE.js → app-2KhtHCv9.js} +1 -1
- package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-2KhtHCv9.js.gz} +0 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
- package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/{badge-CR72dasT.js → badge-CKsF123d.js} +1 -1
- package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
- package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
- package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
- package/dist/dashboard/assets/{card-DS7CQEsO.js → card-CdsSlWA3.js} +1 -1
- package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
- package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
- package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
- package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
- package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
- package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
- package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
- package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
- package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
- package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
- package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
- package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
- package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-CM1AaG_y.js} +1 -1
- package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
- package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-BOevJzdz.js} +1 -1
- package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/{select-C6MMiaio.js → select-C980c4cz.js} +1 -1
- package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
- package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
- package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
- package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
- package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-Dkke7Naf.js} +1 -1
- package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
- package/dist/dashboard/assets/useMutation-EDFRkn1i.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 +51 -51
- package/migrations/README.md +52 -52
- package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
- package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
- package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
- package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
- package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
- package/package.json +96 -97
- package/scripts/cli/admin.ts +126 -125
- package/scripts/cli/autostart.ts +237 -237
- package/scripts/cli/background.ts +120 -110
- package/scripts/cli/bin/carte.cjs +8 -8
- package/scripts/cli/bin/cartethyia.cjs +167 -167
- package/scripts/cli/console-menu.ts +308 -275
- package/scripts/cli/controller.ts +145 -0
- package/scripts/cli/doctor.ts +1 -1
- package/scripts/cli/env.ts +99 -99
- package/scripts/cli/gateway.ts +264 -261
- package/scripts/{health.ts → cli/health.ts} +4 -4
- package/scripts/cli/instance.ts +234 -224
- package/scripts/cli/main.ts +188 -164
- package/scripts/cli/platform.ts +64 -64
- package/scripts/cli/prompt.ts +275 -107
- package/scripts/cli/services.ts +88 -88
- package/scripts/cli/setup.ts +445 -445
- package/scripts/cli/start.ts +85 -82
- package/scripts/cli/startup-signal.ts +23 -23
- package/scripts/cli/telemetry.ts +70 -0
- package/scripts/cli/tray/index.ts +263 -263
- package/scripts/cli/tray/windows.ps1 +107 -107
- package/scripts/cli/tray.ts +192 -208
- package/scripts/cli/update.ts +39 -39
- package/scripts/cli/version.ts +67 -67
- package/scripts/cli/wordmark.ts +58 -58
- package/src/boot/app.ts +16 -1
- package/src/boot/dependencies.ts +128 -25
- package/src/config/package-paths.ts +24 -0
- package/src/config/resources.ts +137 -0
- package/src/config/runtime.ts +133 -76
- package/src/config/spec.ts +30 -29
- package/src/config/telemetry.ts +0 -25
- package/src/console/api-keys/bucket-spend.ts +14 -17
- package/src/console/api-keys/contracts.ts +410 -410
- package/src/console/api-keys/routes.ts +12 -0
- package/src/console/audit/store.ts +2 -3
- package/src/console/auth/setup-service.ts +8 -1
- package/src/console/backup/routes.ts +7 -0
- package/src/console/backup/service.ts +463 -463
- package/src/console/backup/store.ts +16 -11
- package/src/console/backup/validate.ts +1 -4
- package/src/console/cli-tools/fs-ops.ts +18 -2
- package/src/console/data-management/contracts.ts +1 -8
- package/src/console/data-management/operations.ts +61 -48
- package/src/console/data-management/routes.ts +4 -2
- package/src/console/domain-registration.ts +25 -3
- package/src/console/inferhub/client.ts +139 -0
- package/src/console/inferhub/key-store.ts +129 -0
- package/src/console/inferhub/normalize.ts +347 -0
- package/src/console/inferhub/routes.ts +411 -0
- package/src/console/inferhub/store.ts +109 -0
- package/src/console/inferhub/types.ts +183 -0
- package/src/console/observability/contracts.ts +2 -41
- package/src/console/observability/store.ts +176 -371
- package/src/console/providers/catalog/public-model-store.ts +0 -0
- package/src/console/providers/catalog/store.ts +23 -36
- package/src/console/quota/account-quota-shared.ts +52 -52
- package/src/console/quota/account-quota-tenant.ts +16 -24
- package/src/console/quota/refresh.ts +441 -441
- package/src/console/remote-support/service.ts +270 -114
- package/src/console/routing/pools/import-parse.ts +206 -206
- package/src/console/routing/pools/operations.ts +543 -543
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/routes.ts +181 -181
- package/src/console/routing/pools/store.ts +1 -1
- package/src/console/settings/contracts.ts +42 -40
- package/src/console/settings/store.ts +25 -25
- package/src/console/share/share-stats.ts +22 -4
- package/src/console/share/share-usage.ts +30 -4
- package/src/console/shared/cursor.ts +15 -0
- package/src/console/updates/repository-updates.ts +40 -39
- package/src/console/updates/routes.ts +12 -0
- package/src/main.ts +13 -5
- package/src/network/pool/resolver.ts +1104 -1104
- package/src/observability/client-name.ts +22 -0
- package/src/observability/event-log.ts +5 -29
- package/src/observability/event-query.ts +690 -0
- package/src/observability/log-ring.ts +26 -2
- package/src/observability/log-sanitize.ts +118 -0
- package/src/observability/logger.ts +17 -13
- package/src/observability/metrics.ts +35 -0
- package/src/observability/payload-capture.ts +114 -41
- package/src/observability/payload-store.ts +249 -24
- package/src/observability/raw-event-writer.ts +118 -0
- package/src/observability/runtime-metrics.ts +71 -1
- package/src/observability/telemetry-buffer.ts +100 -17
- package/src/observability/telemetry-reconciliation.ts +417 -0
- package/src/persistence/boot.ts +20 -9
- package/src/persistence/db-pg.ts +13 -0
- package/src/persistence/db-sqlite.ts +569 -497
- package/src/persistence/dialect.ts +3 -2
- package/src/persistence/migrate.ts +181 -181
- package/src/persistence/schema.ts +1496 -1406
- package/src/persistence/shared-cache.ts +5 -1
- package/src/persistence/stores/api-key-store.ts +581 -581
- package/src/persistence/stores/telemetry-store.ts +124 -63
- package/src/protocol/reasoning-text.ts +30 -0
- package/src/protocol/registry.ts +2 -0
- package/src/protocol/response/chat.ts +33 -7
- package/src/protocol/response/codex.ts +14 -2
- package/src/protocol/response/messages.ts +5 -2
- package/src/providers/authentication/oauth-flow-store.ts +3 -10
- package/src/providers/bundled-discovery-providers.ts +122 -122
- package/src/providers/default-registry.ts +14 -0
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/probe-phases.ts +43 -7
- package/src/providers/discovery/probing-service.ts +70 -32
- package/src/providers/integrations/antigravity/antigravity.ts +9 -2
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
- package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
- package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
- package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
- package/src/providers/integrations/buddy/capabilities.ts +4 -0
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
- package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
- package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
- package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
- package/src/providers/integrations/claude/claude-quota.ts +2 -2
- package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
- package/src/providers/integrations/claude/claude.ts +3 -4
- package/src/providers/integrations/cline/cline.ts +94 -8
- package/src/providers/integrations/codex/codex.ts +61 -37
- package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
- package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
- package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
- package/src/providers/integrations/cursor/cursor-request.ts +11 -0
- package/src/providers/integrations/cursor/cursor.ts +29 -17
- package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
- package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
- package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
- package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
- package/src/providers/integrations/github/github.ts +3 -1
- package/src/providers/integrations/grok/grok-quota.ts +279 -279
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
- package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
- package/src/providers/integrations/hermes.ts +7 -1
- package/src/providers/integrations/inferhub.ts +201 -73
- package/src/providers/integrations/kilo/kilo.ts +152 -152
- package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
- package/src/providers/integrations/opencode/provider.ts +2 -0
- package/src/providers/integrations/openrouter/provider.ts +129 -129
- package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
- package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
- package/src/providers/integrations/trae/trae-headers.ts +17 -48
- package/src/providers/integrations/trae/trae-oauth.ts +4 -26
- package/src/providers/integrations/trae/trae-quota.ts +389 -384
- package/src/providers/integrations/trae/trae-realm.ts +15 -2
- package/src/providers/integrations/trae/trae.ts +22 -24
- package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
- package/src/providers/integrations/zcode/oauth.ts +1 -1
- package/src/providers/operations/account-health-service.ts +605 -279
- package/src/providers/provider-metadata.ts +12 -1
- package/src/providers/provider-model-definition.ts +373 -373
- package/src/providers/provider-probe.ts +3 -0
- package/src/providers/quota/quota-contracts.ts +3 -7
- package/src/providers/search/search-provider.ts +2 -6
- package/src/security/admission/contracts.ts +10 -0
- package/src/security/admission/in-memory-store.ts +89 -14
- package/src/security/admission/lua.ts +191 -182
- package/src/security/admission/redis-store.ts +30 -0
- package/src/security/admission/service.ts +18 -0
- package/src/security/api-key-auth-cache.ts +96 -0
- package/src/security/api-key-auth.ts +11 -100
- package/src/security/client-router-fingerprint.ts +3 -7
- package/src/security/ip-boundary.ts +7 -3
- package/src/shared/coerce.ts +67 -0
- package/src/shared/hash.ts +6 -0
- package/src/shared/time.ts +7 -0
- package/src/transport/dispatch/attempt-finalize.ts +26 -138
- package/src/transport/dispatch/attempt-loop.ts +103 -81
- package/src/transport/dispatch/capture-policy.ts +78 -0
- package/src/transport/dispatch/proxy-request.ts +4 -2
- package/src/transport/dispatch/streaming/encoder.ts +111 -5
- package/src/transport/dispatch/streaming/stream.ts +19 -2
- package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
- package/src/transport/failure-policy.ts +27 -0
- package/src/transport/guards/abuse-guard.ts +2 -2
- package/src/transport/guards/auth-guard.ts +17 -0
- package/src/transport/guards/body-policy.ts +3 -2
- package/src/transport/guards/concurrency-guard.ts +89 -13
- package/src/transport/guards/early-rejection-capture.ts +43 -3
- package/src/transport/guards/error-lifecycle.ts +10 -2
- package/src/transport/guards/guard-errors.ts +23 -0
- package/src/transport/guards/headroom-guard.ts +41 -7
- package/src/transport/guards/pipeline.ts +24 -0
- package/src/transport/guards/request-context.ts +2 -1
- package/src/transport/routing/admission.ts +51 -9
- package/src/transport/routing/bounded-lru.ts +49 -0
- package/src/transport/routing/candidate-index.ts +88 -0
- package/src/transport/routing/reservations.ts +121 -7
- package/src/transport/routing/round-robin.ts +10 -22
- package/src/transport/routing/route-catalog.ts +26 -13
- package/src/transport/routing/route-model.ts +22 -2
- package/src/transport/routing/router.ts +53 -31
- package/src/transport/surface/chat/encode.ts +2 -3
- package/src/transport/surface/chat/parse.ts +2 -1
- package/src/transport/surface/dialects.ts +11 -3
- package/src/transport/surface/messages/encode.ts +2 -1
- package/src/transport/surface/messages/parse.ts +3 -1
- package/src/transport/surface/messages/stream.ts +2 -6
- package/src/workers/maintenance-lease.ts +200 -0
- package/src/workers/scheduler.ts +101 -1
- package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
- package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
- package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
- package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
- package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
- package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
- package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
- package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
- package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
- package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
- package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
- package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
- package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
- package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
- package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
- package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/scripts/build/aot.ts +0 -143
- package/scripts/build/build.ts +0 -24
- package/scripts/build/compile.ts +0 -88
- package/scripts/build/restart.ts +0 -120
- package/scripts/build/start.ts +0 -25
- package/src/observability/event-compaction.ts +0 -317
- package/src/observability/event-day-index.ts +0 -440
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* snapshot and then subscribes over SSE (`console/observability/logs.ts`); nothing
|
|
5
5
|
* here touches the database — restarts clear the tail by design.
|
|
6
6
|
*/
|
|
7
|
+
import { sanitizeLogValue } from './log-sanitize';
|
|
7
8
|
|
|
8
9
|
export const CONSOLE_LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
|
|
9
10
|
|
|
@@ -53,6 +54,19 @@ export interface ConsoleLogMetadata {
|
|
|
53
54
|
readonly details?: Record<string, unknown>;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Bounds one structured `details` object.
|
|
59
|
+
*
|
|
60
|
+
* Returns a fresh record rather than mutating, so a caller that keeps its own
|
|
61
|
+
* reference to the object it passed does not find it rewritten.
|
|
62
|
+
*/
|
|
63
|
+
function sanitizeLogDetails(details: Record<string, unknown>): Record<string, unknown> {
|
|
64
|
+
const sanitized = sanitizeLogValue(details);
|
|
65
|
+
return typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized)
|
|
66
|
+
? (sanitized as Record<string, unknown>)
|
|
67
|
+
: {};
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
export function pushStructuredConsoleLog(
|
|
57
71
|
level: ConsoleLogLevel,
|
|
58
72
|
msg: string,
|
|
@@ -67,8 +81,12 @@ export type ConsoleLogEvent =
|
|
|
67
81
|
| { readonly type: "line"; readonly line: ConsoleLogLine }
|
|
68
82
|
| { readonly type: "clear" };
|
|
69
83
|
|
|
70
|
-
|
|
71
|
-
const
|
|
84
|
+
/** Lines retained. Exported so the console can state the tail's depth. */
|
|
85
|
+
export const CONSOLE_LOG_CAPACITY = 500;
|
|
86
|
+
/** Code units kept per rendered message. Exported for the same reason. */
|
|
87
|
+
export const CONSOLE_LOG_MAX_MESSAGE_CODE_UNITS = 2_000;
|
|
88
|
+
const CAPACITY = CONSOLE_LOG_CAPACITY;
|
|
89
|
+
const MAX_MSG = CONSOLE_LOG_MAX_MESSAGE_CODE_UNITS;
|
|
72
90
|
|
|
73
91
|
const lines: ConsoleLogLine[] = [];
|
|
74
92
|
// Start index of the live window: eviction advances the pointer instead of
|
|
@@ -82,6 +100,12 @@ function appendLine(line: Omit<ConsoleLogLine, "ts" | "msg"> & { readonly msg: s
|
|
|
82
100
|
ts: new Date().toISOString(),
|
|
83
101
|
...line,
|
|
84
102
|
msg: line.msg.length > MAX_MSG ? `${line.msg.slice(0, MAX_MSG)}…` : line.msg,
|
|
103
|
+
// `details` is caller-supplied and never routed through the logger, so it
|
|
104
|
+
// is the one field that could arrive unbounded: a structured line carrying
|
|
105
|
+
// a whole response body would make this 500-line ring hold a fraction of
|
|
106
|
+
// one event. Bounded by the same sanitizer the logger uses, so the two
|
|
107
|
+
// paths cannot disagree about what "bounded" means.
|
|
108
|
+
...(line.details === undefined ? {} : { details: sanitizeLogDetails(line.details) }),
|
|
85
109
|
};
|
|
86
110
|
lines.push(stored);
|
|
87
111
|
if (lines.length - ringStart > CAPACITY) ringStart += 1;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded structural sanitization for log payloads.
|
|
3
|
+
*
|
|
4
|
+
* Its own module because both directions need it: `logger.ts` bounds what it
|
|
5
|
+
* hands Pino and the console ring, and `log-ring.ts` bounds structured
|
|
6
|
+
* `details` pushed by callers that never go through the logger. Putting it in
|
|
7
|
+
* either one would be an import cycle.
|
|
8
|
+
*
|
|
9
|
+
* Depth alone does not bound a log payload. A single multi-megabyte string
|
|
10
|
+
* field passes any depth cap, and so does an array of ten thousand short
|
|
11
|
+
* items — either is copied in full on the request path, and a log call that
|
|
12
|
+
* allocates megabytes to describe an error makes the error worse. Every
|
|
13
|
+
* dimension gets a cap, and every marker is the same stable `[truncated]` so a
|
|
14
|
+
* bounded line is recognizable as bounded rather than looking like content.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Nesting levels kept. */
|
|
18
|
+
export const LOG_MAX_DEPTH = 5;
|
|
19
|
+
/** Array elements kept per array. */
|
|
20
|
+
export const LOG_MAX_ARRAY_ITEMS = 32;
|
|
21
|
+
/** Object keys kept per object. */
|
|
22
|
+
export const LOG_MAX_KEYS = 64;
|
|
23
|
+
/** Code units kept per string. */
|
|
24
|
+
export const LOG_MAX_STRING_CODE_UNITS = 4_096;
|
|
25
|
+
/**
|
|
26
|
+
* Total nodes visited per copy.
|
|
27
|
+
*
|
|
28
|
+
* The whole-copy cap, and the one that actually stops a wide graph: depth and
|
|
29
|
+
* the per-container limits bound each dimension, but a million sibling keys at
|
|
30
|
+
* depth 2 passes all of them.
|
|
31
|
+
*/
|
|
32
|
+
export const LOG_MAX_NODES = 256;
|
|
33
|
+
|
|
34
|
+
/** Code units kept when rendering one console-ring line. */
|
|
35
|
+
export const LOG_RING_MAX_CODE_UNITS = 8_192;
|
|
36
|
+
|
|
37
|
+
/** Marker appended wherever content was dropped. */
|
|
38
|
+
export const LOG_TRUNCATED_MARKER = "[truncated]";
|
|
39
|
+
export const LOG_CIRCULAR_MARKER = "[circular]";
|
|
40
|
+
|
|
41
|
+
function isHighSurrogate(code: number): boolean {
|
|
42
|
+
return code >= 0xd800 && code <= 0xdbff;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Truncates without splitting a surrogate pair.
|
|
47
|
+
*
|
|
48
|
+
* Cutting between a high and low surrogate leaves a lone half, which renders
|
|
49
|
+
* as U+FFFD — a log line that looks like the data was corrupt rather than
|
|
50
|
+
* merely cut short.
|
|
51
|
+
*/
|
|
52
|
+
export function truncateLogString(value: string, maxCodeUnits: number): string {
|
|
53
|
+
if (value.length <= maxCodeUnits) return value;
|
|
54
|
+
const end =
|
|
55
|
+
maxCodeUnits > 0 && isHighSurrogate(value.charCodeAt(maxCodeUnits - 1))
|
|
56
|
+
? maxCodeUnits - 1
|
|
57
|
+
: maxCodeUnits;
|
|
58
|
+
return `${value.slice(0, end)}${LOG_TRUNCATED_MARKER}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A circular-safe, bounded copy.
|
|
63
|
+
*
|
|
64
|
+
* Cycle-guarded because the canonical redactor applied downstream recurses
|
|
65
|
+
* without one: a circular log arg would overflow the stack before redaction
|
|
66
|
+
* ran. Errors collapse to name+message — a stack is far too long for a tail
|
|
67
|
+
* line, and the message already carries the cause.
|
|
68
|
+
*
|
|
69
|
+
* The node budget is a mutable counter object rather than a returned value:
|
|
70
|
+
* a recursive walk that threaded remaining budget through every branch would
|
|
71
|
+
* silently unbound any subtree that forgot to pass it along.
|
|
72
|
+
*/
|
|
73
|
+
export function sanitizeLogValue(value: unknown): unknown {
|
|
74
|
+
return decycle(value, { remaining: LOG_MAX_NODES }, new WeakSet(), 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function decycle(
|
|
78
|
+
value: unknown,
|
|
79
|
+
budget: { remaining: number },
|
|
80
|
+
seen: WeakSet<object>,
|
|
81
|
+
depth: number,
|
|
82
|
+
): unknown {
|
|
83
|
+
if (typeof value === "string") return truncateLogString(value, LOG_MAX_STRING_CODE_UNITS);
|
|
84
|
+
if (value === null || typeof value !== "object") return value;
|
|
85
|
+
if (seen.has(value)) return LOG_CIRCULAR_MARKER;
|
|
86
|
+
if (depth > LOG_MAX_DEPTH) return LOG_TRUNCATED_MARKER;
|
|
87
|
+
if (budget.remaining <= 0) return LOG_TRUNCATED_MARKER;
|
|
88
|
+
budget.remaining -= 1;
|
|
89
|
+
if (value instanceof Error) {
|
|
90
|
+
return {
|
|
91
|
+
name: truncateLogString(value.name, LOG_MAX_STRING_CODE_UNITS),
|
|
92
|
+
message: truncateLogString(value.message, LOG_MAX_STRING_CODE_UNITS),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
seen.add(value);
|
|
96
|
+
if (Array.isArray(value)) {
|
|
97
|
+
const kept = value
|
|
98
|
+
.slice(0, LOG_MAX_ARRAY_ITEMS)
|
|
99
|
+
.map((item) => decycle(item, budget, seen, depth + 1));
|
|
100
|
+
// The count is the point of a log line: "200 items, first 32 shown" reads
|
|
101
|
+
// very differently from "32 items".
|
|
102
|
+
if (value.length > LOG_MAX_ARRAY_ITEMS) {
|
|
103
|
+
kept.push(`[truncated:${value.length - LOG_MAX_ARRAY_ITEMS} more]`);
|
|
104
|
+
}
|
|
105
|
+
return kept;
|
|
106
|
+
}
|
|
107
|
+
const out: Record<string, unknown> = {};
|
|
108
|
+
const entries = Object.entries(value);
|
|
109
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
110
|
+
const [key, entry] = entries[index]!;
|
|
111
|
+
if (index >= LOG_MAX_KEYS) {
|
|
112
|
+
out[LOG_TRUNCATED_MARKER] = entries.length - LOG_MAX_KEYS;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
out[truncateLogString(key, LOG_MAX_STRING_CODE_UNITS)] = decycle(entry, budget, seen, depth + 1);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import pino from 'pino';
|
|
2
2
|
import { pushConsoleLog, type ConsoleLogLevel } from './log-ring';
|
|
3
|
+
import { LOG_RING_MAX_CODE_UNITS, sanitizeLogValue, truncateLogString } from './log-sanitize';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Structured logger using Pino for production-ready logging.
|
|
@@ -58,19 +59,13 @@ const logger = pino({
|
|
|
58
59
|
* without a cycle guard, so a circular log arg would overflow the stack
|
|
59
60
|
* before redaction even runs. Errors collapse to name+message - stacks are
|
|
60
61
|
* too long for a tail line and the message already carries the cause.
|
|
62
|
+
*
|
|
63
|
+
* Bounded in every dimension, not just depth: see the constants above. The
|
|
64
|
+
* node budget is carried by a mutable counter object rather than a return
|
|
65
|
+
* value, because a recursive walk that returned remaining budget would have to
|
|
66
|
+
* thread it through every branch and a missed one would silently unbound a
|
|
67
|
+
* subtree.
|
|
61
68
|
*/
|
|
62
|
-
function decycle(value: unknown, seen: WeakSet<object> = new WeakSet(), depth = 0): unknown {
|
|
63
|
-
if (value === null || typeof value !== "object") return value;
|
|
64
|
-
if (seen.has(value)) return "[circular]";
|
|
65
|
-
if (depth > 5) return "[truncated]";
|
|
66
|
-
if (value instanceof Error) return { name: value.name, message: value.message };
|
|
67
|
-
seen.add(value);
|
|
68
|
-
if (Array.isArray(value)) return value.map((item) => decycle(item, seen, depth + 1));
|
|
69
|
-
const out: Record<string, unknown> = {};
|
|
70
|
-
for (const [key, entry] of Object.entries(value)) out[key] = decycle(entry, seen, depth + 1);
|
|
71
|
-
return out;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
69
|
/**
|
|
75
70
|
* Flatten a log call into one ring line. Args keep their real values: a log
|
|
76
71
|
* line whose credential was rewritten into `***REDACTED***` is
|
|
@@ -79,7 +74,7 @@ function decycle(value: unknown, seen: WeakSet<object> = new WeakSet(), depth =
|
|
|
79
74
|
*/
|
|
80
75
|
function safeLogValue(value: unknown): unknown {
|
|
81
76
|
try {
|
|
82
|
-
return
|
|
77
|
+
return sanitizeLogValue(value);
|
|
83
78
|
} catch {
|
|
84
79
|
return "[unserializable args]";
|
|
85
80
|
}
|
|
@@ -98,6 +93,10 @@ function formatRingMessage(msg: string, args: readonly unknown[]): string {
|
|
|
98
93
|
} catch {
|
|
99
94
|
rendered = "[unserializable args]";
|
|
100
95
|
}
|
|
96
|
+
// Bounded on the way out too: the copy above caps each field, but a few
|
|
97
|
+
// hundred capped fields still render to a line long enough that the ring —
|
|
98
|
+
// a fixed 500-entry buffer — holds a meaningful fraction of one log call.
|
|
99
|
+
rendered = truncateLogString(rendered, LOG_RING_MAX_CODE_UNITS);
|
|
101
100
|
return rendered.length > 0 ? `${msg} ${rendered}` : msg;
|
|
102
101
|
}
|
|
103
102
|
|
|
@@ -160,6 +159,11 @@ function safeErrorForPino(error: Error): Error {
|
|
|
160
159
|
}
|
|
161
160
|
|
|
162
161
|
function logError(msg: string, error: Error | undefined, args: readonly unknown[]): void {
|
|
162
|
+
// Same fast-path as `logArgs`, and for the same reason: `safeErrorForPino`
|
|
163
|
+
// copies every own enumerable field of the error, and an error from a failed
|
|
164
|
+
// upstream call carries the response body. That copy is the largest one in
|
|
165
|
+
// this module and it used to run unconditionally.
|
|
166
|
+
if (!logger.isLevelEnabled("error")) return;
|
|
163
167
|
const safe = safeLogArgs(args);
|
|
164
168
|
const ringError = error === undefined ? undefined : safeErrorForRing(error);
|
|
165
169
|
const pinoError = error === undefined ? undefined : safeErrorForPino(error);
|
|
@@ -279,10 +279,21 @@ export class PrometheusRegistry {
|
|
|
279
279
|
readonly cartethyia_ip_abuse_keys: GaugeMetric;
|
|
280
280
|
readonly cartethyia_quota_cache_entries: GaugeMetric;
|
|
281
281
|
readonly cartethyia_model_catalog_entries: GaugeMetric;
|
|
282
|
+
readonly cartethyia_process_in_flight: GaugeMetric;
|
|
283
|
+
readonly cartethyia_process_concurrency_limit: GaugeMetric;
|
|
284
|
+
readonly cartethyia_telemetry_backlog_items: GaugeMetric;
|
|
285
|
+
readonly cartethyia_telemetry_backlog_bytes: GaugeMetric;
|
|
282
286
|
readonly cartethyia_proxy_dial_proxy_resolved_total: CounterMetric;
|
|
283
287
|
readonly proxy_provider_adapter_load_ms: HistogramMetric;
|
|
284
288
|
readonly pool_cooldown_record_failed: CounterMetric;
|
|
285
289
|
readonly quota_cache_invalidate_failed: CounterMetric;
|
|
290
|
+
/** Payload frames dropped because the writer backlog was at its bound. */
|
|
291
|
+
readonly cartethyia_payload_backlog_dropped_total: CounterMetric;
|
|
292
|
+
/**
|
|
293
|
+
* Telemetry backfill conflicts: same (tenant, request) identity with
|
|
294
|
+
* different content. Reported, never overwritten.
|
|
295
|
+
*/
|
|
296
|
+
readonly cartethyia_telemetry_raw_conflicts_total: CounterMetric;
|
|
286
297
|
|
|
287
298
|
private readonly all: RenderableMetric[] = [];
|
|
288
299
|
|
|
@@ -343,6 +354,22 @@ export class PrometheusRegistry {
|
|
|
343
354
|
"cartethyia_memory_limit_bytes",
|
|
344
355
|
"Container/cgroup memory limit in bytes (0 when unknown)",
|
|
345
356
|
);
|
|
357
|
+
this.cartethyia_process_in_flight = this.gauge(
|
|
358
|
+
"cartethyia_process_in_flight",
|
|
359
|
+
"Gateway requests currently admitted into this process",
|
|
360
|
+
);
|
|
361
|
+
this.cartethyia_process_concurrency_limit = this.gauge(
|
|
362
|
+
"cartethyia_process_concurrency_limit",
|
|
363
|
+
"Effective in-flight ceiling for this process, derived from the memory limit",
|
|
364
|
+
);
|
|
365
|
+
this.cartethyia_telemetry_backlog_items = this.gauge(
|
|
366
|
+
"cartethyia_telemetry_backlog_items",
|
|
367
|
+
"Telemetry events queued for the next drain",
|
|
368
|
+
);
|
|
369
|
+
this.cartethyia_telemetry_backlog_bytes = this.gauge(
|
|
370
|
+
"cartethyia_telemetry_backlog_bytes",
|
|
371
|
+
"Estimated bytes queued for the next telemetry drain",
|
|
372
|
+
);
|
|
346
373
|
this.cartethyia_routing_roundrobin_entries = this.gauge(
|
|
347
374
|
"cartethyia_routing_roundrobin_entries",
|
|
348
375
|
"Round-robin routing state entries by scope",
|
|
@@ -377,6 +404,14 @@ export class PrometheusRegistry {
|
|
|
377
404
|
"quota_cache_invalidate_failed",
|
|
378
405
|
"Quota cache invalidations that failed",
|
|
379
406
|
);
|
|
407
|
+
this.cartethyia_payload_backlog_dropped_total = this.counter(
|
|
408
|
+
"cartethyia_payload_backlog_dropped_total",
|
|
409
|
+
"Payload captures dropped because the payload writer backlog was full",
|
|
410
|
+
);
|
|
411
|
+
this.cartethyia_telemetry_raw_conflicts_total = this.counter(
|
|
412
|
+
"cartethyia_telemetry_raw_conflicts_total",
|
|
413
|
+
"Telemetry backfill conflicts (same tenant/request identity, different content)",
|
|
414
|
+
);
|
|
380
415
|
}
|
|
381
416
|
|
|
382
417
|
counter(name: string, help: string, labelNames: readonly string[] = []): CounterMetric {
|
|
@@ -1,59 +1,93 @@
|
|
|
1
1
|
import type { CartethyiaDatabase } from "../persistence/db-handle";
|
|
2
2
|
import { DrizzleTelemetryStore } from "../persistence/stores/telemetry-store";
|
|
3
|
-
import { prunePayloadFrames, writePayloadFrame } from "./payload-store";
|
|
3
|
+
import { PayloadBacklogFullError, prunePayloadFrames, writePayloadFrame } from "./payload-store";
|
|
4
4
|
import { resolveTelemetryPayloadMaxBytes } from "../config/telemetry";
|
|
5
|
+
import { metrics } from "./metrics";
|
|
5
6
|
import { preferencesReaderFor } from "../persistence/tenant-preferences";
|
|
6
7
|
import type {
|
|
7
8
|
TelemetryPayloadDepth,
|
|
8
9
|
TelemetryPayloadMode,
|
|
9
|
-
TelemetryPayloadRetentionMinutes,
|
|
10
10
|
} from "../console/settings/contracts";
|
|
11
11
|
export type CaptureScope = "tenant" | "debug_session" | "operator_flag";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* How long a captured payload frame lives when the deployment has not chosen.
|
|
15
15
|
*
|
|
16
|
+
* An hour, not fifteen minutes: a frame is the only record of what a request
|
|
17
|
+
* actually sent and received, and the window is when an operator can still go
|
|
18
|
+
* back and read it. Fifteen minutes is shorter than the time between noticing
|
|
19
|
+
* a bad response and opening the drawer, so the frame was already gone by the
|
|
20
|
+
* time someone looked for it. The cost is disk, which the Data Management
|
|
21
|
+
* panel reports and a tenant can reclaim on demand.
|
|
22
|
+
*
|
|
16
23
|
* Exported so surfaces that report on disk usage quote the same window the
|
|
17
24
|
* capture path enforces, rather than a second copy of the default that would
|
|
18
25
|
* drift the first time this one changes.
|
|
19
26
|
*/
|
|
20
27
|
export function payloadRetentionMs(): number {
|
|
21
|
-
const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_RETENTION_MS ??
|
|
22
|
-
if (!Number.isFinite(raw) || raw < 1_000 || raw > MAX_PAYLOAD_RETENTION_MS) return
|
|
28
|
+
const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_RETENTION_MS ?? DEFAULT_PAYLOAD_RETENTION_MS);
|
|
29
|
+
if (!Number.isFinite(raw) || raw < 1_000 || raw > MAX_PAYLOAD_RETENTION_MS) return DEFAULT_PAYLOAD_RETENTION_MS;
|
|
23
30
|
return Math.floor(raw);
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
/**
|
|
27
|
-
* The
|
|
28
|
-
*
|
|
29
|
-
* Two things depend on this being a hard ceiling rather than a suggestion.
|
|
34
|
+
* The fallback window, exported so a test can assert the default without
|
|
35
|
+
* pinning the number.
|
|
30
36
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* a year while the prune assumed an hour would have live frames deleted out
|
|
35
|
-
* from under them.
|
|
36
|
-
*
|
|
37
|
-
* The disk-usage panel counts a frame as reclaimable once its file is older
|
|
38
|
-
* than the same window. Without a ceiling it would offer a reclaim button for
|
|
39
|
-
* frames that are still live, and the operator would press it and see
|
|
40
|
-
* "nothing to reclaim".
|
|
37
|
+
* A literal in a test is a veto on changing policy: the assertion fails the
|
|
38
|
+
* moment the default moves, so the number stops being a choice and becomes
|
|
39
|
+
* whatever the test happened to be written against.
|
|
41
40
|
*/
|
|
41
|
+
export const DEFAULT_PAYLOAD_RETENTION_MS = 60 * 60_000;
|
|
42
|
+
|
|
43
|
+
/** Upper bound for a deployment-level retention override, not a tenant setting. */
|
|
42
44
|
export const MAX_PAYLOAD_RETENTION_MS = 7 * 24 * 60 * 60_000;
|
|
43
45
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Truncates a string to at most `maxBytes` UTF-8 bytes without splitting a
|
|
48
|
+
* code point.
|
|
49
|
+
*
|
|
50
|
+
* The budget contract everywhere in this module is **bytes**, not UTF-16 code
|
|
51
|
+
* units: `.length` on a CJK-heavy body counts one per character while the same
|
|
52
|
+
* text costs three bytes on the wire and on disk, so a "1 MiB" cap kept ~3 MiB
|
|
53
|
+
* of CJK content and dropped 3× more Latin content than the operator asked
|
|
54
|
+
* for. Slicing at a raw byte boundary can split a multi-byte sequence; this
|
|
55
|
+
* helper backs up to the last complete sequence so the truncated text stays
|
|
56
|
+
* valid.
|
|
57
|
+
*/
|
|
58
|
+
export function truncateUtf8(value: string, maxBytes: number): string {
|
|
59
|
+
if (maxBytes <= 0) return "";
|
|
60
|
+
const totalBytes = Buffer.byteLength(value, "utf8");
|
|
61
|
+
if (totalBytes <= maxBytes) return value;
|
|
62
|
+
const sliced = Buffer.from(value, "utf8").subarray(0, maxBytes);
|
|
63
|
+
// Walk back from the cut (at most 3 bytes: the longest UTF-8 sequence is 4
|
|
64
|
+
// bytes) to the lead byte of the sequence the cut landed inside, then drop
|
|
65
|
+
// that whole sequence. A cut that lands between sequences stays put.
|
|
66
|
+
for (let back = 0; back < 4 && sliced.length - back > 0; back += 1) {
|
|
67
|
+
const byte = sliced[sliced.length - back - 1]!;
|
|
68
|
+
if ((byte & 0xc0) !== 0x80) {
|
|
69
|
+
// This is a lead byte (or ASCII): the sequence it starts has n bytes.
|
|
70
|
+
const sequenceLength =
|
|
71
|
+
byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
|
|
72
|
+
// The cut is `back` bytes into this sequence. If the sequence does not
|
|
73
|
+
// fully fit within the slice, drop the whole sequence.
|
|
74
|
+
if (back + 1 < sequenceLength) {
|
|
75
|
+
return sliced.subarray(0, sliced.length - back - 1).toString("utf8");
|
|
76
|
+
}
|
|
77
|
+
return sliced.toString("utf8");
|
|
78
|
+
}
|
|
79
|
+
// Continuation byte: the lead byte is further back.
|
|
80
|
+
}
|
|
81
|
+
// Degenerate slice of only continuation bytes: nothing complete to keep.
|
|
82
|
+
return "";
|
|
49
83
|
}
|
|
50
84
|
|
|
51
85
|
/**
|
|
52
86
|
* Settings-gated payload capture. Request-event metadata is always retained;
|
|
53
87
|
* tenants opt into drawer capture through Settings → Privacy: `metadata`
|
|
54
88
|
* keeps only the Proxy→Provider request line, `full` keeps redacted bodies
|
|
55
|
-
* at the tenant's chosen depth
|
|
56
|
-
*
|
|
89
|
+
* at the tenant's chosen depth. Their lifetime is the tenant's Request detail
|
|
90
|
+
* window, or the deployment default when unset. Fail-closed on error.
|
|
57
91
|
*/
|
|
58
92
|
export async function resolvePayloadCapture(
|
|
59
93
|
db: CartethyiaDatabase,
|
|
@@ -72,7 +106,11 @@ export async function resolvePayloadCapture(
|
|
|
72
106
|
const prefs = await preferencesReaderFor(db).readPreferences(tenantId);
|
|
73
107
|
const mode = prefs?.telemetryPayloads;
|
|
74
108
|
const depth = prefs?.telemetryPayloadDepth;
|
|
75
|
-
const
|
|
109
|
+
const minutes = prefs?.telemetryWindowMinutes;
|
|
110
|
+
const retentionMs =
|
|
111
|
+
typeof minutes === "number" && Number.isFinite(minutes) && minutes > 0
|
|
112
|
+
? Math.min(MAX_PAYLOAD_RETENTION_MS, Math.floor(minutes * 60_000))
|
|
113
|
+
: payloadRetentionMs();
|
|
76
114
|
return {
|
|
77
115
|
ok: true,
|
|
78
116
|
mode: mode === "full" || mode === "metadata" || mode === "none" ? mode : "none",
|
|
@@ -86,7 +124,7 @@ export async function resolvePayloadCapture(
|
|
|
86
124
|
: depth === "full"
|
|
87
125
|
? "maximum"
|
|
88
126
|
: "minimum",
|
|
89
|
-
retentionMs
|
|
127
|
+
retentionMs,
|
|
90
128
|
};
|
|
91
129
|
} catch {
|
|
92
130
|
// A preference read that fails must not invent capture. Off keeps nothing.
|
|
@@ -135,7 +173,9 @@ export type PayloadCaptureState =
|
|
|
135
173
|
/** The preference read failed, and capture fails closed. */
|
|
136
174
|
| "skipped_preference_read_failed"
|
|
137
175
|
/** The storage write failed after the decision to capture. */
|
|
138
|
-
| "failed_write"
|
|
176
|
+
| "failed_write"
|
|
177
|
+
/** The payload writer's backlog was at its bound, so the frame was dropped. */
|
|
178
|
+
| "failed_backlog";
|
|
139
179
|
|
|
140
180
|
export interface PayloadCaptureInput {
|
|
141
181
|
tenantId: string;
|
|
@@ -150,8 +190,7 @@ export interface PayloadCaptureInput {
|
|
|
150
190
|
*/
|
|
151
191
|
maxBytes?: number;
|
|
152
192
|
/**
|
|
153
|
-
*
|
|
154
|
-
* retention, then the deployment default. Bounded by
|
|
193
|
+
* Retention for this capture; normally the deployment default. Bounded by
|
|
155
194
|
* {@link MAX_PAYLOAD_RETENTION_MS} either way.
|
|
156
195
|
*/
|
|
157
196
|
retentionMs?: number;
|
|
@@ -295,6 +334,8 @@ export function buildPayloadRecord(
|
|
|
295
334
|
const kept: unknown[] = [];
|
|
296
335
|
let accounted = 0;
|
|
297
336
|
const truncatedKeys = new Set<keyof StoredPayload>();
|
|
337
|
+
// A fully-dropped body's own byte size, for the marker the drawer renders.
|
|
338
|
+
const truncatedOriginalBytes = new Map<keyof StoredPayload, number>();
|
|
298
339
|
/**
|
|
299
340
|
* Whether a surface kept a *body* — not whether it is merely non-null.
|
|
300
341
|
*
|
|
@@ -311,13 +352,21 @@ export function buildPayloadRecord(
|
|
|
311
352
|
continue;
|
|
312
353
|
}
|
|
313
354
|
const text = JSON.stringify(body);
|
|
314
|
-
|
|
355
|
+
// Accounted in UTF-8 bytes: the cap is a storage budget, and the bytes a
|
|
356
|
+
// body costs on disk are its UTF-8 size. Code-unit accounting under-counts
|
|
357
|
+
// multi-byte content ~3x (CJK) and over-allocates Latin content.
|
|
358
|
+
const textBytes = Buffer.byteLength(text, "utf8");
|
|
359
|
+
if (accounted + textBytes > maxBytes) {
|
|
360
|
+
// Same drop-then-marker rule as before — only the accounting basis is
|
|
361
|
+
// now bytes. A partial head would be raw JSON text where the drawer
|
|
362
|
+
// renders a structured panel, so the body is kept whole or marked.
|
|
315
363
|
truncatedKeys.add(key);
|
|
364
|
+
truncatedOriginalBytes.set(key, textBytes);
|
|
316
365
|
stored[key] = null;
|
|
317
366
|
serialized.push("null");
|
|
318
367
|
continue;
|
|
319
368
|
}
|
|
320
|
-
accounted +=
|
|
369
|
+
accounted += textBytes;
|
|
321
370
|
stored[key] = body;
|
|
322
371
|
serialized.push(text);
|
|
323
372
|
kept.push(body);
|
|
@@ -332,7 +381,7 @@ export function buildPayloadRecord(
|
|
|
332
381
|
_original_bytes: originalBytes,
|
|
333
382
|
_hint: "Truncated by the capture cap — raise Settings → Telemetry payloads → Capture depth to keep more.",
|
|
334
383
|
});
|
|
335
|
-
for (const key of truncatedKeys) stored[key] = marker(
|
|
384
|
+
for (const key of truncatedKeys) stored[key] = marker(truncatedOriginalBytes.get(key) ?? 0);
|
|
336
385
|
return {
|
|
337
386
|
request_id: input.requestId ?? null,
|
|
338
387
|
captured_at: now,
|
|
@@ -385,7 +434,12 @@ export class TelemetryPayloadCapture {
|
|
|
385
434
|
this.store = new DrizzleTelemetryStore(db);
|
|
386
435
|
}
|
|
387
436
|
|
|
388
|
-
async capture(input: PayloadCaptureInput): Promise<{
|
|
437
|
+
async capture(input: PayloadCaptureInput): Promise<{
|
|
438
|
+
id: string;
|
|
439
|
+
expiresAt: Date;
|
|
440
|
+
/** Terminal capture state for the request row (`failed_backlog` on a dropped frame). */
|
|
441
|
+
payloadCaptureState?: PayloadCaptureState;
|
|
442
|
+
}> {
|
|
389
443
|
if (!isCaptureAllowed(input)) {
|
|
390
444
|
throw Object.assign(new Error("payload capture not opted in"), {
|
|
391
445
|
code: "capture_not_opted_in",
|
|
@@ -402,14 +456,33 @@ export class TelemetryPayloadCapture {
|
|
|
402
456
|
input.maxBytes ?? resolveTelemetryPayloadMaxBytes(),
|
|
403
457
|
retentionMs,
|
|
404
458
|
);
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
459
|
+
let reference;
|
|
460
|
+
try {
|
|
461
|
+
reference = await writePayloadFrame(record, record.expires_at, record._serialized
|
|
462
|
+
? {
|
|
463
|
+
requestId: record.request_id,
|
|
464
|
+
capturedAt: record.captured_at.toISOString(),
|
|
465
|
+
signals: record.signals,
|
|
466
|
+
bodies: record._serialized,
|
|
467
|
+
}
|
|
468
|
+
: undefined);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
if (error instanceof PayloadBacklogFullError) {
|
|
471
|
+
// The writer's backlog is at its bound: the frame is dropped here,
|
|
472
|
+
// counted, and surfaced on the request as its own terminal state —
|
|
473
|
+
// distinct from a storage failure, because "the disk is slow" and
|
|
474
|
+
// "capture burst past the backlog bound" need different fixes. No
|
|
475
|
+
// `telemetry_payloads` row is inserted: a row without a frame would
|
|
476
|
+
// read as a corrupt reference.
|
|
477
|
+
metrics.cartethyia_payload_backlog_dropped_total.inc(1);
|
|
478
|
+
return {
|
|
479
|
+
id: "",
|
|
480
|
+
expiresAt: record.expires_at,
|
|
481
|
+
payloadCaptureState: "failed_backlog",
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
413
486
|
// Typed columns only — bodies live in the frame file. The schema has no
|
|
414
487
|
// jsonb body column, so a body cannot be written to Postgres here.
|
|
415
488
|
return this.store.insertPayload({
|