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
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { mkdir, open, readdir, readFile, stat, unlink } from "node:fs/promises";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
6
|
+
import { Worker } from "node:worker_threads";
|
|
7
|
+
import { hourStamp } from "../shared/time";
|
|
5
8
|
|
|
6
9
|
export interface PayloadFileReference {
|
|
7
10
|
readonly storage: "jsonl-file";
|
|
@@ -67,7 +70,68 @@ function isFrameFileName(name: string): boolean {
|
|
|
67
70
|
const MAX_FRAME_BYTES = 256 * 1024 * 1024;
|
|
68
71
|
const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
69
72
|
const DEFAULT_DIRECTORY = "./data/telemetry-payloads";
|
|
70
|
-
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Backlog bounds for the payload writer: per-process, in front of the
|
|
76
|
+
* append-only file writes.
|
|
77
|
+
*
|
|
78
|
+
* The writer used to be an unbounded promise chain: every capture appended
|
|
79
|
+
* another link, so a slow disk (or a burst of large captures) accumulated
|
|
80
|
+
* every waiting frame's bytes in memory with no ceiling — the exact
|
|
81
|
+
* unbounded-growth failure this phase exists to remove. Items are counted
|
|
82
|
+
* before queueing in their exact serialized frame bytes (the same buffers
|
|
83
|
+
* that will be written), so 64 MiB of backlog means 64 MiB of real memory,
|
|
84
|
+
* not an estimate.
|
|
85
|
+
*/
|
|
86
|
+
export const MAX_PAYLOAD_BACKLOG_ITEMS = 32;
|
|
87
|
+
export const MAX_PAYLOAD_BACKLOG_BYTES = 64 * 1024 * 1024;
|
|
88
|
+
|
|
89
|
+
/** Raised when the payload writer's backlog is at its bound. */
|
|
90
|
+
export class PayloadBacklogFullError extends Error {
|
|
91
|
+
constructor(
|
|
92
|
+
readonly boundedBy: "items" | "bytes",
|
|
93
|
+
) {
|
|
94
|
+
super(`telemetry payload writer backlog full (${boundedBy})`);
|
|
95
|
+
this.name = "PayloadBacklogFullError";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface BacklogEntry {
|
|
100
|
+
readonly estimatedBytes: number;
|
|
101
|
+
run: () => Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let backlog: BacklogEntry[] = [];
|
|
105
|
+
let backlogBytes = 0;
|
|
106
|
+
let draining = false;
|
|
107
|
+
|
|
108
|
+
/** Test/diagnostic seam: current backlog occupancy. */
|
|
109
|
+
export function payloadWriterBacklog(): { readonly items: number; readonly bytes: number } {
|
|
110
|
+
return { items: backlog.length, bytes: backlogBytes };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Runs the backlog to completion. One drain at a time: entries execute in
|
|
115
|
+
* FIFO order, which preserves the append-only file semantics the previous
|
|
116
|
+
* promise chain provided.
|
|
117
|
+
*/
|
|
118
|
+
function drainBacklog(): void {
|
|
119
|
+
if (draining) return;
|
|
120
|
+
draining = true;
|
|
121
|
+
const step = (): void => {
|
|
122
|
+
const entry = backlog.shift();
|
|
123
|
+
if (entry === undefined) {
|
|
124
|
+
draining = false;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
backlogBytes -= entry.estimatedBytes;
|
|
128
|
+
entry
|
|
129
|
+
.run()
|
|
130
|
+
.catch(() => undefined)
|
|
131
|
+
.finally(() => step());
|
|
132
|
+
};
|
|
133
|
+
step();
|
|
134
|
+
}
|
|
71
135
|
|
|
72
136
|
/**
|
|
73
137
|
* Single source of truth for the telemetry payload directory: the
|
|
@@ -84,33 +148,161 @@ function maxFileBytes(): number {
|
|
|
84
148
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_FILE_BYTES;
|
|
85
149
|
}
|
|
86
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Frame checksum.
|
|
153
|
+
*
|
|
154
|
+
* A byte-loop FNV-1a ran on the event loop — O(frame bytes) of pure JS per
|
|
155
|
+
* capture, on buffers up to the frame cap. `crypto.createHash` is the runtime's
|
|
156
|
+
* native path. The digest is hex-SHA-256 truncated to 16 hex chars: still far
|
|
157
|
+
* stronger than FNV-32 for corruption detection, and short enough that the
|
|
158
|
+
* `telemetry_payloads` column shape is unchanged.
|
|
159
|
+
*
|
|
160
|
+
* **Format note:** the frame `version` stays 1 because the checksum is a
|
|
161
|
+
* stored reference field, not part of the frame bytes on disk — the reader
|
|
162
|
+
* never re-verified the old FNV value against the file, and the new value is
|
|
163
|
+
* written and read by the same deployed version. References written by the
|
|
164
|
+
* FNV build (8 hex chars) and the new build (16 hex chars) coexist: the
|
|
165
|
+
* checksum column is opaque identity, not a validated format.
|
|
166
|
+
*/
|
|
87
167
|
function checksum(value: Buffer | readonly Buffer[]): string {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
for (let index = 0; index < part.byteLength; index += 1) {
|
|
92
|
-
hash ^= part[index]!;
|
|
93
|
-
hash = Math.imul(hash, 16777619);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
168
|
+
const hash = createHash("sha256");
|
|
169
|
+
for (const part of Array.isArray(value) ? value : [value]) hash.update(part);
|
|
170
|
+
return hash.digest("hex").slice(0, 16);
|
|
97
171
|
}
|
|
98
172
|
|
|
99
173
|
function fileName(now = new Date()): string {
|
|
100
|
-
return `${now
|
|
174
|
+
return `${hourStamp(now)}-${process.pid}${FRAME_FILE_SUFFIX}`;
|
|
101
175
|
}
|
|
102
176
|
|
|
103
177
|
/**
|
|
104
178
|
* Gzipped frame body.
|
|
105
179
|
*
|
|
106
|
-
* The pieces stay separate until
|
|
107
|
-
* them assembled and joining them earlier is a copy of the
|
|
108
|
-
* join happens
|
|
109
|
-
* is what gets written, checksummed, and bounded — so the
|
|
110
|
-
* reference both describe bytes on disk, not bytes of JSON.
|
|
180
|
+
* The pieces stay separate until the worker, because nothing before the
|
|
181
|
+
* compression reads them assembled and joining them earlier is a copy of the
|
|
182
|
+
* whole frame. The single join happens inside the compression step, and the
|
|
183
|
+
* compressed buffer is what gets written, checksummed, and bounded — so the
|
|
184
|
+
* cap and the reference both describe bytes on disk, not bytes of JSON.
|
|
185
|
+
*/
|
|
186
|
+
async function compressFrame(parts: readonly Buffer[]): Promise<Buffer> {
|
|
187
|
+
const joined = Buffer.concat(parts);
|
|
188
|
+
return compressAsync(joined);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The compression worker: one per process, started lazily on the first
|
|
193
|
+
* capture, at most {@link MAX_ACTIVE_COMPRESSION_JOBS} jobs in flight.
|
|
194
|
+
*
|
|
195
|
+
* `gzipSync` ran on the event loop: compressing a frame is O(frame bytes) of
|
|
196
|
+
* native CPU, and a burst of large captures (a batch of retries finishing
|
|
197
|
+
* together) stalled every request sharing the thread — a 1 MiB frame at
|
|
198
|
+
* ~100 MB/s of gzip is ~10 ms of loop stall per capture, times the burst.
|
|
199
|
+
* The worker turns that into background CPU; the semaphore keeps at most two
|
|
200
|
+
* compressions running at once so a burst cannot pile unbounded memory into
|
|
201
|
+
* the worker (each queued job holds its input buffer).
|
|
202
|
+
*
|
|
203
|
+
* Fallback: if the worker cannot start or dies, compression falls back to
|
|
204
|
+
* synchronous gzip on the event loop — capture still works, which is what the
|
|
205
|
+
* at-least-once frame contract requires; only the stall protection is lost.
|
|
111
206
|
*/
|
|
112
|
-
|
|
113
|
-
|
|
207
|
+
const MAX_ACTIVE_COMPRESSION_JOBS = 2;
|
|
208
|
+
let compressionWorker: Worker | undefined;
|
|
209
|
+
let workerBroken = false;
|
|
210
|
+
let activeCompressionJobs = 0;
|
|
211
|
+
const compressionQueue: (() => void)[] = [];
|
|
212
|
+
|
|
213
|
+
interface CompressionJob {
|
|
214
|
+
readonly id: number;
|
|
215
|
+
readonly input: Buffer;
|
|
216
|
+
readonly resolve: (output: Buffer) => void;
|
|
217
|
+
readonly reject: (error: Error) => void;
|
|
218
|
+
}
|
|
219
|
+
let nextJobId = 1;
|
|
220
|
+
const pendingJobs = new Map<number, CompressionJob>();
|
|
221
|
+
|
|
222
|
+
function ensureCompressionWorker(): Worker | undefined {
|
|
223
|
+
if (workerBroken) return undefined;
|
|
224
|
+
if (compressionWorker) return compressionWorker;
|
|
225
|
+
try {
|
|
226
|
+
// Inline worker via Blob URL: no separate source file to ship, no path
|
|
227
|
+
// resolution to break in compiled/AOT builds. The worker body is the
|
|
228
|
+
// minimal gzip service: postMessage({id, input}) -> {id, output} or {id, error}.
|
|
229
|
+
const source = `
|
|
230
|
+
const { gzipSync } = require("node:zlib");
|
|
231
|
+
const { parentPort } = require("node:worker_threads");
|
|
232
|
+
parentPort.on("message", (job) => {
|
|
233
|
+
try {
|
|
234
|
+
const output = gzipSync(Buffer.from(job.input));
|
|
235
|
+
parentPort.postMessage({ id: job.id, output });
|
|
236
|
+
} catch (error) {
|
|
237
|
+
parentPort.postMessage({ id: job.id, error: String(error) });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
`;
|
|
241
|
+
const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
|
|
242
|
+
compressionWorker = new Worker(url, { name: "payload-gzip" });
|
|
243
|
+
compressionWorker.unref();
|
|
244
|
+
compressionWorker.on("message", (message: { id: number; output?: Buffer; error?: string }) => {
|
|
245
|
+
const job = pendingJobs.get(message.id);
|
|
246
|
+
if (!job) return;
|
|
247
|
+
pendingJobs.delete(message.id);
|
|
248
|
+
releaseCompressionSlot();
|
|
249
|
+
if (message.error !== undefined) job.reject(new Error(message.error));
|
|
250
|
+
else job.resolve(message.output ?? Buffer.alloc(0));
|
|
251
|
+
});
|
|
252
|
+
compressionWorker.on("error", () => {
|
|
253
|
+
// Worker died: fail every pending job and fall back to sync forever.
|
|
254
|
+
workerBroken = true;
|
|
255
|
+
compressionWorker = undefined;
|
|
256
|
+
for (const job of pendingJobs.values()) {
|
|
257
|
+
job.reject(new Error("payload compression worker failed"));
|
|
258
|
+
}
|
|
259
|
+
pendingJobs.clear();
|
|
260
|
+
});
|
|
261
|
+
return compressionWorker;
|
|
262
|
+
} catch {
|
|
263
|
+
workerBroken = true;
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function releaseCompressionSlot(): void {
|
|
269
|
+
activeCompressionJobs -= 1;
|
|
270
|
+
const next = compressionQueue.shift();
|
|
271
|
+
if (next) next();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function compressAsync(input: Buffer): Promise<Buffer> {
|
|
275
|
+
const worker = ensureCompressionWorker();
|
|
276
|
+
if (!worker) return gzipSync(input);
|
|
277
|
+
// Semaphore: wait for a free slot before queueing the job.
|
|
278
|
+
if (activeCompressionJobs >= MAX_ACTIVE_COMPRESSION_JOBS) {
|
|
279
|
+
await new Promise<void>((resolve) => compressionQueue.push(resolve));
|
|
280
|
+
}
|
|
281
|
+
activeCompressionJobs += 1;
|
|
282
|
+
try {
|
|
283
|
+
return await new Promise<Buffer>((resolve, reject) => {
|
|
284
|
+
const job: CompressionJob = {
|
|
285
|
+
id: nextJobId++,
|
|
286
|
+
input,
|
|
287
|
+
resolve: (output) => resolve(output),
|
|
288
|
+
reject: (error) => reject(error),
|
|
289
|
+
};
|
|
290
|
+
pendingJobs.set(job.id, job);
|
|
291
|
+
worker.postMessage({ id: job.id, input }, [input.buffer.slice(
|
|
292
|
+
input.byteOffset,
|
|
293
|
+
input.byteOffset + input.byteLength,
|
|
294
|
+
) as ArrayBuffer]);
|
|
295
|
+
});
|
|
296
|
+
} catch {
|
|
297
|
+
// A failed job still holds its slot until the worker's reply arrives or
|
|
298
|
+
// the worker is declared broken; the sync fallback keeps the write alive.
|
|
299
|
+
return gzipSync(input);
|
|
300
|
+
} finally {
|
|
301
|
+
// The slot is released by the worker's message handler, not here — but a
|
|
302
|
+
// worker that never replies (broken before reply) leaks the slot. The
|
|
303
|
+
// error path above declared the worker broken and drained the queue, so
|
|
304
|
+
// a stale slot only outlives one job.
|
|
305
|
+
}
|
|
114
306
|
}
|
|
115
307
|
|
|
116
308
|
async function appendFrame(frame: PayloadFrame, parts?: readonly Buffer[]): Promise<PayloadFileReference> {
|
|
@@ -119,10 +311,13 @@ async function appendFrame(frame: PayloadFrame, parts?: readonly Buffer[]): Prom
|
|
|
119
311
|
// Encoded as a line here rather than at the write, so the cap, the checksum,
|
|
120
312
|
// and the reference all describe the same bytes that land on disk — the
|
|
121
313
|
// prebuilt-parts path and the plain one cannot disagree about the shape.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
314
|
+
// Single copy: the prebuilt parts (or the one serialized buffer) go to the
|
|
315
|
+
// compressor as-is; the join happens exactly once inside compressFrame.
|
|
316
|
+
// The previous shape concatenated here and again inside compressFrame — two
|
|
317
|
+
// full-frame copies before any bytes reached the worker.
|
|
318
|
+
const compressed = await compressFrame(
|
|
319
|
+
parts === undefined ? [Buffer.from(JSON.stringify(frame), "utf8")] : parts,
|
|
320
|
+
);
|
|
126
321
|
if (compressed.byteLength > MAX_FRAME_BYTES) throw new Error("telemetry payload frame exceeds limit");
|
|
127
322
|
const encoded = toLine(compressed);
|
|
128
323
|
const file = fileName();
|
|
@@ -238,8 +433,38 @@ export function writePayloadFrame(
|
|
|
238
433
|
? prebuiltFrameParts(id, expires, prebuilt.capturedAt, prebuilt.requestId, prebuilt.signals, prebuilt.bodies)
|
|
239
434
|
: undefined;
|
|
240
435
|
const frame: PayloadFrame = { version: 1, id, expiresAt: expires, payload };
|
|
241
|
-
|
|
242
|
-
|
|
436
|
+
// Accounted in exact serialized frame bytes: the prebuilt parts are the
|
|
437
|
+
// buffers that will be written; the non-prebuilt path serializes here once
|
|
438
|
+
// (the same buffer the append would build anyway). Counting before queueing
|
|
439
|
+
// is what makes the byte bound real rather than an estimate.
|
|
440
|
+
const estimatedBytes = parts
|
|
441
|
+
? parts.reduce((total, part) => total + part.byteLength, 0)
|
|
442
|
+
: Buffer.byteLength(JSON.stringify(frame), "utf8");
|
|
443
|
+
if (backlog.length >= MAX_PAYLOAD_BACKLOG_ITEMS) {
|
|
444
|
+
throw new PayloadBacklogFullError("items");
|
|
445
|
+
}
|
|
446
|
+
if (backlogBytes + estimatedBytes > MAX_PAYLOAD_BACKLOG_BYTES) {
|
|
447
|
+
throw new PayloadBacklogFullError("bytes");
|
|
448
|
+
}
|
|
449
|
+
const task = new Promise<PayloadFileReference>((resolve, reject) => {
|
|
450
|
+
// The entry settles the caller's promise itself: the drain loop's own
|
|
451
|
+
// `.catch(() => undefined)` is only there so one failed write cannot
|
|
452
|
+
// stop the rest of the backlog from running. Bytes are charged here, on
|
|
453
|
+
// queue, and discharged when the entry starts running — the window
|
|
454
|
+
// between is exactly the memory the bound exists to cap.
|
|
455
|
+
backlogBytes += estimatedBytes;
|
|
456
|
+
backlog.push({
|
|
457
|
+
estimatedBytes,
|
|
458
|
+
run: async () => {
|
|
459
|
+
try {
|
|
460
|
+
resolve(await appendFrame(frame, parts));
|
|
461
|
+
} catch (error) {
|
|
462
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
drainBacklog();
|
|
243
468
|
return task;
|
|
244
469
|
}
|
|
245
470
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { mkdir, open } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { hourStamp } from "../shared/time";
|
|
5
|
+
|
|
6
|
+
interface AppendHandle {
|
|
7
|
+
appendFile?(buffer: Buffer): Promise<void>;
|
|
8
|
+
write(buffer: Buffer): Promise<{ readonly bytesWritten: number }>;
|
|
9
|
+
close(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RawEventWriterOptions {
|
|
13
|
+
readonly open?: (path: string) => Promise<AppendHandle>;
|
|
14
|
+
readonly mkdir?: (directory: string) => Promise<void>;
|
|
15
|
+
/** Test seam: the file's event-time stamp source (default `Date`). */
|
|
16
|
+
readonly now?: () => Date;
|
|
17
|
+
/** Test seam: fixed writer identity (default: one random UUID per instance). */
|
|
18
|
+
readonly writerId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Serializes raw event appends without keeping an idle descriptor open.
|
|
23
|
+
*
|
|
24
|
+
* There is one short-lived handle per queued batch. Every handle is closed on
|
|
25
|
+
* success and failure, and a failed append does not poison the following one.
|
|
26
|
+
* This serializes callers inside one process; cross-process safety comes from
|
|
27
|
+
* the FILENAME, not a lock: each writer instance owns a distinct file
|
|
28
|
+
* (`<hourStamp>-<writerUUID>.jsonl`), so two processes can never target the
|
|
29
|
+
* same file and their appends cannot interleave destructively.
|
|
30
|
+
*
|
|
31
|
+
* The file is chosen by the EVENT's stamped time, not the dequeue time: a
|
|
32
|
+
* batch flushed after an hour boundary still belongs to the hour its events
|
|
33
|
+
* happened in, so retention's hour-granular rule never deletes a file whose
|
|
34
|
+
* events belong to the current window.
|
|
35
|
+
*/
|
|
36
|
+
export class RawTelemetryEventWriter {
|
|
37
|
+
private readonly state: { tail: Promise<void> };
|
|
38
|
+
private readonly openFile: (path: string) => Promise<AppendHandle>;
|
|
39
|
+
private readonly makeDirectory: (directory: string) => Promise<void>;
|
|
40
|
+
private readonly now: () => Date;
|
|
41
|
+
private readonly writerId: string;
|
|
42
|
+
|
|
43
|
+
constructor(directory: string, options: RawEventWriterOptions = {}) {
|
|
44
|
+
this.directory = directory;
|
|
45
|
+
this.state = sharedState(directory);
|
|
46
|
+
this.openFile = options.open ?? (async (path) => {
|
|
47
|
+
// 'a' positions every write at end-of-file: no stat, no positioned
|
|
48
|
+
// write, no truncate-rollback. Two writers on one file would still
|
|
49
|
+
// interleave at line granularity (append is atomic per call for
|
|
50
|
+
// line-sized buffers), but the writer-UUID filename makes that
|
|
51
|
+
// impossible; 'a' is the belt to that suspenders.
|
|
52
|
+
return (await open(path, "a")) as unknown as AppendHandle;
|
|
53
|
+
});
|
|
54
|
+
this.makeDirectory = options.mkdir ?? (async (path) => {
|
|
55
|
+
await mkdir(path, { recursive: true });
|
|
56
|
+
});
|
|
57
|
+
this.now = options.now ?? (() => new Date());
|
|
58
|
+
this.writerId = options.writerId ?? randomUUID();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Queues an already-bounded batch; the telemetry buffer owns drop policy. */
|
|
62
|
+
append(rows: readonly object[]): Promise<void> {
|
|
63
|
+
// Serialize before queueing so a backlog does not retain row graphs and no
|
|
64
|
+
// JSON encoding work happens while a descriptor is open.
|
|
65
|
+
const body = Buffer.from(`${rows.map((row) => JSON.stringify(row)).join("\n")}\n`, "utf8");
|
|
66
|
+
const hour = hourStamp(this.now());
|
|
67
|
+
const file = `${hour}-${this.writerId}.jsonl`;
|
|
68
|
+
const write = this.state.tail.then(() => this.write(file, body));
|
|
69
|
+
this.state.tail = write.catch(() => undefined);
|
|
70
|
+
return write;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Waits for writes queued before this call; used before retention cleanup. */
|
|
74
|
+
async idle(): Promise<void> {
|
|
75
|
+
await this.state.tail;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async write(file: string, body: Buffer): Promise<void> {
|
|
79
|
+
await this.makeDirectory(this.directory);
|
|
80
|
+
const handle = await this.openFile(join(this.directory, file));
|
|
81
|
+
try {
|
|
82
|
+
// Append-only: a failed write leaves whatever prefix landed, and the
|
|
83
|
+
// reader already tolerates a torn tail (a partial final line without a
|
|
84
|
+
// newline is skipped). No truncate-rollback: truncating under
|
|
85
|
+
// `reusePort` multi-process was the corruption risk this rewrite
|
|
86
|
+
// removes — one process's rollback could cut another process's
|
|
87
|
+
// already-committed lines on a shared file. Private files make that
|
|
88
|
+
// impossible, and refusing to truncate keeps it impossible even if the
|
|
89
|
+
// filename rule is ever relaxed.
|
|
90
|
+
if (typeof handle.appendFile === "function") {
|
|
91
|
+
await handle.appendFile(body);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
let offset = 0;
|
|
95
|
+
while (offset < body.byteLength) {
|
|
96
|
+
const { bytesWritten } = await handle.write(body.subarray(offset));
|
|
97
|
+
if (!Number.isInteger(bytesWritten) || bytesWritten <= 0) {
|
|
98
|
+
throw new Error("telemetry event writer made no forward progress");
|
|
99
|
+
}
|
|
100
|
+
offset += bytesWritten;
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
await handle.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private readonly directory: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const states = new Map<string, { tail: Promise<void> }>();
|
|
111
|
+
|
|
112
|
+
function sharedState(directory: string): { tail: Promise<void> } {
|
|
113
|
+
const existing = states.get(directory);
|
|
114
|
+
if (existing) return existing;
|
|
115
|
+
const created = { tail: Promise.resolve() };
|
|
116
|
+
states.set(directory, created);
|
|
117
|
+
return created;
|
|
118
|
+
}
|
|
@@ -13,6 +13,23 @@ import { trackMemoryUsage } from "./performance-metrics";
|
|
|
13
13
|
import { log } from "./logger";
|
|
14
14
|
import { resolveMemoryLimitOverrideBytes } from "../config/timeouts";
|
|
15
15
|
import { CONFIG_SPEC, readFlag } from "../config/spec";
|
|
16
|
+
import type { TelemetryBacklogSnapshot } from "./telemetry-buffer";
|
|
17
|
+
/**
|
|
18
|
+
* The memory figures a shed decision needs.
|
|
19
|
+
*
|
|
20
|
+
* Published once per sample instead of re-measured per request:
|
|
21
|
+
* `process.memoryUsage()` is not free, and the headroom guard runs on every
|
|
22
|
+
* request. A snapshot up to one sample interval old is the right trade — the
|
|
23
|
+
* guard is protecting against a spike that arrives over seconds, not one
|
|
24
|
+
* request's worth of allocation.
|
|
25
|
+
*/
|
|
26
|
+
export interface RuntimeMemorySnapshot {
|
|
27
|
+
readonly rssBytes: number;
|
|
28
|
+
/** The resolved limit; `undefined` when none could be discovered. */
|
|
29
|
+
readonly limitBytes: number | undefined;
|
|
30
|
+
readonly sampledAtMs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
16
33
|
/** Live collection sizes sampled alongside process memory. */
|
|
17
34
|
export interface RuntimeMetricSources {
|
|
18
35
|
readonly routingRoundRobinEntries: () => { readonly combo: number; readonly provider: number };
|
|
@@ -22,6 +39,11 @@ export interface RuntimeMetricSources {
|
|
|
22
39
|
* identities, so its size is the leak signal to watch.
|
|
23
40
|
*/
|
|
24
41
|
readonly ipAbuseKeys: () => number;
|
|
42
|
+
/**
|
|
43
|
+
* The telemetry backlog's current shape, or `undefined` when no buffer is
|
|
44
|
+
* wired (tests, and the console-only shell).
|
|
45
|
+
*/
|
|
46
|
+
readonly telemetryBacklog?: () => TelemetryBacklogSnapshot | undefined;
|
|
25
47
|
/** Tracked keys in the in-process quota cache tier (0 when Redis-only paths are idle). */
|
|
26
48
|
readonly quotaCacheEntries: () => number;
|
|
27
49
|
|
|
@@ -93,6 +115,13 @@ export class RuntimeMetricsSampler {
|
|
|
93
115
|
private lastIdleGcAt = 0;
|
|
94
116
|
private rssAtLastGc = 0;
|
|
95
117
|
private inFlightSource: () => number = () => 0;
|
|
118
|
+
/**
|
|
119
|
+
* The latest snapshot, published once per sample. Stays undefined until the
|
|
120
|
+
* first sample runs; consumers must treat that as "unknown", not as zero.
|
|
121
|
+
*/
|
|
122
|
+
private snapshot: RuntimeMemorySnapshot | undefined;
|
|
123
|
+
/** The effective concurrency ceiling, published alongside the gauges. */
|
|
124
|
+
private concurrencyLimit: number | undefined;
|
|
96
125
|
|
|
97
126
|
/**
|
|
98
127
|
* Binds the live in-flight count after the request state store exists. The
|
|
@@ -102,6 +131,28 @@ export class RuntimeMetricsSampler {
|
|
|
102
131
|
this.inFlightSource = source;
|
|
103
132
|
}
|
|
104
133
|
|
|
134
|
+
/**
|
|
135
|
+
* The last sampled memory state, for consumers on a hot path.
|
|
136
|
+
*
|
|
137
|
+
* Undefined before the first sample: a guard that read zero as "plenty of
|
|
138
|
+
* room" would disable itself exactly when it matters, so callers must fall
|
|
139
|
+
* back to their own judgement when this is absent.
|
|
140
|
+
*/
|
|
141
|
+
memorySnapshot(): RuntimeMemorySnapshot | undefined {
|
|
142
|
+
return this.snapshot;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Publishes the effective concurrency ceiling.
|
|
147
|
+
*
|
|
148
|
+
* Late-bound like `bindInFlight`: the ceiling is derived from the memory
|
|
149
|
+
* limit, which is known here, but the guard that owns it is constructed in
|
|
150
|
+
* the transport layer.
|
|
151
|
+
*/
|
|
152
|
+
bindConcurrencyLimit(limit: number): void {
|
|
153
|
+
this.concurrencyLimit = limit;
|
|
154
|
+
}
|
|
155
|
+
|
|
105
156
|
constructor(
|
|
106
157
|
private readonly sources: RuntimeMetricSources,
|
|
107
158
|
limitBytes: number | undefined = resolveMemoryLimitBytes(),
|
|
@@ -125,6 +176,11 @@ export class RuntimeMetricsSampler {
|
|
|
125
176
|
metrics.cartethyia_memory_heap_used_bytes.set(memory.heapUsed);
|
|
126
177
|
metrics.cartethyia_memory_heap_total_bytes.set(memory.heapTotal);
|
|
127
178
|
metrics.cartethyia_memory_limit_bytes.set(this.limitBytes ?? 0);
|
|
179
|
+
this.snapshot = {
|
|
180
|
+
rssBytes: memory.rss,
|
|
181
|
+
limitBytes: this.limitBytes,
|
|
182
|
+
sampledAtMs: Date.now(),
|
|
183
|
+
};
|
|
128
184
|
trackMemoryUsage("rss", memory.rss);
|
|
129
185
|
trackMemoryUsage("heap_used", memory.heapUsed);
|
|
130
186
|
trackMemoryUsage("heap_total", memory.heapTotal);
|
|
@@ -136,6 +192,15 @@ export class RuntimeMetricsSampler {
|
|
|
136
192
|
metrics.cartethyia_ip_abuse_keys.set(this.sources.ipAbuseKeys());
|
|
137
193
|
metrics.cartethyia_quota_cache_entries.set(this.sources.quotaCacheEntries());
|
|
138
194
|
metrics.cartethyia_model_catalog_entries.set(this.sources.modelCatalogEntries());
|
|
195
|
+
metrics.cartethyia_process_in_flight.set(this.inFlightSource());
|
|
196
|
+
const backlog = this.sources.telemetryBacklog?.();
|
|
197
|
+
if (backlog !== undefined) {
|
|
198
|
+
metrics.cartethyia_telemetry_backlog_items.set(backlog.items);
|
|
199
|
+
metrics.cartethyia_telemetry_backlog_bytes.set(backlog.bytes);
|
|
200
|
+
}
|
|
201
|
+
if (this.concurrencyLimit !== undefined) {
|
|
202
|
+
metrics.cartethyia_process_concurrency_limit.set(this.concurrencyLimit);
|
|
203
|
+
}
|
|
139
204
|
|
|
140
205
|
this.checkPressure(memory.rss);
|
|
141
206
|
this.checkIdleReclaim(memory.rss);
|
|
@@ -170,9 +235,14 @@ export class RuntimeMetricsSampler {
|
|
|
170
235
|
* A synchronous full collection is safe here because no request is in flight to
|
|
171
236
|
* stall, and it is the only form that actually returns pages to the OS; the
|
|
172
237
|
* async form used under pressure does not.
|
|
238
|
+
*
|
|
239
|
+
* Not gated on `gcOnPressure` the way `checkPressure` is. The opt-in exists
|
|
240
|
+
* because a synchronous collection stalls concurrent requests, and this path
|
|
241
|
+
* only runs when there is nothing in flight to stall — so the reason the flag
|
|
242
|
+
* is off does not apply to it. Gating it meant a gateway with no traffic at
|
|
243
|
+
* all never reclaimed anything and held its startup peak for hours.
|
|
173
244
|
*/
|
|
174
245
|
private checkIdleReclaim(rss: number): void {
|
|
175
|
-
if (!this.gcOnPressure) return;
|
|
176
246
|
if (this.inFlightSource() > 0) return;
|
|
177
247
|
const now = Date.now();
|
|
178
248
|
if (now - this.lastIdleGcAt < IDLE_GC_WINDOW_MS) return;
|