cartethyia 2.5.10 → 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 +8 -0
- package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-l6XMnfr9.js} +1 -1
- package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-DwOzAW45.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-BT3Up3QA.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-6kYIUpzR.js} +1 -1
- package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.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-CS5mPuVF.js → LoginPage-CbphLvjg.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.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-vdja5ZPK.js → ProvidersPage-Dfb2896L.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.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-FadUOl9S.js → SetupPage-D4g6vOGc.js} +1 -1
- package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C93i14BD.js} +1 -1
- package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-SgQpymjm.js} +1 -1
- package/dist/dashboard/assets/{StudioPage-CM92fgJf.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
- package/dist/dashboard/assets/{UsagePage-BT4MyQeT.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-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
- package/dist/dashboard/assets/{app-B7pVRhJ1.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-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
- package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
- package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
- package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
- package/dist/dashboard/assets/{card-D5_gYIS_.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-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
- package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
- package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
- package/dist/dashboard/assets/{entry-C_oakV3z.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-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
- package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
- package/dist/dashboard/assets/{index-Bhey_rg_.js → index-Bblu6erR.js} +2 -2
- package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
- package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
- package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
- package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
- package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
- package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
- package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Cw07dioc.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-DrdA9oSM.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-CxVVB__N.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-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
- package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
- package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
- package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
- package/dist/dashboard/assets/{switch-sevsRrRo.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 -95
- package/scripts/cli/admin.ts +126 -128
- 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 +39 -30
- package/scripts/cli/controller.ts +145 -0
- package/scripts/cli/env.ts +99 -99
- package/scripts/cli/gateway.ts +264 -260
- package/scripts/cli/instance.ts +234 -224
- package/scripts/cli/main.ts +188 -167
- package/scripts/cli/platform.ts +64 -64
- package/scripts/cli/prompt.ts +217 -79
- package/scripts/cli/services.ts +88 -88
- package/scripts/cli/setup.ts +445 -445
- package/scripts/cli/start.ts +85 -85
- 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 +29 -4
- 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/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/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/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/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/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 +10 -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/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/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/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-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/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/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-Cd30Z0b9.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
- package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
- package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
- package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BT4MyQeT.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-Bvyr7D0u.js.gz +0 -0
- package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
- package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
- package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
- package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
- package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
- package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
- package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
- package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
- package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
- package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
- package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
- package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
- package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Cw07dioc.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-DrdA9oSM.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-CxVVB__N.js.gz +0 -0
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
- package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
- package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
- package/src/observability/event-compaction.ts +0 -317
- package/src/observability/event-day-index.ts +0 -439
|
@@ -6,6 +6,8 @@ import { DEFAULT_BOUNDS } from "../config/network";
|
|
|
6
6
|
import { type SourceSurface, type UsageRecord } from "../protocol/canonical-model";
|
|
7
7
|
import { log } from "./logger";
|
|
8
8
|
import { scrubSecrets } from "./redaction";
|
|
9
|
+
import { clientNameFromUserAgent } from "./client-name";
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Buffered durable telemetry writer. Coalesces per-request telemetry into
|
|
@@ -103,6 +105,12 @@ function telemetryEventRow(event: TelemetryEventInput) {
|
|
|
103
105
|
// `cachedInputTokens` stays — `health` aggregates it into the Overview
|
|
104
106
|
// cache-hit rate.
|
|
105
107
|
return {
|
|
108
|
+
// Stable identity: the request's own id. Retries reuse the materialized
|
|
109
|
+
// row (the buffer retries the same rows across its five attempts), and
|
|
110
|
+
// the (tenant_id, request_id) unique boundary makes a re-drain or a
|
|
111
|
+
// cross-process race converge to one row via ON CONFLICT DO NOTHING
|
|
112
|
+
// instead of double-charging.
|
|
113
|
+
id: event.requestId,
|
|
106
114
|
tenantId: event.tenantId,
|
|
107
115
|
requestId: event.requestId,
|
|
108
116
|
// Stamped here, not by the reader. A row without it has no real time, and a
|
|
@@ -115,6 +123,8 @@ function telemetryEventRow(event: TelemetryEventInput) {
|
|
|
115
123
|
endpoint: event.endpoint ?? null,
|
|
116
124
|
apiKeyId: event.apiKeyId ?? null,
|
|
117
125
|
userAgent: event.userAgent ?? null,
|
|
126
|
+
// Canonical client grouping, stamped once so SQL breakdowns group by it.
|
|
127
|
+
clientName: clientNameFromUserAgent(event.userAgent),
|
|
118
128
|
clientIp: event.clientIp ?? null,
|
|
119
129
|
providerId: event.providerId ?? null,
|
|
120
130
|
accountId: event.accountId ?? null,
|
|
@@ -153,6 +163,9 @@ export interface TelemetryBatchBufferOptions {
|
|
|
153
163
|
readonly maxBatch?: number;
|
|
154
164
|
readonly maxItems?: number;
|
|
155
165
|
readonly maxBytes?: number;
|
|
166
|
+
/** Test seam: a deterministic hook before a retry timer is installed. */
|
|
167
|
+
readonly onRetry?: (attempt: number) => void | Promise<void>;
|
|
168
|
+
readonly store?: Pick<DrizzleTelemetryStore, "insertEvents">;
|
|
156
169
|
}
|
|
157
170
|
|
|
158
171
|
/** Cooldown between `[telemetry] failed to record` log lines during a sustained drain outage. */
|
|
@@ -166,8 +179,8 @@ const DRAIN_RETRY_MAX_MS = 1_000;
|
|
|
166
179
|
import { exponentialBackoff, type BackoffOptions } from "../network/backoff";
|
|
167
180
|
|
|
168
181
|
/** Exponential retry delay for the `attempt`-th retry, clamped to the ceiling. */
|
|
169
|
-
function drainRetryDelayMs(attempt: number): number {
|
|
170
|
-
const options: BackoffOptions = { baseDelayMs
|
|
182
|
+
function drainRetryDelayMs(attempt: number, baseDelayMs = DRAIN_RETRY_BASE_MS): number {
|
|
183
|
+
const options: BackoffOptions = { baseDelayMs, maxDelayMs: DRAIN_RETRY_MAX_MS };
|
|
171
184
|
return exponentialBackoff(attempt, options);
|
|
172
185
|
}
|
|
173
186
|
|
|
@@ -192,6 +205,20 @@ export function adaptiveFlushIntervalMs(
|
|
|
192
205
|
return Math.max(ADAPTIVE_FLUSH_MIN_MS, Math.round(baseMs * (1 - fill)));
|
|
193
206
|
}
|
|
194
207
|
|
|
208
|
+
/** The telemetry backlog's current shape — see {@link TelemetryBatchBuffer.snapshot}. */
|
|
209
|
+
export interface TelemetryBacklogSnapshot {
|
|
210
|
+
/** Queued events waiting for the next drain. */
|
|
211
|
+
readonly items: number;
|
|
212
|
+
/** Estimated queued bytes. */
|
|
213
|
+
readonly bytes: number;
|
|
214
|
+
readonly itemCapacity: number;
|
|
215
|
+
readonly byteCapacity: number;
|
|
216
|
+
/** Whether a drain is currently in flight. */
|
|
217
|
+
readonly flushing: boolean;
|
|
218
|
+
/** Consecutive fully-failed drains; 0 when healthy. */
|
|
219
|
+
readonly drainFailureStreak: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
195
222
|
export class TelemetryBatchBuffer {
|
|
196
223
|
/**
|
|
197
224
|
* One entry per queued event. `bytes` is a conservative enqueue-time
|
|
@@ -199,16 +226,17 @@ export class TelemetryBatchBuffer {
|
|
|
199
226
|
* JSON serialization allocation on every request.
|
|
200
227
|
*/
|
|
201
228
|
private readonly queue: Array<{ event: TelemetryEventInput; bytes: number }> = [];
|
|
202
|
-
private readonly store: DrizzleTelemetryStore
|
|
229
|
+
private readonly store: Pick<DrizzleTelemetryStore, "insertEvents">;
|
|
203
230
|
private readonly flushIntervalMs: number;
|
|
204
231
|
private readonly maxBatch: number;
|
|
205
232
|
private readonly maxItems: number;
|
|
206
233
|
private readonly maxBytes: number;
|
|
234
|
+
private readonly onRetry: ((attempt: number) => void | Promise<void>) | undefined;
|
|
207
235
|
private queuedBytes = 0;
|
|
208
236
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
209
237
|
private stopped = false;
|
|
210
238
|
private flushing = false;
|
|
211
|
-
private flushWait: Promise<
|
|
239
|
+
private flushWait: Promise<boolean> = Promise.resolve(true);
|
|
212
240
|
private lastDrainOutageAt = 0;
|
|
213
241
|
private suppressedDrainOutages = 0;
|
|
214
242
|
/** Consecutive fully-failed drains; drives the cross-drain outage backoff. */
|
|
@@ -220,11 +248,12 @@ export class TelemetryBatchBuffer {
|
|
|
220
248
|
db: CartethyiaDatabase,
|
|
221
249
|
opts: TelemetryBatchBufferOptions = {},
|
|
222
250
|
) {
|
|
223
|
-
this.store = new DrizzleTelemetryStore(db);
|
|
251
|
+
this.store = opts.store ?? new DrizzleTelemetryStore(db);
|
|
224
252
|
this.flushIntervalMs = opts.flushIntervalMs ?? 500;
|
|
225
253
|
this.maxBatch = opts.maxBatch ?? 500;
|
|
226
254
|
this.maxItems = opts.maxItems ?? 10000;
|
|
227
255
|
this.maxBytes = opts.maxBytes ?? DEFAULT_BOUNDS.maxTelemetryQueueBytes;
|
|
256
|
+
this.onRetry = opts.onRetry;
|
|
228
257
|
this.scheduleNextFlush(this.flushIntervalMs);
|
|
229
258
|
}
|
|
230
259
|
|
|
@@ -246,10 +275,31 @@ export class TelemetryBatchBuffer {
|
|
|
246
275
|
if (typeof this.timer.unref === "function") this.timer.unref();
|
|
247
276
|
}
|
|
248
277
|
|
|
249
|
-
/**
|
|
278
|
+
/**
|
|
279
|
+
* The backlog's current shape, for gauges and the health surface.
|
|
280
|
+
*
|
|
281
|
+
* The buffer already drops on its bounds, but a drop nobody can see is
|
|
282
|
+
* indistinguishable from "no traffic" — an operator watching the console
|
|
283
|
+
* during an outage would see flat telemetry and conclude nothing was
|
|
284
|
+
* happening, when in fact the queue was full and discarding.
|
|
285
|
+
*/
|
|
286
|
+
snapshot(): TelemetryBacklogSnapshot {
|
|
287
|
+
return {
|
|
288
|
+
items: this.queue.length,
|
|
289
|
+
bytes: this.queuedBytes,
|
|
290
|
+
itemCapacity: this.maxItems,
|
|
291
|
+
byteCapacity: this.maxBytes,
|
|
292
|
+
flushing: this.flushing,
|
|
293
|
+
// The streak is the interesting one: a single failed drain is noise, a
|
|
294
|
+
// rising streak means the database is refusing writes and every event
|
|
295
|
+
// buffered past the cap is being dropped.
|
|
296
|
+
drainFailureStreak: this.drainFailureStreak,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
250
300
|
enqueue(event: TelemetryEventInput): void {
|
|
251
301
|
const bytes = estimateTelemetryEventBytes(event);
|
|
252
|
-
if (this.queue.length >= this.maxItems || this.queuedBytes + bytes > this.maxBytes) {
|
|
302
|
+
if (this.stopped || this.queue.length >= this.maxItems || this.queuedBytes + bytes > this.maxBytes) {
|
|
253
303
|
metrics.cartethyia_telemetry_dropped_total.inc();
|
|
254
304
|
return;
|
|
255
305
|
}
|
|
@@ -266,8 +316,8 @@ export class TelemetryBatchBuffer {
|
|
|
266
316
|
}
|
|
267
317
|
}
|
|
268
318
|
|
|
269
|
-
/** Drains the buffer with a single multi-row insert.
|
|
270
|
-
*
|
|
319
|
+
/** Drains the buffer with a single multi-row insert. Safe against concurrent
|
|
320
|
+
* invocation: overlapping callers await the in-flight
|
|
271
321
|
* drain and then re-enter until the queue is fully empty. Shutdown callers
|
|
272
322
|
* rely on this loop so they can observe an "empty queue" postcondition
|
|
273
323
|
* before closing the database pool, instead of returning immediately when
|
|
@@ -284,13 +334,16 @@ export class TelemetryBatchBuffer {
|
|
|
284
334
|
}
|
|
285
335
|
this.flushing = true;
|
|
286
336
|
this.flushWait = (async () => {
|
|
337
|
+
let completed: boolean;
|
|
287
338
|
try {
|
|
288
|
-
await this.drain();
|
|
339
|
+
completed = await this.drain();
|
|
289
340
|
} finally {
|
|
290
341
|
this.flushing = false;
|
|
291
342
|
}
|
|
343
|
+
return completed;
|
|
292
344
|
})();
|
|
293
|
-
await this.flushWait;
|
|
345
|
+
const completed = await this.flushWait;
|
|
346
|
+
if (!completed) return;
|
|
294
347
|
}
|
|
295
348
|
}
|
|
296
349
|
|
|
@@ -306,7 +359,23 @@ export class TelemetryBatchBuffer {
|
|
|
306
359
|
clearTimeout(this.timer);
|
|
307
360
|
this.timer = undefined;
|
|
308
361
|
}
|
|
309
|
-
if (options?.flush)
|
|
362
|
+
if (options?.flush) {
|
|
363
|
+
await this.flush();
|
|
364
|
+
} else if (this.flushing) {
|
|
365
|
+
// A retry interrupted by stop returns its in-flight batch to the head
|
|
366
|
+
// of the queue. Await that handoff before accounting for a drop.
|
|
367
|
+
await this.flushWait;
|
|
368
|
+
}
|
|
369
|
+
this.dropRemaining();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Drops only after shutdown has made the owner/timer unavailable. */
|
|
373
|
+
private dropRemaining(): void {
|
|
374
|
+
if (this.queue.length === 0) return;
|
|
375
|
+
metrics.cartethyia_telemetry_dropped_total.inc(this.queue.length);
|
|
376
|
+
metrics.cartethyia_telemetry_buffered.inc(-this.queue.length);
|
|
377
|
+
this.queue.length = 0;
|
|
378
|
+
this.queuedBytes = 0;
|
|
310
379
|
}
|
|
311
380
|
|
|
312
381
|
/**
|
|
@@ -320,8 +389,8 @@ export class TelemetryBatchBuffer {
|
|
|
320
389
|
return this.flush();
|
|
321
390
|
}
|
|
322
391
|
|
|
323
|
-
private async drain(): Promise<
|
|
324
|
-
if (this.queue.length === 0) return;
|
|
392
|
+
private async drain(): Promise<boolean> {
|
|
393
|
+
if (this.queue.length === 0) return true;
|
|
325
394
|
const batch = this.queue.splice(0, Math.min(this.queue.length, this.maxBatch));
|
|
326
395
|
// Reuse the enqueue-time serialized sizes — no second JSON pass.
|
|
327
396
|
const batchBytes = batch.reduce((sum, item) => sum + item.bytes, 0);
|
|
@@ -335,10 +404,23 @@ export class TelemetryBatchBuffer {
|
|
|
335
404
|
// drains probe the database less and less often. Unref'd so a
|
|
336
405
|
// mid-shutdown backoff cannot hold the event loop open.
|
|
337
406
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
338
|
-
|
|
407
|
+
if (this.stopped) {
|
|
408
|
+
this.queue.unshift(...batch);
|
|
409
|
+
this.queuedBytes += batchBytes;
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
await this.onRetry?.(attempt);
|
|
413
|
+
const backoff = setTimeout(
|
|
414
|
+
resolve,
|
|
415
|
+
drainRetryDelayMs(this.drainFailureStreak + attempt - 1),
|
|
416
|
+
);
|
|
339
417
|
if (typeof backoff.unref === "function") backoff.unref();
|
|
340
418
|
await promise;
|
|
341
|
-
if (this.stopped)
|
|
419
|
+
if (this.stopped) {
|
|
420
|
+
this.queue.unshift(...batch);
|
|
421
|
+
this.queuedBytes += batchBytes;
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
342
424
|
}
|
|
343
425
|
try {
|
|
344
426
|
await this.store.insertEvents(rows);
|
|
@@ -346,7 +428,7 @@ export class TelemetryBatchBuffer {
|
|
|
346
428
|
this.drainFailureStreak = 0;
|
|
347
429
|
this.drainBlockedUntil = 0;
|
|
348
430
|
metrics.cartethyia_telemetry_buffered.inc(-batch.length);
|
|
349
|
-
return;
|
|
431
|
+
return true;
|
|
350
432
|
} catch (error) {
|
|
351
433
|
if (attempt === 0) firstError = error;
|
|
352
434
|
lastError = error;
|
|
@@ -358,6 +440,7 @@ export class TelemetryBatchBuffer {
|
|
|
358
440
|
this.logDrainOutage(lastError, firstError);
|
|
359
441
|
metrics.cartethyia_telemetry_dropped_total.inc(batch.length);
|
|
360
442
|
metrics.cartethyia_telemetry_buffered.inc(-batch.length);
|
|
443
|
+
return true;
|
|
361
444
|
}
|
|
362
445
|
|
|
363
446
|
/**
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconciles the raw telemetry mirror back into the SQL table.
|
|
3
|
+
*
|
|
4
|
+
* The durable record is `telemetry_events`, written transactionally on the
|
|
5
|
+
* request path. The raw JSONL mirror is at-least-once: a drain that retried
|
|
6
|
+
* after a crash can have appended a line whose SQL row never landed (or landed
|
|
7
|
+
* and was rolled back). This job is how an operator recovers that gap, and how
|
|
8
|
+
* a deployment that ran before `telemetry_events` was written gets its history
|
|
9
|
+
* back.
|
|
10
|
+
*
|
|
11
|
+
* Design constraints, in the order they bind:
|
|
12
|
+
*
|
|
13
|
+
* - **Idempotent.** The unique index on `(tenant_id, request_id)` is the
|
|
14
|
+
* boundary: `ON CONFLICT DO NOTHING` plus `RETURNING` means a re-run
|
|
15
|
+
* inserts nothing and charges nothing. The checkpoint can therefore be
|
|
16
|
+
* advanced optimistically and still be safe.
|
|
17
|
+
* - **Resumable.** Progress is a `(file_name, byte_offset)` checkpoint written
|
|
18
|
+
* in the SAME transaction as the rows it covers, so a crash replays at most
|
|
19
|
+
* one chunk and never loses a completed one.
|
|
20
|
+
* - **Leased.** Several processes share one database; a lease row
|
|
21
|
+
* (`telemetry_reconciliation_state`) means one runner at a time. The lease
|
|
22
|
+
* expires, so a crashed runner does not wedge the job forever.
|
|
23
|
+
* - **Bounded.** Chunked reads, a chunk cap, and a transaction cap. A 512 MiB
|
|
24
|
+
* backlog drains over successive runs instead of one unbounded sweep.
|
|
25
|
+
* - **Never touches `telemetry_usage_totals`.** Those counters were charged at
|
|
26
|
+
* request time. Backfilling rows must not double-charge them, so this path
|
|
27
|
+
* inserts events only.
|
|
28
|
+
*/
|
|
29
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
30
|
+
import { readdir, open, stat } from "node:fs/promises";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
import type { CartethyiaDatabase } from "../persistence/db-handle";
|
|
34
|
+
import { telemetryEvents, telemetryReconciliationState } from "../persistence/schema";
|
|
35
|
+
import { TELEMETRY_EVENT_DIR } from "./event-log";
|
|
36
|
+
import { clientNameFromUserAgent } from "./client-name";
|
|
37
|
+
import type { TelemetryEventRecord } from "./event-log";
|
|
38
|
+
import { hourStamp } from "../shared/time";
|
|
39
|
+
|
|
40
|
+
/** The single job name: one reconciliation stream, one checkpoint. */
|
|
41
|
+
const JOB_NAME = "raw_to_sql_v1";
|
|
42
|
+
|
|
43
|
+
/** How long a runner holds the job. Generous, so a slow run is not stolen. */
|
|
44
|
+
const LEASE_MS = 30 * 60_000;
|
|
45
|
+
|
|
46
|
+
/** One read of a file. Bounds peak memory to one chunk, not one file. */
|
|
47
|
+
const CHUNK_BYTES = 256 * 1024;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Rows per transaction. Enough to amortise commit cost, small enough that a
|
|
51
|
+
* single transaction is never a long lock or a huge WAL burst.
|
|
52
|
+
*/
|
|
53
|
+
const ROWS_PER_TRANSACTION = 500;
|
|
54
|
+
|
|
55
|
+
/** Chunks per run. A backlog drains over successive runs, not one sweep. */
|
|
56
|
+
const MAX_CHUNKS_PER_RUN = 512;
|
|
57
|
+
|
|
58
|
+
/** Files per run, so a fresh backlog is not capped by one huge file. */
|
|
59
|
+
const MAX_FILES_PER_RUN = 64;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The current and previous UTC hour are skipped: the current one is still
|
|
63
|
+
* being appended to, and the previous one can still receive a batch that was
|
|
64
|
+
* flushed after the boundary. Those rows arrive through the request path
|
|
65
|
+
* itself — reconciling them would race the writer for no gain.
|
|
66
|
+
*/
|
|
67
|
+
const HOURS_TO_SKIP = 2;
|
|
68
|
+
|
|
69
|
+
export interface ReconcileOptions {
|
|
70
|
+
/** Report only: count the gap without writing anything. */
|
|
71
|
+
readonly verify?: boolean;
|
|
72
|
+
readonly now?: Date;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ReconcileReport {
|
|
76
|
+
readonly ran: boolean;
|
|
77
|
+
/** Why the run did nothing, when it did nothing. */
|
|
78
|
+
readonly skippedReason?: "lease_held" | "no_files" | "uptodate";
|
|
79
|
+
readonly leaseOwner: string;
|
|
80
|
+
readonly filesCompleted: number;
|
|
81
|
+
readonly eventsExamined: number;
|
|
82
|
+
readonly eventsInserted: number;
|
|
83
|
+
readonly conflicts: number;
|
|
84
|
+
readonly bytesRead: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The checkpoint: which file, and how far into it.
|
|
89
|
+
*
|
|
90
|
+
* `fileName` is compared as a string and files are processed in name order,
|
|
91
|
+
* so an hour that rolls over does not resurrect an older file: once the
|
|
92
|
+
* checkpoint passes a name, files sorting before it never come back.
|
|
93
|
+
*/
|
|
94
|
+
interface Checkpoint {
|
|
95
|
+
readonly fileName: string;
|
|
96
|
+
readonly byteOffset: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function nowMs(options: ReconcileOptions): number {
|
|
100
|
+
return (options.now ?? new Date()).getTime();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Files eligible for reconciliation: JSONL logs, newest-completed-first is irrelevant here. */
|
|
104
|
+
async function candidateFiles(): Promise<readonly string[]> {
|
|
105
|
+
const all = await readdir(TELEMETRY_EVENT_DIR).catch(() => [] as string[]);
|
|
106
|
+
// The current and previous hour are still being written; see HOURS_TO_SKIP.
|
|
107
|
+
const floor = hourStamp(new Date(Date.now() - HOURS_TO_SKIP * 3_600_000));
|
|
108
|
+
return all
|
|
109
|
+
.filter((file) => file.endsWith(".jsonl") && !file.endsWith(".compacted.jsonl"))
|
|
110
|
+
.filter((file) => file.slice(0, 13) < floor)
|
|
111
|
+
.sort();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parses the complete lines in one chunk.
|
|
116
|
+
*
|
|
117
|
+
* `carry` holds the trailing partial line: the chunk boundary falls mid-line
|
|
118
|
+
* whenever the writer's last append was split. It is prepended to the next
|
|
119
|
+
* chunk rather than parsed, because a half line is not a JSON document.
|
|
120
|
+
*/
|
|
121
|
+
function parseChunk(text: string): { readonly events: TelemetryEventRecord[]; readonly carry: string } {
|
|
122
|
+
const lines = text.split("\n");
|
|
123
|
+
// The last element is either "" (the chunk ended on a newline, so every line
|
|
124
|
+
// is complete) or a partial line (keep it for the next chunk).
|
|
125
|
+
const carry = lines.pop() ?? "";
|
|
126
|
+
const events: TelemetryEventRecord[] = [];
|
|
127
|
+
for (const line of lines) {
|
|
128
|
+
if (line.length === 0) continue;
|
|
129
|
+
let value: unknown;
|
|
130
|
+
try {
|
|
131
|
+
value = JSON.parse(line);
|
|
132
|
+
} catch {
|
|
133
|
+
// A torn line from a crash mid-append. Skipped rather than fatal: the
|
|
134
|
+
// rest of the file is still recoverable, and one bad line must not
|
|
135
|
+
// strand a whole backlog.
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (typeof value !== "object" || value === null) continue;
|
|
139
|
+
const event = value as Partial<TelemetryEventRecord>;
|
|
140
|
+
if (typeof event.tenantId !== "string" || typeof event.requestId !== "string") continue;
|
|
141
|
+
events.push(value as TelemetryEventRecord);
|
|
142
|
+
}
|
|
143
|
+
return { events, carry };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The columns the backfill inserts. Mirrors `telemetryEventRow` on the write path. */
|
|
147
|
+
function toRow(event: TelemetryEventRecord): typeof telemetryEvents.$inferInsert {
|
|
148
|
+
return {
|
|
149
|
+
// `id = requestId` is what makes the unique index a working idempotency
|
|
150
|
+
// boundary: a retry of the same request carries the same id.
|
|
151
|
+
id: event.requestId,
|
|
152
|
+
tenantId: event.tenantId!,
|
|
153
|
+
requestId: event.requestId,
|
|
154
|
+
sourceSurface: event.sourceSurface as typeof telemetryEvents.$inferInsert.sourceSurface,
|
|
155
|
+
requestedModel: event.requestedModel,
|
|
156
|
+
reportedModel: event.reportedModel,
|
|
157
|
+
requestedEffort: event.requestedEffort,
|
|
158
|
+
providerId: event.providerId,
|
|
159
|
+
accountId: event.accountId,
|
|
160
|
+
networkPoolId: event.networkPoolId,
|
|
161
|
+
endpoint: event.endpoint,
|
|
162
|
+
apiKeyId: event.apiKeyId,
|
|
163
|
+
userAgent: event.userAgent,
|
|
164
|
+
// Derived the same way ingestion derives it, so a backfilled row groups
|
|
165
|
+
// with the rows the request path wrote instead of forming its own bucket.
|
|
166
|
+
clientName: clientNameFromUserAgent(event.userAgent),
|
|
167
|
+
clientIp: event.clientIp,
|
|
168
|
+
latencyMs: event.latencyMs,
|
|
169
|
+
ttfbMs: event.ttfbMs,
|
|
170
|
+
stream: event.stream,
|
|
171
|
+
httpStatus: event.httpStatus,
|
|
172
|
+
status: event.status as typeof telemetryEvents.$inferInsert.status,
|
|
173
|
+
errorCategory: event.errorCategory,
|
|
174
|
+
errorOrigin: event.errorOrigin,
|
|
175
|
+
inputTokens: event.inputTokens,
|
|
176
|
+
cachedInputTokens: event.cachedInputTokens,
|
|
177
|
+
outputTokens: event.outputTokens,
|
|
178
|
+
reasoningTokens: event.reasoningTokens,
|
|
179
|
+
estimatedCostUsd:
|
|
180
|
+
event.estimatedCostUsd === null ? null : String(event.estimatedCostUsd),
|
|
181
|
+
createdAt: new Date(event.createdAt),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Acquires (or steals an expired) lease.
|
|
187
|
+
*
|
|
188
|
+
* `insert ... on conflict` in one statement: the row is created on first run
|
|
189
|
+
* and refreshed afterwards without a read-modify-write that two runners could
|
|
190
|
+
* interleave. `lease_expires_at` is the only arbiter, so a runner that died
|
|
191
|
+
* mid-file is replaced once its lease lapses rather than blocking forever.
|
|
192
|
+
*/
|
|
193
|
+
async function acquireLease(db: CartethyiaDatabase, owner: string, now: Date): Promise<boolean> {
|
|
194
|
+
const expiresAt = new Date(now.getTime() + LEASE_MS);
|
|
195
|
+
const result = await db
|
|
196
|
+
.insert(telemetryReconciliationState)
|
|
197
|
+
.values({
|
|
198
|
+
jobName: JOB_NAME,
|
|
199
|
+
leaseOwner: owner,
|
|
200
|
+
leaseExpiresAt: expiresAt,
|
|
201
|
+
fileName: "",
|
|
202
|
+
byteOffset: 0,
|
|
203
|
+
filesCompleted: 0,
|
|
204
|
+
eventsExamined: 0,
|
|
205
|
+
eventsInserted: 0,
|
|
206
|
+
conflicts: 0,
|
|
207
|
+
updatedAt: now,
|
|
208
|
+
})
|
|
209
|
+
.onConflictDoUpdate({
|
|
210
|
+
target: telemetryReconciliationState.jobName,
|
|
211
|
+
set: { leaseOwner: owner, leaseExpiresAt: expiresAt, updatedAt: now },
|
|
212
|
+
// Take the lease only when nobody holds a live one: an expired lease or
|
|
213
|
+
// no lease at all means the previous runner is gone.
|
|
214
|
+
where: sql`(${telemetryReconciliationState.leaseExpiresAt} is null or ${telemetryReconciliationState.leaseExpiresAt} < ${now} or ${telemetryReconciliationState.leaseOwner} = ${owner})`,
|
|
215
|
+
})
|
|
216
|
+
.returning({ owner: telemetryReconciliationState.leaseOwner });
|
|
217
|
+
return result.length > 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function releaseLease(db: CartethyiaDatabase, owner: string, now: Date): Promise<void> {
|
|
221
|
+
await db
|
|
222
|
+
.update(telemetryReconciliationState)
|
|
223
|
+
.set({ leaseExpiresAt: null, updatedAt: now })
|
|
224
|
+
.where(
|
|
225
|
+
and(
|
|
226
|
+
eq(telemetryReconciliationState.jobName, JOB_NAME),
|
|
227
|
+
eq(telemetryReconciliationState.leaseOwner, owner),
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function readCheckpoint(db: CartethyiaDatabase): Promise<Checkpoint> {
|
|
233
|
+
const [row] = await db
|
|
234
|
+
.select({
|
|
235
|
+
fileName: telemetryReconciliationState.fileName,
|
|
236
|
+
byteOffset: telemetryReconciliationState.byteOffset,
|
|
237
|
+
})
|
|
238
|
+
.from(telemetryReconciliationState)
|
|
239
|
+
.where(eq(telemetryReconciliationState.jobName, JOB_NAME))
|
|
240
|
+
.limit(1);
|
|
241
|
+
return { fileName: row?.fileName ?? "", byteOffset: row?.byteOffset ?? 0 };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* One run of the reconciliation job.
|
|
246
|
+
*
|
|
247
|
+
* Returns a report rather than throwing on "nothing to do": a scheduled or
|
|
248
|
+
* CLI invocation has to distinguish "no gap" from "could not run", and a
|
|
249
|
+
* thrown error would read as a failure of the whole command.
|
|
250
|
+
*/
|
|
251
|
+
export async function reconcileTelemetry(
|
|
252
|
+
db: CartethyiaDatabase,
|
|
253
|
+
options: ReconcileOptions = {},
|
|
254
|
+
): Promise<ReconcileReport> {
|
|
255
|
+
const owner = `${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
|
|
256
|
+
const now = new Date(nowMs(options));
|
|
257
|
+
const verify = options.verify === true;
|
|
258
|
+
|
|
259
|
+
const files = await candidateFiles();
|
|
260
|
+
if (files.length === 0) {
|
|
261
|
+
return {
|
|
262
|
+
ran: false,
|
|
263
|
+
skippedReason: "no_files",
|
|
264
|
+
leaseOwner: owner,
|
|
265
|
+
filesCompleted: 0,
|
|
266
|
+
eventsExamined: 0,
|
|
267
|
+
eventsInserted: 0,
|
|
268
|
+
conflicts: 0,
|
|
269
|
+
bytesRead: 0,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// `--verify` takes no lease: it only reads the mirror and counts, so two
|
|
274
|
+
// concurrent verifications cannot conflict and neither can block a real run.
|
|
275
|
+
if (!verify && !(await acquireLease(db, owner, now))) {
|
|
276
|
+
return {
|
|
277
|
+
ran: false,
|
|
278
|
+
skippedReason: "lease_held",
|
|
279
|
+
leaseOwner: owner,
|
|
280
|
+
filesCompleted: 0,
|
|
281
|
+
eventsExamined: 0,
|
|
282
|
+
eventsInserted: 0,
|
|
283
|
+
conflicts: 0,
|
|
284
|
+
bytesRead: 0,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const checkpoint = verify ? { fileName: "", byteOffset: 0 } : await readCheckpoint(db);
|
|
289
|
+
const pending = files.filter((file) => file > checkpoint.fileName);
|
|
290
|
+
if (pending.length === 0) {
|
|
291
|
+
if (!verify) await releaseLease(db, owner, new Date());
|
|
292
|
+
return {
|
|
293
|
+
ran: false,
|
|
294
|
+
skippedReason: "uptodate",
|
|
295
|
+
leaseOwner: owner,
|
|
296
|
+
filesCompleted: 0,
|
|
297
|
+
eventsExamined: 0,
|
|
298
|
+
eventsInserted: 0,
|
|
299
|
+
conflicts: 0,
|
|
300
|
+
bytesRead: 0,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let eventsExamined = 0;
|
|
305
|
+
let eventsInserted = 0;
|
|
306
|
+
let conflicts = 0;
|
|
307
|
+
let filesCompleted = 0;
|
|
308
|
+
let bytesRead = 0;
|
|
309
|
+
let chunks = 0;
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
for (const file of pending.slice(0, MAX_FILES_PER_RUN)) {
|
|
313
|
+
const path = join(TELEMETRY_EVENT_DIR, file);
|
|
314
|
+
const size = (await stat(path).catch(() => undefined))?.size ?? 0;
|
|
315
|
+
const handle = await open(path, "r").catch(() => undefined);
|
|
316
|
+
if (handle === undefined) {
|
|
317
|
+
// Deleted between the listing and the open — retention pruned it.
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
let offset = file === checkpoint.fileName ? checkpoint.byteOffset : 0;
|
|
321
|
+
let carry = "";
|
|
322
|
+
try {
|
|
323
|
+
while (offset < size && chunks < MAX_CHUNKS_PER_RUN) {
|
|
324
|
+
const buffer = Buffer.alloc(Math.min(CHUNK_BYTES, size - offset));
|
|
325
|
+
const { bytesRead: read } = await handle.read(buffer, 0, buffer.length, offset);
|
|
326
|
+
if (read <= 0) break;
|
|
327
|
+
chunks += 1;
|
|
328
|
+
bytesRead += read;
|
|
329
|
+
offset += read;
|
|
330
|
+
const parsed = parseChunk(carry + buffer.toString("utf8", 0, read));
|
|
331
|
+
carry = parsed.carry;
|
|
332
|
+
eventsExamined += parsed.events.length;
|
|
333
|
+
if (parsed.events.length > 0 && !verify) {
|
|
334
|
+
for (let start = 0; start < parsed.events.length; start += ROWS_PER_TRANSACTION) {
|
|
335
|
+
const slice = parsed.events.slice(start, start + ROWS_PER_TRANSACTION);
|
|
336
|
+
const inserted = await insertChunk(db, slice, file, offset);
|
|
337
|
+
eventsInserted += inserted.inserted;
|
|
338
|
+
conflicts += inserted.conflicts;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} finally {
|
|
343
|
+
await handle.close().catch(() => undefined);
|
|
344
|
+
}
|
|
345
|
+
// Only complete files are marked done: a run that hit its chunk cap
|
|
346
|
+
// resumes inside this file, so the offset must stay.
|
|
347
|
+
if (offset >= size) {
|
|
348
|
+
filesCompleted += 1;
|
|
349
|
+
if (!verify) await writeCheckpoint(db, file, 0, now);
|
|
350
|
+
} else if (!verify) {
|
|
351
|
+
await writeCheckpoint(db, file, offset, now);
|
|
352
|
+
}
|
|
353
|
+
if (chunks >= MAX_CHUNKS_PER_RUN) break;
|
|
354
|
+
}
|
|
355
|
+
} finally {
|
|
356
|
+
if (!verify) await releaseLease(db, owner, new Date());
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
ran: true,
|
|
361
|
+
leaseOwner: owner,
|
|
362
|
+
filesCompleted,
|
|
363
|
+
eventsExamined,
|
|
364
|
+
eventsInserted,
|
|
365
|
+
conflicts,
|
|
366
|
+
bytesRead,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Inserts one batch and advances the checkpoint in the SAME transaction.
|
|
372
|
+
*
|
|
373
|
+
* `ON CONFLICT DO NOTHING` + `RETURNING` is the idempotency boundary: the
|
|
374
|
+
* unique index on `(tenant_id, request_id)` decides, and only returned rows
|
|
375
|
+
* count. Usage totals are deliberately not touched — those counters were
|
|
376
|
+
* charged at request time, and re-charging from a backfill would inflate
|
|
377
|
+
* every dashboard figure for the affected window.
|
|
378
|
+
*/
|
|
379
|
+
async function insertChunk(
|
|
380
|
+
db: CartethyiaDatabase,
|
|
381
|
+
events: readonly TelemetryEventRecord[],
|
|
382
|
+
fileName: string,
|
|
383
|
+
byteOffset: number,
|
|
384
|
+
): Promise<{ readonly inserted: number; readonly conflicts: number }> {
|
|
385
|
+
return db.transaction(async (tx) => {
|
|
386
|
+
const rows = await tx
|
|
387
|
+
.insert(telemetryEvents)
|
|
388
|
+
.values(events.map(toRow))
|
|
389
|
+
.onConflictDoNothing()
|
|
390
|
+
.returning({ id: telemetryEvents.id });
|
|
391
|
+
await tx
|
|
392
|
+
.update(telemetryReconciliationState)
|
|
393
|
+
.set({
|
|
394
|
+
fileName,
|
|
395
|
+
byteOffset,
|
|
396
|
+
filesCompleted: sql`${telemetryReconciliationState.filesCompleted} + 0`,
|
|
397
|
+
eventsExamined: sql`${telemetryReconciliationState.eventsExamined} + ${events.length}`,
|
|
398
|
+
eventsInserted: sql`${telemetryReconciliationState.eventsInserted} + ${rows.length}`,
|
|
399
|
+
conflicts: sql`${telemetryReconciliationState.conflicts} + ${events.length - rows.length}`,
|
|
400
|
+
updatedAt: new Date(),
|
|
401
|
+
})
|
|
402
|
+
.where(eq(telemetryReconciliationState.jobName, JOB_NAME));
|
|
403
|
+
return { inserted: rows.length, conflicts: events.length - rows.length };
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function writeCheckpoint(
|
|
408
|
+
db: CartethyiaDatabase,
|
|
409
|
+
fileName: string,
|
|
410
|
+
byteOffset: number,
|
|
411
|
+
now: Date,
|
|
412
|
+
): Promise<void> {
|
|
413
|
+
await db
|
|
414
|
+
.update(telemetryReconciliationState)
|
|
415
|
+
.set({ fileName, byteOffset, updatedAt: now })
|
|
416
|
+
.where(eq(telemetryReconciliationState.jobName, JOB_NAME));
|
|
417
|
+
}
|