From 3f2e242c0dbde45ed3b0d8b608f0f5573000549f Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Fri, 9 Oct 2026 21:19:40 -0300 Subject: [PATCH 1/3] feat(observability): durable tenant CostStore with Postgres backend Add the CostStore port (reserve, commit, release, window) with an in-memory store, an optional Drizzle/Postgres subpath with atomic cap admission and a usage ledger, and an exported contract suite. The three cost guards accept an optional store and warn in production when spend is process-local. --- .changeset/durable-cost-store.md | 7 + .doc-bridge/capabilities.json | 2 +- .doc-bridge/index.json | 1700 ++++++++++++++--- .github/workflows/ci.yml | 5 + .size-limit.json | 14 +- .../content/docs/for-agents/observability.mdx | 5 + .../docs/reference/packages/observability.mdx | 1 + .../adrs/0044-durable-cost-store.md | 64 + docs/architecture/adrs/README.md | 1 + docs/stability/public-api-v1.json | 175 ++ packages/observability/README.md | 38 + packages/observability/package.json | 31 +- .../src/cost-guard-advanced-types.ts | 3 +- .../observability/src/cost-guard-advanced.ts | 22 +- .../src/cost-guard-multi-tenant.ts | 24 +- .../observability/src/cost-guard-store.ts | 46 + packages/observability/src/cost-guard.ts | 41 +- .../observability/src/cost-store-contract.ts | 136 ++ packages/observability/src/cost-store.ts | 308 +++ packages/observability/src/index.ts | 16 + packages/observability/src/postgres.ts | 199 ++ .../tests/cost-guard-store.test.ts | 109 ++ .../tests/cost-store-contract.test.ts | 55 + .../observability/tests/cost-store.test.ts | 73 + .../observability/tests/postgres-contract.ts | 39 + .../observability/tests/postgres-worker.ts | 17 + .../observability/tests/postgres-workerd.mjs | 25 + .../tests/postgres.integration.test.ts | 13 + packages/observability/tests/postgres.test.ts | 85 + .../tests/wrangler.postgres.jsonc | 6 + packages/observability/tsup.config.ts | 4 + pnpm-lock.yaml | 54 +- readme-standard-v1.json | 2 +- 33 files changed, 2962 insertions(+), 358 deletions(-) create mode 100644 .changeset/durable-cost-store.md create mode 100644 docs/architecture/adrs/0044-durable-cost-store.md create mode 100644 packages/observability/src/cost-guard-store.ts create mode 100644 packages/observability/src/cost-store-contract.ts create mode 100644 packages/observability/src/cost-store.ts create mode 100644 packages/observability/src/postgres.ts create mode 100644 packages/observability/tests/cost-guard-store.test.ts create mode 100644 packages/observability/tests/cost-store-contract.test.ts create mode 100644 packages/observability/tests/cost-store.test.ts create mode 100644 packages/observability/tests/postgres-contract.ts create mode 100644 packages/observability/tests/postgres-worker.ts create mode 100644 packages/observability/tests/postgres-workerd.mjs create mode 100644 packages/observability/tests/postgres.integration.test.ts create mode 100644 packages/observability/tests/postgres.test.ts create mode 100644 packages/observability/tests/wrangler.postgres.jsonc diff --git a/.changeset/durable-cost-store.md b/.changeset/durable-cost-store.md new file mode 100644 index 000000000..a29dde0bc --- /dev/null +++ b/.changeset/durable-cost-store.md @@ -0,0 +1,7 @@ +--- +'@agentskit/observability': minor +--- + +Add the `CostStore` port (`reserve`, `commit`, `release`, `window`) for durable, tenant-scoped spend accounting, with `createInMemoryCostStore` for development and an optional `@agentskit/observability/postgres` subpath: BYO Drizzle/node-postgres store with atomic cap admission, idempotent reservations, a usage ledger, exported schema and migration SQL. `@agentskit/observability/cost-store-contract` exports the contract suite for custom stores. + +`costGuard`, `multiTenantCostGuard` and `createAdvancedCostGuard` accept an optional `store`. With it, each priced call is written to the store and the budget is checked against spend recorded by every instance in the current accounting window (UTC month by default). Without it behavior is unchanged, except for a one-time warning when `NODE_ENV=production`, because process-local totals do not hold across instances or isolates. `costGuard` also accepts `tenant` to name the store partition. diff --git a/.doc-bridge/capabilities.json b/.doc-bridge/capabilities.json index 39dedb1ef..f0a907e54 100644 --- a/.doc-bridge/capabilities.json +++ b/.doc-bridge/capabilities.json @@ -7,7 +7,7 @@ "name": "AgentsKit", "root": "." }, - "contentHash": "067a1c851a5ecc5d384f61db7a0e5b5eaaffce52b7d67ce9a19e39bf95c49d83", + "contentHash": "67a7cb465aadd34c99468f94869e00495bcc92863f5819adaec1eb1e13e13d2c", "artifacts": { "index": ".doc-bridge/index.json", "llmsTxt": ".doc-bridge/llms.txt" diff --git a/.doc-bridge/index.json b/.doc-bridge/index.json index d95f3457c..a26e7a7c7 100644 --- a/.doc-bridge/index.json +++ b/.doc-bridge/index.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "contentHash": "067a1c851a5ecc5d384f61db7a0e5b5eaaffce52b7d67ce9a19e39bf95c49d83", + "contentHash": "67a7cb465aadd34c99468f94869e00495bcc92863f5819adaec1eb1e13e13d2c", "contentHashAlgo": "sha256-normalized-v1", - "generatedAt": "2026-10-08T06:33:24.391Z", + "generatedAt": "2026-10-10T00:13:36.699Z", "project": { "name": "AgentsKit", "root": "." @@ -3819,7 +3819,7 @@ "document", "human" ], - "contentHash": "95f6176a624aaa8a72d71b7f46a4aa84d72f3cf48238dbfa38ce42f49a12f40f" + "contentHash": "ef1b809976de1c818cb8a99839f929d4a9fa5bf13b634598684abffad563e6aa" }, { "id": "document:apps/docs-next/content/docs/reference/packages/overview.mdx", @@ -6064,6 +6064,18 @@ ], "contentHash": "e726f3b6b1f4d568fb9ee435cd3499673cfa86a42d7778a4b3d45b91e8d0426b" }, + { + "id": "document:docs/architecture/adrs/0044-durable-cost-store.md", + "type": "document", + "title": "ADR 0044 — Durable tenant cost store", + "path": "docs/architecture/adrs/0044-durable-cost-store.md", + "description": "Status: Proposed (implementation authorized; evidence review remains external)", + "tags": [ + "document", + "human" + ], + "contentHash": "9f0c1e6420206c1f6a813589cd231af59b72d22fb68a895a6bafed1d6f1b8203" + }, { "id": "document:docs/architecture/adrs/README.md", "type": "document", @@ -6074,7 +6086,7 @@ "document", "human" ], - "contentHash": "3cca1f08e78d34cd103a4b41ba860a4d5da92e4d7567be565ae363ec49dd2082" + "contentHash": "2bbd8aa572d63a9bd4b3a781597ef5181427c1a91ed34f5f18e88e5b3c720451" }, { "id": "document:docs/architecture/ecosystem-manifest.md", @@ -7286,7 +7298,7 @@ "document", "project" ], - "contentHash": "6829b6fc5e48db6d6ef4f74e9bee465a3938c276002e7ed5154d0e8efec2cbf7" + "contentHash": "f784f385ea347b6b418c6d84d67f7c6a100a21806c880640f9edbfdcf1635221" }, { "id": "document:packages/rag/CONVENTIONS.md", @@ -27895,7 +27907,7 @@ "ts", "src" ], - "contentHash": "4b4343b1366f4405400fb2d7155e11b84702cee443666f45c20d400511cad29b" + "contentHash": "a8038e7c3858f283889cae18cc5f3470c6df5d791763f0c446daf8567f0a4d11" }, { "id": "module:packages/observability/src/cost-guard-advanced.ts", @@ -27922,7 +27934,7 @@ "ts", "src" ], - "contentHash": "07048e517735836b3d0391c846119b17d3e49fe5279d9fcf9068cdd43256d6e8" + "contentHash": "75f302e345d2a4bd69fbdf08b4693eedaa50a59c2c6a019fcc1774913d5ed12f" }, { "id": "module:packages/observability/src/cost-guard-alert-sinks.ts", @@ -27956,7 +27968,25 @@ "ts", "src" ], - "contentHash": "6721ce5463e504d1d2f343897677dba248b3594ea4fbb331229854e75b808d37" + "contentHash": "6577cedb8e48993548baefaae96fbc910d157fe3e5d355345ff3d286c0fa5dc3" + }, + { + "id": "module:packages/observability/src/cost-guard-store.ts", + "type": "module", + "title": "cost-guard-store.ts", + "path": "packages/observability/src/cost-guard-store.ts", + "symbols": [ + "CostGuardStoreOptions", + "DurableSpendRecorder", + "PricedCall", + "durableSpendRecorder" + ], + "tags": [ + "module", + "ts", + "src" + ], + "contentHash": "9a078a80a71859f4200812350e14376f90fd49f0b602bed5e006e4054c18259b" }, { "id": "module:packages/observability/src/cost-guard.ts", @@ -27988,7 +28018,58 @@ "ts", "src" ], - "contentHash": "349e02829af7a7907cc897a9aae0598e3383e91726d53ded4ae351a68670e737" + "contentHash": "dbed63fe3d0288ce8719ea849709b60870774e988be501b23fe69a731785355d" + }, + { + "id": "module:packages/observability/src/cost-store-contract.ts", + "type": "module", + "title": "cost-store-contract.ts", + "path": "packages/observability/src/cost-store-contract.ts", + "symbols": [ + "CostStoreContractOptions", + "CostStoreContractResult", + "costStoreContract" + ], + "tags": [ + "module", + "ts", + "src" + ], + "contentHash": "1e5f787b952cbbd9bdfd9b809cbdf7a8c315ee9a3def164285420482c228f189" + }, + { + "id": "module:packages/observability/src/cost-store.ts", + "type": "module", + "title": "cost-store.ts", + "path": "packages/observability/src/cost-store.ts", + "symbols": [ + "CostCommitInput", + "CostReleaseInput", + "CostReservationError", + "CostReserveInput", + "CostReserveResult", + "CostSpendRecord", + "CostStore", + "CostStoreOptions", + "CostUsageEntry", + "CostWindow", + "CostWindowInput", + "InMemoryCostStore", + "InMemoryLedgerRow", + "createInMemoryCostStore", + "describeCostWindow", + "monthlyWindowKey", + "recordCostSpend", + "validateCostCommit", + "validateCostReserve", + "warnProcessLocalCostState" + ], + "tags": [ + "module", + "ts", + "src" + ], + "contentHash": "000d000f32f8ff879d6372400a7620b9574392e10947972ffa252c1339d51dfe" }, { "id": "module:packages/observability/src/datadog.ts", @@ -28083,10 +28164,22 @@ "CostAlertType", "CostCapWindow", "CostCaps", + "CostCommitInput", "CostGuardErrorHandler", "CostGuardMode", "CostGuardOptions", + "CostGuardStoreOptions", + "CostReleaseInput", + "CostReservationError", + "CostReserveInput", + "CostReserveResult", "CostSample", + "CostSpendRecord", + "CostStore", + "CostStoreOptions", + "CostUsageEntry", + "CostWindow", + "CostWindowInput", "DEFAULT_PRICES", "DEFAULT_SLO_TARGETS", "DatadogSinkConfig", @@ -28097,6 +28190,8 @@ "DevtoolsServerOptions", "FileTraceSink", "HttpBatchOptions", + "InMemoryCostStore", + "InMemoryLedgerRow", "LangSmithConfig", "LangSmithObserver", "LifecycleObserver", @@ -28155,6 +28250,7 @@ "createDevtoolsServer", "createFileTraceSink", "createInMemoryAuditStore", + "createInMemoryCostStore", "createProviderCounter", "createSignedAuditLog", "createTopologyGraph", @@ -28162,11 +28258,13 @@ "datadogSink", "diffState", "langsmith", + "monthlyWindowKey", "multiTenantCostGuard", "newRelicSink", "opentelemetry", "positionAt", "priceFor", + "recordCostSpend", "renderTraceViewerHtml", "replayBisect", "replayEvents", @@ -28181,7 +28279,7 @@ "ts", "src" ], - "contentHash": "798ff8456fffbdd982758c4594a1c08e21570e6c340a64159f3f590498771f18" + "contentHash": "1731cecba9b317493798a6b8e8c8d0840b7b613a60ae28eb8d246a21d6dc8aa3" }, { "id": "module:packages/observability/src/langsmith.ts", @@ -28234,6 +28332,26 @@ ], "contentHash": "1a3b7f76dd711d5eb0764274c557fed4a5d572e33cfedbb096a9b2b65b3b6b08" }, + { + "id": "module:packages/observability/src/postgres.ts", + "type": "module", + "title": "postgres.ts", + "path": "packages/observability/src/postgres.ts", + "symbols": [ + "PostgresCostStoreOptions", + "postgresCostMigrationSql", + "postgresCostReservationTable", + "postgresCostStore", + "postgresCostWindowTable", + "postgresUsageLedgerTable" + ], + "tags": [ + "module", + "ts", + "src" + ], + "contentHash": "c717f504c6644d0f7d1db9017415638bc8f1889e8e974545c508aa31484da167" + }, { "id": "module:packages/observability/src/prod-control.ts", "type": "module", @@ -28528,6 +28646,19 @@ ], "contentHash": "7b74900ce5c37d1169fb2c03873b1ad908c2b4437175586a3657a3b5c12ed1e5" }, + { + "id": "module:packages/observability/tests/cost-guard-store.test.ts", + "type": "module", + "title": "cost-guard-store.test.ts", + "path": "packages/observability/tests/cost-guard-store.test.ts", + "tags": [ + "module", + "ts", + "test", + "tests" + ], + "contentHash": "3dbca16e7462eb3d4946f12a3730c0396d4a0762cc414572d1e6d820e0d74288" + }, { "id": "module:packages/observability/tests/cost-guard.test.ts", "type": "module", @@ -28541,6 +28672,32 @@ ], "contentHash": "3acbe9407dbb22e4ae1ee4dd654af5fa1a28b5ee440994b7385f8f0dd34a5a08" }, + { + "id": "module:packages/observability/tests/cost-store-contract.test.ts", + "type": "module", + "title": "cost-store-contract.test.ts", + "path": "packages/observability/tests/cost-store-contract.test.ts", + "tags": [ + "module", + "ts", + "test", + "tests" + ], + "contentHash": "447ac4782c545122cb71dfc17c03751951c9e62ec542b47d91d2df3e2768e3f8" + }, + { + "id": "module:packages/observability/tests/cost-store.test.ts", + "type": "module", + "title": "cost-store.test.ts", + "path": "packages/observability/tests/cost-store.test.ts", + "tags": [ + "module", + "ts", + "test", + "tests" + ], + "contentHash": "263a1943013345075c1080f4b31228f7fe934bc31feb05d2f0b5c0ab9fd42f1d" + }, { "id": "module:packages/observability/tests/devtools.test.ts", "type": "module", @@ -28606,6 +28763,75 @@ ], "contentHash": "116fca086b5348287b8989ab035785d393bbfbaecdcd6160b325fb04c06f373c" }, + { + "id": "module:packages/observability/tests/postgres-contract.ts", + "type": "module", + "title": "postgres-contract.ts", + "path": "packages/observability/tests/postgres-contract.ts", + "symbols": [ + "PostgresCostContractResult", + "postgresCostContract" + ], + "tags": [ + "module", + "ts", + "tests" + ], + "contentHash": "f3d3e7a3eae7a1c1d5cddb2bf506ea81fdc0fff40056edbd57505a02e1b4611b" + }, + { + "id": "module:packages/observability/tests/postgres-worker.ts", + "type": "module", + "title": "postgres-worker.ts", + "path": "packages/observability/tests/postgres-worker.ts", + "symbols": [ + "default" + ], + "tags": [ + "module", + "ts", + "tests" + ], + "contentHash": "407e64dc7114efd7920d36a27a747899ccb99afbf79db8a86c2ec404df756f12" + }, + { + "id": "module:packages/observability/tests/postgres-workerd.mjs", + "type": "module", + "title": "postgres-workerd.mjs", + "path": "packages/observability/tests/postgres-workerd.mjs", + "tags": [ + "module", + "mjs", + "tests" + ], + "contentHash": "7a7a6a85faffe15c65b44898f95505d629d8aa57a2d7c3750c0a187a15f92bce" + }, + { + "id": "module:packages/observability/tests/postgres.integration.test.ts", + "type": "module", + "title": "postgres.integration.test.ts", + "path": "packages/observability/tests/postgres.integration.test.ts", + "tags": [ + "module", + "ts", + "test", + "tests" + ], + "contentHash": "8fb55eaf341dfedbc79f7ce382f60e785b3954afb810e36048803a02c7c1feb7" + }, + { + "id": "module:packages/observability/tests/postgres.test.ts", + "type": "module", + "title": "postgres.test.ts", + "path": "packages/observability/tests/postgres.test.ts", + "tags": [ + "module", + "ts", + "test", + "tests" + ], + "contentHash": "9f3255e0a36e2632e7119af79ca31ee09386584d3b1f1d6e9df665710ac3f76a" + }, { "id": "module:packages/observability/tests/prod-control.test.ts", "type": "module", @@ -28762,7 +28988,7 @@ "ts", "observability" ], - "contentHash": "a113333f9e05e5c05e240aab56326896c5938cad85959a208fa086f5c1ba89ce" + "contentHash": "2f318bb41889c463db9944961fec345256f93559fe44779010397c00395f051d" }, { "id": "module:packages/observability/vitest.config.ts", @@ -38346,8 +38572,8 @@ "changes": {} }, "inputs": { - "hash": "fd8a6653c220223ff9639c20eb76cf9a4f9a831a76724cd18eadc99032b619c4", - "fileCount": 2549, + "hash": "6fed057dcbc0aba6f59e7843774e3abaffcd6c3bb1df390b6059e1ea22ec6abf", + "fileCount": 2562, "projectionVersion": 2 }, "retrieval": { @@ -38369,9 +38595,9 @@ "projection": { "type": "retrieval-index", "schemaVersion": 1, - "contentHash": "63fb3049fd1569f0ae2f8c1890506d8925d7b666bc22327c126b9227383d8552", + "contentHash": "a79c78312df2218ccf1c4fb17ecd51b3c64eafb5eb0b7afd9de693c88931d169", "contentHashAlgo": "sha256-normalized-v1", - "snapshotHash": "7aea2df3a6681cd143f3eda83a9b7336b9738dfcf1ca1698dbee48e35132c472", + "snapshotHash": "2c2951311fc560f749cec14351cb1b672e5c0acccadbd4ef880b2d69997e2dd9", "overlayHash": "f99666ff9119084dd25c2974ae64eb6935ec2aa785ff5ad478fb9d541f7a5e94", "configurationHash": "9662cc07ea4ab203ff2ddf457d2db48b5375a7ab4a1f8fbc79dfebcaa8f9a00c", "lexiconVersion": 1, @@ -38391,7 +38617,7 @@ }, "lexical": { "version": 1, - "documentCount": 2504, + "documentCount": 2517, "fieldNames": [ "aliases", "body", @@ -38402,13 +38628,13 @@ "title" ], "averageFieldLength": { - "aliases": 3.0139776357827475, - "body": 61.93690095846645, - "headings": 4.032747603833866, - "path": 7.611821086261981, - "summary": 4.569488817891374, - "symbols": 7.453274760383387, - "title": 3.3606230031948883 + "aliases": 3.0154946364719906, + "body": 61.76042908224076, + "headings": 4.018275725069527, + "path": 7.612236789829161, + "summary": 4.54906634882797, + "symbols": 7.5013905442987685, + "title": 3.3663090981326977 } }, "entries": [ @@ -38827,7 +39053,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/reference/examples/discord-bot.mdx" @@ -38866,7 +39092,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/reference/examples/dspy.mdx" @@ -38944,7 +39170,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/reference/examples/embedded.mdx" @@ -39265,7 +39491,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/reference/examples/slack-bot.mdx" @@ -39304,7 +39530,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/reference/examples/teams-bot.mdx" @@ -39660,7 +39886,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/data/providers/embedders.mdx" @@ -39775,7 +40001,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/production/cli/index.mdx" @@ -39938,7 +40164,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/production/security/index.mdx" @@ -40101,7 +40327,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.002769, + "pagerank": 0.002764, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/reference/packages/eval-braintrust.mdx" @@ -40185,7 +40411,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.002769, + "pagerank": 0.002764, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/production/evals/snapshots.mdx" @@ -40224,7 +40450,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/production/evals/replay.mdx" @@ -40263,7 +40489,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.002769, + "pagerank": 0.002764, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/production/evals/snapshots.mdx" @@ -40302,7 +40528,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.002356, + "pagerank": 0.002352, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/ui/index.mdx" @@ -40372,7 +40598,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.002769, + "pagerank": 0.002764, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/agents/tools/integrations-catalog.mdx" @@ -40442,7 +40668,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/data/memory/index.mdx" @@ -40481,7 +40707,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/data/rag/loaders.mdx" @@ -40520,7 +40746,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/data/rag/rerank.mdx" @@ -40559,7 +40785,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.002356, + "pagerank": 0.002352, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/ui/index.mdx" @@ -40598,7 +40824,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.002769, + "pagerank": 0.002764, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/production/security/sandbox.mdx" @@ -40637,7 +40863,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.002356, + "pagerank": 0.002352, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/ui/index.mdx" @@ -40676,7 +40902,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.002356, + "pagerank": 0.002352, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/ui/index.mdx" @@ -40746,7 +40972,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.002769, + "pagerank": 0.002764, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/agents/tools/integrations-catalog.mdx" @@ -40836,7 +41062,7 @@ "aliases": "area" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [ "document:apps/docs-next/content/docs/production/observability/devtools.mdx" @@ -41662,7 +41888,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -41748,7 +41974,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -41801,7 +42027,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.005797, + "pagerank": 0.005786, "inboundLinks": 10, "coveredBy": [], "mentionedBy": [ @@ -41997,7 +42223,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -44358,7 +44584,7 @@ "aliases": "document human" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -46402,7 +46628,7 @@ "aliases": "document human" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -46544,7 +46770,7 @@ "aliases": "document human" }, "graph": { - "pagerank": 0.003595, + "pagerank": 0.003588, "inboundLinks": 1, "coveredBy": [], "mentionedBy": [], @@ -47223,7 +47449,7 @@ "aliases": "document human" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -48615,7 +48841,7 @@ "aliases": "document human" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -49836,7 +50062,7 @@ "aliases": "document human" }, "graph": { - "pagerank": 0.002356, + "pagerank": 0.002352, "inboundLinks": 1, "coveredBy": [], "mentionedBy": [], @@ -50454,7 +50680,7 @@ "aliases": "document human" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -50508,7 +50734,7 @@ "aliases": "document human" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -50554,7 +50780,7 @@ "aliases": "adapters document agent" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -50905,7 +51131,7 @@ "aliases": "angular document agent" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -51011,7 +51237,7 @@ "aliases": "cli document agent" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -51157,7 +51383,7 @@ "aliases": "core document agent" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -51328,7 +51554,7 @@ "aliases": "cross-platform document agent" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -51444,7 +51670,7 @@ "aliases": "doc-bridge document agent" }, "graph": { - "pagerank": 0.00204, + "pagerank": 0.002036, "inboundLinks": 1, "coveredBy": [], "mentionedBy": [], @@ -51491,7 +51717,7 @@ "aliases": "eval-braintrust document agent" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -51552,7 +51778,7 @@ "aliases": "eval document agent" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -51784,7 +52010,7 @@ "aliases": "ink document agent" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -51895,7 +52121,7 @@ "aliases": "integrations document agent" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -52036,7 +52262,7 @@ "aliases": "mcp document agent" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -52122,7 +52348,7 @@ "aliases": "memory document agent" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -52293,7 +52519,7 @@ "aliases": "net document agent" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -52354,7 +52580,7 @@ "aliases": "observability-langfuse document agent" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -52416,11 +52642,11 @@ "path": "apps/docs-next/content/docs/for-agents/observability.mdx", "symbols": "", "summary": "consoleLogger(config?) — local dev logging.", - "body": "Install Primary exports Loggers / tracers - consoleLogger(config?) — local dev logging. - langsmith(config) — LangSmith lifecycle observer ( flush / idempotent shutdown ; optional langsmith peer, lazy). - opentelemetry(config) — OpenTelemetry lifecycle observer (optional OTel peers, lazy). - createTraceTracker({ onSpanStart, onSpanEnd }) — low-level span lifecycle. Cross-repository correlation Every AgentEvent may carry an optional correlation envelope. Set the stable operationId at the host boundary and preserve local runId , sessionId , turnId , actionId , and traceId values when they exist. Observability propagates these values as span attributes without requiring a vendor SDK or a shared runtime. Sinks (batch HTTP exporters) All three return LifecycleObserver ( flush(): Promisebeta-package", @@ -80238,7 +80564,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -80324,6 +80650,16 @@ "id": "module:packages/observability/src/cost-guard.ts", "confidence": "observed" }, + { + "kind": "mentions-symbol", + "id": "module:packages/observability/src/cost-store-contract.ts", + "confidence": "observed" + }, + { + "kind": "mentions-symbol", + "id": "module:packages/observability/src/cost-store.ts", + "confidence": "observed" + }, { "kind": "mentions-symbol", "id": "module:packages/observability/src/datadog.ts", @@ -80356,7 +80692,7 @@ } ] }, - "contentHash": "6829b6fc5e48db6d6ef4f74e9bee465a3938c276002e7ed5154d0e8efec2cbf7", + "contentHash": "f784f385ea347b6b418c6d84d67f7c6a100a21806c880640f9edbfdcf1635221", "provenance": "observed", "confidence": "observed" }, @@ -80381,7 +80717,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -80500,7 +80836,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -80742,7 +81078,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -80870,7 +81206,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -80969,7 +81305,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -81122,7 +81458,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -81196,7 +81532,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.00204, + "pagerank": 0.002036, "inboundLinks": 2, "coveredBy": [], "mentionedBy": [ @@ -81362,7 +81698,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -81436,7 +81772,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -81564,7 +81900,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -81633,7 +81969,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -81825,7 +82161,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -81986,7 +82322,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.002769, + "pagerank": 0.002764, "inboundLinks": 2, "coveredBy": [], "mentionedBy": [], @@ -82179,7 +82515,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -82429,7 +82765,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.00204, + "pagerank": 0.002036, "inboundLinks": 1, "coveredBy": [], "mentionedBy": [], @@ -82553,7 +82889,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -82612,7 +82948,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -82929,7 +83265,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -83076,7 +83412,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -83205,7 +83541,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 9, "coveredBy": [], "mentionedBy": [ @@ -83534,7 +83870,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.002053, + "pagerank": 0.002049, "inboundLinks": 1, "coveredBy": [], "mentionedBy": [], @@ -83583,7 +83919,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.004169, + "pagerank": 0.004162, "inboundLinks": 3, "coveredBy": [], "mentionedBy": [], @@ -83662,7 +83998,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.003297, + "pagerank": 0.003291, "inboundLinks": 2, "coveredBy": [], "mentionedBy": [], @@ -83791,7 +84127,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.002053, + "pagerank": 0.002049, "inboundLinks": 1, "coveredBy": [], "mentionedBy": [], @@ -83865,7 +84201,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.002926, + "pagerank": 0.00292, "inboundLinks": 2, "coveredBy": [], "mentionedBy": [], @@ -83964,7 +84300,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.002053, + "pagerank": 0.002049, "inboundLinks": 1, "coveredBy": [], "mentionedBy": [], @@ -84043,7 +84379,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.002053, + "pagerank": 0.002049, "inboundLinks": 1, "coveredBy": [], "mentionedBy": [], @@ -84112,7 +84448,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.004256, + "pagerank": 0.004247, "inboundLinks": 4, "coveredBy": [], "mentionedBy": [], @@ -84196,7 +84532,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.002053, + "pagerank": 0.002049, "inboundLinks": 1, "coveredBy": [], "mentionedBy": [], @@ -84275,7 +84611,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.002879, + "pagerank": 0.002874, "inboundLinks": 2, "coveredBy": [], "mentionedBy": [], @@ -84349,7 +84685,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.002053, + "pagerank": 0.002049, "inboundLinks": 1, "coveredBy": [], "mentionedBy": [], @@ -84423,7 +84759,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.005081, + "pagerank": 0.005072, "inboundLinks": 4, "coveredBy": [], "mentionedBy": [], @@ -84512,7 +84848,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.002879, + "pagerank": 0.002874, "inboundLinks": 2, "coveredBy": [], "mentionedBy": [], @@ -84606,7 +84942,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.002053, + "pagerank": 0.002049, "inboundLinks": 1, "coveredBy": [], "mentionedBy": [], @@ -84681,7 +85017,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -84825,7 +85161,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 1, "coveredBy": [], "mentionedBy": [ @@ -84977,7 +85313,7 @@ "aliases": "document project" }, "graph": { - "pagerank": 0.007508, + "pagerank": 0.007493, "inboundLinks": 6, "coveredBy": [], "mentionedBy": [ @@ -85101,7 +85437,7 @@ "aliases": "document unclassified" }, "graph": { - "pagerank": 0.001943, + "pagerank": 0.001939, "inboundLinks": 0, "coveredBy": [], "mentionedBy": [], @@ -158682,6 +159018,11 @@ } ], "outbound": [ + { + "kind": "imports", + "id": "module:packages/observability/src/cost-guard-store.ts", + "confidence": "observed" + }, { "kind": "imports", "id": "module:packages/observability/src/cost-guard.ts", @@ -158689,7 +159030,7 @@ } ] }, - "contentHash": "4b4343b1366f4405400fb2d7155e11b84702cee443666f45c20d400511cad29b", + "contentHash": "a8038e7c3858f283889cae18cc5f3470c6df5d791763f0c446daf8567f0a4d11", "provenance": "observed", "confidence": "observed" }, @@ -158730,10 +159071,11 @@ }, "graph": { "pagerank": 0, - "inboundLinks": 2, + "inboundLinks": 3, "coveredBy": [], "mentionedBy": [ "document:.agent-memory/project_file-size-baselines.md", + "document:docs/architecture/adrs/0044-durable-cost-store.md", "document:packages/observability/README.md" ], "packageId": "package:@agentskit/observability", @@ -158748,11 +159090,21 @@ "id": "module:packages/observability/tests/cost-guard-advanced.test.ts", "confidence": "observed" }, + { + "kind": "imports", + "id": "module:packages/observability/tests/cost-guard-store.test.ts", + "confidence": "observed" + }, { "kind": "mentions", "id": "document:.agent-memory/project_file-size-baselines.md", "confidence": "observed" }, + { + "kind": "mentions-symbol", + "id": "document:docs/architecture/adrs/0044-durable-cost-store.md", + "confidence": "observed" + }, { "kind": "mentions-symbol", "id": "document:packages/observability/README.md", @@ -158775,6 +159127,11 @@ "id": "module:packages/observability/src/cost-guard-advanced-types.ts", "confidence": "observed" }, + { + "kind": "imports", + "id": "module:packages/observability/src/cost-guard-store.ts", + "confidence": "observed" + }, { "kind": "imports", "id": "module:packages/observability/src/cost-guard.ts", @@ -158792,7 +159149,7 @@ } ] }, - "contentHash": "07048e517735836b3d0391c846119b17d3e49fe5279d9fcf9068cdd43256d6e8", + "contentHash": "75f302e345d2a4bd69fbdf08b4693eedaa50a59c2c6a019fcc1774913d5ed12f", "provenance": "observed", "confidence": "observed" }, @@ -158883,10 +159240,11 @@ }, "graph": { "pagerank": 0, - "inboundLinks": 2, + "inboundLinks": 3, "coveredBy": [], "mentionedBy": [ "document:apps/docs-next/content/docs/get-started/decision-tree.mdx", + "document:docs/architecture/adrs/0044-durable-cost-store.md", "document:packages/observability/README.md" ], "packageId": "package:@agentskit/observability", @@ -158896,11 +159254,21 @@ "id": "module:packages/observability/tests/cost-guard-multi-tenant.test.ts", "confidence": "observed" }, + { + "kind": "imports", + "id": "module:packages/observability/tests/cost-guard-store.test.ts", + "confidence": "observed" + }, { "kind": "mentions-symbol", "id": "document:apps/docs-next/content/docs/get-started/decision-tree.mdx", "confidence": "observed" }, + { + "kind": "mentions-symbol", + "id": "document:docs/architecture/adrs/0044-durable-cost-store.md", + "confidence": "observed" + }, { "kind": "mentions-symbol", "id": "document:packages/observability/README.md", @@ -158913,6 +159281,11 @@ } ], "outbound": [ + { + "kind": "imports", + "id": "module:packages/observability/src/cost-guard-store.ts", + "confidence": "observed" + }, { "kind": "imports", "id": "module:packages/observability/src/cost-guard.ts", @@ -158920,7 +159293,78 @@ } ] }, - "contentHash": "6721ce5463e504d1d2f343897677dba248b3594ea4fbb331229854e75b808d37", + "contentHash": "6577cedb8e48993548baefaae96fbc910d157fe3e5d355345ff3d286c0fa5dc3", + "provenance": "observed", + "confidence": "observed" + }, + { + "id": "module:packages/observability/src/cost-guard-store.ts", + "kind": "module", + "path": "packages/observability/src/cost-guard-store.ts", + "title": "cost-guard-store.ts", + "aliases": [], + "symbols": [ + "CostGuardStoreOptions", + "DurableSpendRecorder", + "PricedCall", + "durableSpendRecorder" + ], + "tags": [ + "module", + "ts", + "src" + ], + "fields": { + "title": "cost-guard-store.ts", + "headings": "", + "path": "packages/observability/src/cost-guard-store.ts", + "symbols": "CostGuardStoreOptions DurableSpendRecorder PricedCall durableSpendRecorder", + "summary": "", + "body": "", + "aliases": "module ts src" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 0, + "coveredBy": [], + "mentionedBy": [], + "packageId": "package:@agentskit/observability", + "inbound": [ + { + "kind": "imports", + "id": "module:packages/observability/src/cost-guard-advanced-types.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/src/cost-guard-advanced.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/src/cost-guard-multi-tenant.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/src/cost-guard.ts", + "confidence": "observed" + }, + { + "kind": "re-exports", + "id": "module:packages/observability/src/index.ts", + "confidence": "observed" + } + ], + "outbound": [ + { + "kind": "imports", + "id": "module:packages/observability/src/cost-store.ts", + "confidence": "observed" + } + ] + }, + "contentHash": "9a078a80a71859f4200812350e14376f90fd49f0b602bed5e006e4054c18259b", "provenance": "observed", "confidence": "observed" }, @@ -158966,7 +159410,7 @@ }, "graph": { "pagerank": 0, - "inboundLinks": 13, + "inboundLinks": 14, "coveredBy": [], "mentionedBy": [ "document:docs/PHASE-1-REVIEW.md", @@ -158978,6 +159422,7 @@ "document:apps/docs-next/content/docs/production/observability/index.mdx", "document:apps/docs-next/content/docs/reference/packages/observability.mdx", "document:apps/docs-next/content/docs/reference/recipes/cost-guard.mdx", + "document:docs/architecture/adrs/0044-durable-cost-store.md", "document:docs/PHASE-1-REVIEW.md", "document:packages/observability/CONVENTIONS.md", "document:packages/observability/README.md", @@ -159015,6 +159460,11 @@ "id": "module:packages/observability/src/cost-guard-multi-tenant.ts", "confidence": "observed" }, + { + "kind": "imports", + "id": "module:packages/observability/tests/cost-guard-store.test.ts", + "confidence": "observed" + }, { "kind": "imports", "id": "module:packages/observability/tests/cost-guard.test.ts", @@ -159065,6 +159515,11 @@ "id": "document:apps/docs-next/content/docs/reference/recipes/cost-guard.mdx", "confidence": "observed" }, + { + "kind": "mentions-symbol", + "id": "document:docs/architecture/adrs/0044-durable-cost-store.md", + "confidence": "observed" + }, { "kind": "mentions-symbol", "id": "document:docs/PHASE-1-REVIEW.md", @@ -159091,9 +159546,205 @@ "confidence": "observed" } ], + "outbound": [ + { + "kind": "imports", + "id": "module:packages/observability/src/cost-guard-store.ts", + "confidence": "observed" + } + ] + }, + "contentHash": "dbed63fe3d0288ce8719ea849709b60870774e988be501b23fe69a731785355d", + "provenance": "observed", + "confidence": "observed" + }, + { + "id": "module:packages/observability/src/cost-store-contract.ts", + "kind": "module", + "path": "packages/observability/src/cost-store-contract.ts", + "title": "cost-store-contract.ts", + "aliases": [], + "symbols": [ + "CostStoreContractOptions", + "CostStoreContractResult", + "costStoreContract" + ], + "tags": [ + "module", + "ts", + "src" + ], + "fields": { + "title": "cost-store-contract.ts", + "headings": "", + "path": "packages/observability/src/cost-store-contract.ts", + "symbols": "CostStoreContractOptions CostStoreContractResult costStoreContract", + "summary": "", + "body": "", + "aliases": "module ts src" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 3, + "coveredBy": [], + "mentionedBy": [ + "document:apps/docs-next/content/docs/reference/packages/observability.mdx", + "document:docs/architecture/adrs/0044-durable-cost-store.md", + "document:packages/observability/README.md" + ], + "packageId": "package:@agentskit/observability", + "inbound": [ + { + "kind": "imports", + "id": "module:packages/observability/tests/cost-store-contract.test.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/tests/cost-store.test.ts", + "confidence": "observed" + }, + { + "kind": "mentions-symbol", + "id": "document:apps/docs-next/content/docs/reference/packages/observability.mdx", + "confidence": "observed" + }, + { + "kind": "mentions-symbol", + "id": "document:docs/architecture/adrs/0044-durable-cost-store.md", + "confidence": "observed" + }, + { + "kind": "mentions-symbol", + "id": "document:packages/observability/README.md", + "confidence": "observed" + } + ], + "outbound": [ + { + "kind": "imports", + "id": "module:packages/observability/src/cost-store.ts", + "confidence": "observed" + } + ] + }, + "contentHash": "1e5f787b952cbbd9bdfd9b809cbdf7a8c315ee9a3def164285420482c228f189", + "provenance": "observed", + "confidence": "observed" + }, + { + "id": "module:packages/observability/src/cost-store.ts", + "kind": "module", + "path": "packages/observability/src/cost-store.ts", + "title": "cost-store.ts", + "aliases": [], + "symbols": [ + "CostCommitInput", + "CostReleaseInput", + "CostReservationError", + "CostReserveInput", + "CostReserveResult", + "CostSpendRecord", + "CostStore", + "CostStoreOptions", + "CostUsageEntry", + "CostWindow", + "CostWindowInput", + "InMemoryCostStore", + "InMemoryLedgerRow", + "createInMemoryCostStore", + "describeCostWindow", + "monthlyWindowKey", + "recordCostSpend", + "validateCostCommit", + "validateCostReserve", + "warnProcessLocalCostState" + ], + "tags": [ + "module", + "ts", + "src" + ], + "fields": { + "title": "cost-store.ts", + "headings": "", + "path": "packages/observability/src/cost-store.ts", + "symbols": "CostCommitInput CostReleaseInput CostReservationError CostReserveInput CostReserveResult CostSpendRecord CostStore CostStoreOptions CostUsageEntry CostWindow CostWindowInput InMemoryCostStore InMemoryLedgerRow createInMemoryCostStore describeCostWindow monthlyWindowKey recordCostSpend validateCostCommit validateCostReserve warnProcessLocalCostState", + "summary": "", + "body": "", + "aliases": "module ts src" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 4, + "coveredBy": [], + "mentionedBy": [ + "document:apps/docs-next/content/docs/for-agents/observability.mdx", + "document:apps/docs-next/content/docs/reference/packages/observability.mdx", + "document:docs/architecture/adrs/0044-durable-cost-store.md", + "document:packages/observability/README.md" + ], + "packageId": "package:@agentskit/observability", + "inbound": [ + { + "kind": "imports", + "id": "module:packages/observability/src/cost-guard-store.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/src/cost-store-contract.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/src/postgres.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/tests/cost-guard-store.test.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/tests/cost-store-contract.test.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/tests/cost-store.test.ts", + "confidence": "observed" + }, + { + "kind": "mentions-symbol", + "id": "document:apps/docs-next/content/docs/for-agents/observability.mdx", + "confidence": "observed" + }, + { + "kind": "mentions-symbol", + "id": "document:apps/docs-next/content/docs/reference/packages/observability.mdx", + "confidence": "observed" + }, + { + "kind": "mentions-symbol", + "id": "document:docs/architecture/adrs/0044-durable-cost-store.md", + "confidence": "observed" + }, + { + "kind": "mentions-symbol", + "id": "document:packages/observability/README.md", + "confidence": "observed" + }, + { + "kind": "re-exports", + "id": "module:packages/observability/src/index.ts", + "confidence": "observed" + } + ], "outbound": [] }, - "contentHash": "349e02829af7a7907cc897a9aae0598e3383e91726d53ded4ae351a68670e737", + "contentHash": "000d000f32f8ff879d6372400a7620b9574392e10947972ffa252c1339d51dfe", "provenance": "observed", "confidence": "observed" }, @@ -159387,10 +160038,22 @@ "CostAlertType", "CostCapWindow", "CostCaps", + "CostCommitInput", "CostGuardErrorHandler", "CostGuardMode", "CostGuardOptions", + "CostGuardStoreOptions", + "CostReleaseInput", + "CostReservationError", + "CostReserveInput", + "CostReserveResult", "CostSample", + "CostSpendRecord", + "CostStore", + "CostStoreOptions", + "CostUsageEntry", + "CostWindow", + "CostWindowInput", "DEFAULT_PRICES", "DEFAULT_SLO_TARGETS", "DatadogSinkConfig", @@ -159401,6 +160064,8 @@ "DevtoolsServerOptions", "FileTraceSink", "HttpBatchOptions", + "InMemoryCostStore", + "InMemoryLedgerRow", "LangSmithConfig", "LangSmithObserver", "LifecycleObserver", @@ -159459,6 +160124,7 @@ "createDevtoolsServer", "createFileTraceSink", "createInMemoryAuditStore", + "createInMemoryCostStore", "createProviderCounter", "createSignedAuditLog", "createTopologyGraph", @@ -159466,11 +160132,13 @@ "datadogSink", "diffState", "langsmith", + "monthlyWindowKey", "multiTenantCostGuard", "newRelicSink", "opentelemetry", "positionAt", "priceFor", + "recordCostSpend", "renderTraceViewerHtml", "replayBisect", "replayEvents", @@ -159489,7 +160157,7 @@ "title": "index.ts", "headings": "", "path": "packages/observability/src/index.ts", - "symbols": "AdvancedCostGuard AdvancedCostGuardOptions AppendAuditInput AuditEntry AuditLogOptions AuditLogStore AuditVerifyResult AxiomSinkConfig AxiomSinkObserver BisectOpts BisectVerdict ChargebackGroupKey ChargebackReport ChargebackReportOptions ChargebackRow ConsoleLoggerConfig ControlAuditEntry ControlSurface ControlSurfaceOptions CostAlertEvent CostAlertSink CostAlertType CostCapWindow CostCaps CostGuardErrorHandler CostGuardMode CostGuardOptions CostSample DEFAULT_PRICES DEFAULT_SLO_TARGETS DatadogSinkConfig DatadogSinkObserver DevtoolsClient DevtoolsEnvelope DevtoolsServer DevtoolsServerOptions FileTraceSink HttpBatchOptions LangSmithConfig LangSmithObserver LifecycleObserver MultiTenantCostGuardOptions NewRelicSinkConfig NewRelicSinkObserver ObserverRedactionMode ObserverRedactionOptions OpenTelemetryConfig OpenTelemetryObserver PiiAuditAction PiiAuditInput PiiAuditPayload ProviderTokenCounterOptions ReplayHandler ReplayOracle ReplayPosition ReplayStep RunSnapshot SignedAuditLog SloObserver SloOptions SloSnapshot SloTargets StateDiffEntry Timeline TimelineRow TokenPrice ToolOverride TopologyEdge TopologyGraph TopologyGraphOptions TopologyGraphSnapshot TopologyLogEvent TopologyNode TraceReport TraceSpan TraceTrackerCallbacks UnknownModelPolicy WebhookAlertSinkOptions appendPiiAuditEvents approximateCounter axiomSink buildTimeline buildTraceReport chargebackReport chargebackReportToCsv computeCost consoleAlertSink consoleLogger costGuard countTokens countTokensDetailed createAdvancedCostGuard createControlSurface createDevtoolsServer createFileTraceSink createInMemoryAuditStore createProviderCounter createSignedAuditLog createTopologyGraph createTraceTracker datadogSink diffState langsmith multiTenantCostGuard newRelicSink opentelemetry positionAt priceFor renderTraceViewerHtml replayBisect replayEvents sloObserver throttle toSseFrame webhookAlertSink wrapObserverWithRedaction", + "symbols": "AdvancedCostGuard AdvancedCostGuardOptions AppendAuditInput AuditEntry AuditLogOptions AuditLogStore AuditVerifyResult AxiomSinkConfig AxiomSinkObserver BisectOpts BisectVerdict ChargebackGroupKey ChargebackReport ChargebackReportOptions ChargebackRow ConsoleLoggerConfig ControlAuditEntry ControlSurface ControlSurfaceOptions CostAlertEvent CostAlertSink CostAlertType CostCapWindow CostCaps CostCommitInput CostGuardErrorHandler CostGuardMode CostGuardOptions CostGuardStoreOptions CostReleaseInput CostReservationError CostReserveInput CostReserveResult CostSample CostSpendRecord CostStore CostStoreOptions CostUsageEntry CostWindow CostWindowInput DEFAULT_PRICES DEFAULT_SLO_TARGETS DatadogSinkConfig DatadogSinkObserver DevtoolsClient DevtoolsEnvelope DevtoolsServer DevtoolsServerOptions FileTraceSink HttpBatchOptions InMemoryCostStore InMemoryLedgerRow LangSmithConfig LangSmithObserver LifecycleObserver MultiTenantCostGuardOptions NewRelicSinkConfig NewRelicSinkObserver ObserverRedactionMode ObserverRedactionOptions OpenTelemetryConfig OpenTelemetryObserver PiiAuditAction PiiAuditInput PiiAuditPayload ProviderTokenCounterOptions ReplayHandler ReplayOracle ReplayPosition ReplayStep RunSnapshot SignedAuditLog SloObserver SloOptions SloSnapshot SloTargets StateDiffEntry Timeline TimelineRow TokenPrice ToolOverride TopologyEdge TopologyGraph TopologyGraphOptions TopologyGraphSnapshot TopologyLogEvent TopologyNode TraceReport TraceSpan TraceTrackerCallbacks UnknownModelPolicy WebhookAlertSinkOptions appendPiiAuditEvents approximateCounter axiomSink buildTimeline buildTraceReport chargebackReport chargebackReportToCsv computeCost consoleAlertSink consoleLogger costGuard countTokens countTokensDetailed createAdvancedCostGuard createControlSurface createDevtoolsServer createFileTraceSink createInMemoryAuditStore createInMemoryCostStore createProviderCounter createSignedAuditLog createTopologyGraph createTraceTracker datadogSink diffState langsmith monthlyWindowKey multiTenantCostGuard newRelicSink opentelemetry positionAt priceFor recordCostSpend renderTraceViewerHtml replayBisect replayEvents sloObserver throttle toSseFrame webhookAlertSink wrapObserverWithRedaction", "summary": "", "body": "", "aliases": "module ts src" @@ -159543,11 +160211,21 @@ "id": "module:packages/observability/src/cost-guard-multi-tenant.ts", "confidence": "observed" }, + { + "kind": "re-exports", + "id": "module:packages/observability/src/cost-guard-store.ts", + "confidence": "observed" + }, { "kind": "re-exports", "id": "module:packages/observability/src/cost-guard.ts", "confidence": "observed" }, + { + "kind": "re-exports", + "id": "module:packages/observability/src/cost-store.ts", + "confidence": "observed" + }, { "kind": "re-exports", "id": "module:packages/observability/src/datadog.ts", @@ -159630,7 +160308,7 @@ } ] }, - "contentHash": "798ff8456fffbdd982758c4594a1c08e21570e6c340a64159f3f590498771f18", + "contentHash": "1731cecba9b317493798a6b8e8c8d0840b7b613a60ae28eb8d246a21d6dc8aa3", "provenance": "observed", "confidence": "observed" }, @@ -159888,6 +160566,78 @@ "provenance": "observed", "confidence": "observed" }, + { + "id": "module:packages/observability/src/postgres.ts", + "kind": "module", + "path": "packages/observability/src/postgres.ts", + "title": "postgres.ts", + "aliases": [], + "symbols": [ + "PostgresCostStoreOptions", + "postgresCostMigrationSql", + "postgresCostReservationTable", + "postgresCostStore", + "postgresCostWindowTable", + "postgresUsageLedgerTable" + ], + "tags": [ + "module", + "ts", + "src" + ], + "fields": { + "title": "postgres.ts", + "headings": "", + "path": "packages/observability/src/postgres.ts", + "symbols": "PostgresCostStoreOptions postgresCostMigrationSql postgresCostReservationTable postgresCostStore postgresCostWindowTable postgresUsageLedgerTable", + "summary": "", + "body": "", + "aliases": "module ts src" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 3, + "coveredBy": [], + "mentionedBy": [ + "document:apps/docs-next/content/docs/for-agents/observability.mdx", + "document:apps/docs-next/content/docs/reference/packages/observability.mdx", + "document:docs/architecture/adrs/0044-durable-cost-store.md" + ], + "packageId": "package:@agentskit/observability", + "inbound": [ + { + "kind": "imports", + "id": "module:packages/observability/tests/postgres.test.ts", + "confidence": "observed" + }, + { + "kind": "mentions-symbol", + "id": "document:apps/docs-next/content/docs/for-agents/observability.mdx", + "confidence": "observed" + }, + { + "kind": "mentions-symbol", + "id": "document:apps/docs-next/content/docs/reference/packages/observability.mdx", + "confidence": "observed" + }, + { + "kind": "mentions-symbol", + "id": "document:docs/architecture/adrs/0044-durable-cost-store.md", + "confidence": "observed" + } + ], + "outbound": [ + { + "kind": "imports", + "id": "module:packages/observability/src/cost-store.ts", + "confidence": "observed" + } + ] + }, + "contentHash": "c717f504c6644d0f7d1db9017415638bc8f1889e8e974545c508aa31484da167", + "provenance": "observed", + "confidence": "observed" + }, { "id": "module:packages/observability/src/prod-control.ts", "kind": "module", @@ -160909,6 +161659,61 @@ "provenance": "observed", "confidence": "observed" }, + { + "id": "module:packages/observability/tests/cost-guard-store.test.ts", + "kind": "module", + "path": "packages/observability/tests/cost-guard-store.test.ts", + "title": "cost-guard-store.test.ts", + "aliases": [], + "tags": [ + "module", + "ts", + "test", + "tests" + ], + "fields": { + "title": "cost-guard-store.test.ts", + "headings": "", + "path": "packages/observability/tests/cost-guard-store.test.ts", + "symbols": "", + "summary": "", + "body": "", + "aliases": "module ts test tests" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 0, + "coveredBy": [], + "mentionedBy": [], + "packageId": "package:@agentskit/observability", + "inbound": [], + "outbound": [ + { + "kind": "imports", + "id": "module:packages/observability/src/cost-guard-advanced.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/src/cost-guard-multi-tenant.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/src/cost-guard.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/src/cost-store.ts", + "confidence": "observed" + } + ] + }, + "contentHash": "3dbca16e7462eb3d4946f12a3730c0396d4a0762cc414572d1e6d820e0d74288", + "provenance": "observed", + "confidence": "observed" + }, { "id": "module:packages/observability/tests/cost-guard.test.ts", "kind": "module", @@ -160949,6 +161754,96 @@ "provenance": "observed", "confidence": "observed" }, + { + "id": "module:packages/observability/tests/cost-store-contract.test.ts", + "kind": "module", + "path": "packages/observability/tests/cost-store-contract.test.ts", + "title": "cost-store-contract.test.ts", + "aliases": [], + "tags": [ + "module", + "ts", + "test", + "tests" + ], + "fields": { + "title": "cost-store-contract.test.ts", + "headings": "", + "path": "packages/observability/tests/cost-store-contract.test.ts", + "symbols": "", + "summary": "", + "body": "", + "aliases": "module ts test tests" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 0, + "coveredBy": [], + "mentionedBy": [], + "packageId": "package:@agentskit/observability", + "inbound": [], + "outbound": [ + { + "kind": "imports", + "id": "module:packages/observability/src/cost-store-contract.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/src/cost-store.ts", + "confidence": "observed" + } + ] + }, + "contentHash": "447ac4782c545122cb71dfc17c03751951c9e62ec542b47d91d2df3e2768e3f8", + "provenance": "observed", + "confidence": "observed" + }, + { + "id": "module:packages/observability/tests/cost-store.test.ts", + "kind": "module", + "path": "packages/observability/tests/cost-store.test.ts", + "title": "cost-store.test.ts", + "aliases": [], + "tags": [ + "module", + "ts", + "test", + "tests" + ], + "fields": { + "title": "cost-store.test.ts", + "headings": "", + "path": "packages/observability/tests/cost-store.test.ts", + "symbols": "", + "summary": "", + "body": "", + "aliases": "module ts test tests" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 0, + "coveredBy": [], + "mentionedBy": [], + "packageId": "package:@agentskit/observability", + "inbound": [], + "outbound": [ + { + "kind": "imports", + "id": "module:packages/observability/src/cost-store-contract.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/src/cost-store.ts", + "confidence": "observed" + } + ] + }, + "contentHash": "263a1943013345075c1080f4b31228f7fe934bc31feb05d2f0b5c0ab9fd42f1d", + "provenance": "observed", + "confidence": "observed" + }, { "id": "module:packages/observability/tests/devtools.test.ts", "kind": "module", @@ -161153,6 +162048,209 @@ "provenance": "observed", "confidence": "observed" }, + { + "id": "module:packages/observability/tests/postgres-contract.ts", + "kind": "module", + "path": "packages/observability/tests/postgres-contract.ts", + "title": "postgres-contract.ts", + "aliases": [], + "symbols": [ + "PostgresCostContractResult", + "postgresCostContract" + ], + "tags": [ + "module", + "ts", + "tests" + ], + "fields": { + "title": "postgres-contract.ts", + "headings": "", + "path": "packages/observability/tests/postgres-contract.ts", + "symbols": "PostgresCostContractResult postgresCostContract", + "summary": "", + "body": "", + "aliases": "module ts tests" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 0, + "coveredBy": [], + "mentionedBy": [], + "packageId": "package:@agentskit/observability", + "inbound": [ + { + "kind": "imports", + "id": "module:packages/observability/tests/postgres-worker.ts", + "confidence": "observed" + }, + { + "kind": "imports", + "id": "module:packages/observability/tests/postgres.integration.test.ts", + "confidence": "observed" + } + ], + "outbound": [] + }, + "contentHash": "f3d3e7a3eae7a1c1d5cddb2bf506ea81fdc0fff40056edbd57505a02e1b4611b", + "provenance": "observed", + "confidence": "observed" + }, + { + "id": "module:packages/observability/tests/postgres-worker.ts", + "kind": "module", + "path": "packages/observability/tests/postgres-worker.ts", + "title": "postgres-worker.ts", + "aliases": [], + "symbols": [ + "default" + ], + "tags": [ + "module", + "ts", + "tests" + ], + "fields": { + "title": "postgres-worker.ts", + "headings": "", + "path": "packages/observability/tests/postgres-worker.ts", + "symbols": "default", + "summary": "", + "body": "", + "aliases": "module ts tests" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 0, + "coveredBy": [], + "mentionedBy": [], + "packageId": "package:@agentskit/observability", + "inbound": [], + "outbound": [ + { + "kind": "imports", + "id": "module:packages/observability/tests/postgres-contract.ts", + "confidence": "observed" + } + ] + }, + "contentHash": "407e64dc7114efd7920d36a27a747899ccb99afbf79db8a86c2ec404df756f12", + "provenance": "observed", + "confidence": "observed" + }, + { + "id": "module:packages/observability/tests/postgres-workerd.mjs", + "kind": "module", + "path": "packages/observability/tests/postgres-workerd.mjs", + "title": "postgres-workerd.mjs", + "aliases": [], + "tags": [ + "module", + "mjs", + "tests" + ], + "fields": { + "title": "postgres-workerd.mjs", + "headings": "", + "path": "packages/observability/tests/postgres-workerd.mjs", + "symbols": "", + "summary": "", + "body": "", + "aliases": "module mjs tests" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 0, + "coveredBy": [], + "mentionedBy": [], + "packageId": "package:@agentskit/observability", + "inbound": [], + "outbound": [] + }, + "contentHash": "7a7a6a85faffe15c65b44898f95505d629d8aa57a2d7c3750c0a187a15f92bce", + "provenance": "observed", + "confidence": "observed" + }, + { + "id": "module:packages/observability/tests/postgres.integration.test.ts", + "kind": "module", + "path": "packages/observability/tests/postgres.integration.test.ts", + "title": "postgres.integration.test.ts", + "aliases": [], + "tags": [ + "module", + "ts", + "test", + "tests" + ], + "fields": { + "title": "postgres.integration.test.ts", + "headings": "", + "path": "packages/observability/tests/postgres.integration.test.ts", + "symbols": "", + "summary": "", + "body": "", + "aliases": "module ts test tests" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 0, + "coveredBy": [], + "mentionedBy": [], + "packageId": "package:@agentskit/observability", + "inbound": [], + "outbound": [ + { + "kind": "imports", + "id": "module:packages/observability/tests/postgres-contract.ts", + "confidence": "observed" + } + ] + }, + "contentHash": "8fb55eaf341dfedbc79f7ce382f60e785b3954afb810e36048803a02c7c1feb7", + "provenance": "observed", + "confidence": "observed" + }, + { + "id": "module:packages/observability/tests/postgres.test.ts", + "kind": "module", + "path": "packages/observability/tests/postgres.test.ts", + "title": "postgres.test.ts", + "aliases": [], + "tags": [ + "module", + "ts", + "test", + "tests" + ], + "fields": { + "title": "postgres.test.ts", + "headings": "", + "path": "packages/observability/tests/postgres.test.ts", + "symbols": "", + "summary": "", + "body": "", + "aliases": "module ts test tests" + }, + "graph": { + "pagerank": 0, + "inboundLinks": 0, + "coveredBy": [], + "mentionedBy": [], + "packageId": "package:@agentskit/observability", + "inbound": [], + "outbound": [ + { + "kind": "imports", + "id": "module:packages/observability/src/postgres.ts", + "confidence": "observed" + } + ] + }, + "contentHash": "9f3255e0a36e2632e7119af79ca31ee09386584d3b1f1d6e9df665710ac3f76a", + "provenance": "observed", + "confidence": "observed" + }, { "id": "module:packages/observability/tests/prod-control.test.ts", "kind": "module", @@ -161641,7 +162739,7 @@ } ] }, - "contentHash": "a113333f9e05e5c05e240aab56326896c5938cad85959a208fa086f5c1ba89ce", + "contentHash": "2f318bb41889c463db9944961fec345256f93559fe44779010397c00395f051d", "provenance": "observed", "confidence": "observed" }, @@ -192065,7 +193163,7 @@ "aliases": "adapters @agentskit/adapters package ownership" }, "graph": { - "pagerank": 0.004421, + "pagerank": 0.004412, "inboundLinks": 64, "coveredBy": [ "document:apps/docs-next/content/docs/for-agents/adapters.mdx", @@ -192490,7 +193588,7 @@ "aliases": "angular @agentskit/angular package ownership" }, "graph": { - "pagerank": 0.005247, + "pagerank": 0.005237, "inboundLinks": 37, "coveredBy": [ "document:apps/docs-next/content/docs/for-agents/angular.mdx", @@ -192753,7 +193851,7 @@ "aliases": "cli @agentskit/cli package ownership" }, "graph": { - "pagerank": 0.005247, + "pagerank": 0.005237, "inboundLinks": 28, "coveredBy": [ "document:apps/docs-next/content/docs/for-agents/cli.mdx", @@ -192962,7 +194060,7 @@ "aliases": "core @agentskit/core package ownership" }, "graph": { - "pagerank": 0.005247, + "pagerank": 0.005237, "inboundLinks": 64, "coveredBy": [ "document:apps/docs-next/content/docs/for-agents/core.mdx", @@ -193387,7 +194485,7 @@ "aliases": "cross-platform @agentskit/cross-platform package ownership" }, "graph": { - "pagerank": 0.005247, + "pagerank": 0.005237, "inboundLinks": 11, "coveredBy": [ "document:apps/docs-next/content/docs/for-agents/cross-platform.mdx", @@ -193494,7 +194592,7 @@ "aliases": "eval @agentskit/eval package ownership" }, "graph": { - "pagerank": 0.007724, + "pagerank": 0.007709, "inboundLinks": 45, "coveredBy": [ "document:apps/docs-next/content/docs/for-agents/eval-braintrust.mdx", @@ -193887,7 +194985,7 @@ "aliases": "ink @agentskit/ink package ownership" }, "graph": { - "pagerank": 0.005247, + "pagerank": 0.005237, "inboundLinks": 56, "coveredBy": [ "document:apps/docs-next/content/docs/for-agents/ink.mdx", @@ -194264,7 +195362,7 @@ "aliases": "integrations @agentskit/integrations package ownership" }, "graph": { - "pagerank": 0.003182, + "pagerank": 0.003176, "inboundLinks": 19, "coveredBy": [ "document:apps/docs-next/content/docs/for-agents/integrations.mdx", @@ -194419,7 +195517,7 @@ "aliases": "mcp @agentskit/mcp package ownership" }, "graph": { - "pagerank": 0.003182, + "pagerank": 0.003176, "inboundLinks": 20, "coveredBy": [ "document:apps/docs-next/content/docs/for-agents/mcp.mdx", @@ -194580,7 +195678,7 @@ "aliases": "memory @agentskit/memory package ownership" }, "graph": { - "pagerank": 0.005247, + "pagerank": 0.005237, "inboundLinks": 64, "coveredBy": [ "document:apps/docs-next/content/docs/for-agents/memory.mdx", @@ -195005,7 +196103,7 @@ "aliases": "net @agentskit/net package ownership" }, "graph": { - "pagerank": 0.005247, + "pagerank": 0.005237, "inboundLinks": 19, "coveredBy": [ "document:apps/docs-next/content/docs/for-agents/net.mdx", @@ -195156,11 +196254,11 @@ "path": "packages/observability", "symbols": "", "summary": "consoleLogger(config?) — local dev logging.", - "body": "Install Primary exports Loggers / tracers - consoleLogger(config?) — local dev logging. - langsmith(config) — LangSmith lifecycle observer ( flush / idempotent shutdown ; optional langsmith peer, lazy). - opentelemetry(config) — OpenTelemetry lifecycle observer (optional OTel peers, lazy). - createTraceTracker({ onSpanStart, onSpanEnd }) — low-level span lifecycle. Cross-repository correlation Every AgentEvent may carry an optional correlation envelope. Set the stable operationId at the host boundary and preserve local runId , sessionId , turnId , actionId , and traceId values when they exist. Observability propagates these values as span attributes without requiring a vendor SDK or a shared runtime. Sinks (batch HTTP exporters) All three return LifecycleObserver ( flush(): Promise`, idempotent `shut - **`kill`** — requires `disableRuntime`; use **`isDisabled(tenant)`**; fail-closed if disable fails. - Accounting is **incremental per `llm:end` / active model**; hostile usage normalizes to zero; zero-budget payloads stay finite. Callback/sink/clock errors are isolated. - `consoleAlertSink()`, `webhookAlertSink({ url, headers, fetch? })`, `throttle(sink, windowMs)` — built-in alert sinks (`webhook` uses injected or `globalThis.fetch`; no-op if none). +- `store?: CostStore` on all three guards — durable spend shared by every instance. Without it totals are process-local and the guards warn once when `NODE_ENV=production`. With it, each priced `llm:end` is written to the store and the budget is checked against the store's current window. +- `CostStore { reserve, commit, release, window }` — tenant-scoped port. `reserve({ tenant, reservationId, amountUsd, capUsd?, windowKey? })` returns `{ ok: true, window }` or `{ ok: false, reason: 'quota_exceeded', window }`; `commit({ tenant, reservationId, actualUsd, usage? })` writes real spend and ledger rows; `release({ tenant, reservationId })` returns the hold. All idempotent per `reservationId`; settling an unknown, released or committed reservation throws `CostReservationError` (`AK_COST_RESERVATION_NOT_FOUND` / `_RELEASED` / `_COMMITTED`). +- `createInMemoryCostStore()` — development and test store. `recordCostSpend(store, { tenant, costUsd, usage? })` — reserve + commit for spend that already happened. `monthlyWindowKey(date)` — default window (`YYYY-MM`, UTC). +- `@agentskit/observability/postgres` — `postgresCostStore({ db })`, `postgresCostMigrationSql`, and the Drizzle tables `postgresCostWindowTable`, `postgresCostReservationTable`, `postgresUsageLedgerTable`. One statement per operation, cap admitted atomically. Optional peers: `drizzle-orm`, `pg`. +- `@agentskit/observability/cost-store-contract` — `costStoreContract(store, { tenantPrefix })`, the framework-free suite a custom `CostStore` must pass. - `chargebackReport(samples, { groupBy, from, to })` + `chargebackReportToCsv(report)` — pure cost-attribution exporter. - `priceFor`, `computeCost`, `DEFAULT_PRICES` — **baseline** prices; override for current provider rates. - `approximateCounter`, `countTokens`, `countTokensDetailed`, `createProviderCounter`. diff --git a/apps/docs-next/content/docs/reference/packages/observability.mdx b/apps/docs-next/content/docs/reference/packages/observability.mdx index 47b229ade..d5c8b2a3d 100644 --- a/apps/docs-next/content/docs/reference/packages/observability.mdx +++ b/apps/docs-next/content/docs/reference/packages/observability.mdx @@ -68,6 +68,7 @@ For many teams, this is the point where an agent stops feeling magical and start - Loggers: `consoleLogger` · `langsmith` · `opentelemetry`. - Tracing: `createTraceTracker` · `createFileTraceSink` · `buildTraceReport` · `renderTraceViewerHtml`. +- Durable budgets: `CostStore` · `createInMemoryCostStore` · `recordCostSpend` · `postgresCostStore` (`/postgres`) · `costStoreContract` (`/cost-store-contract`). - Cost + tokens: `costGuard` · `priceFor` · `computeCost` · `DEFAULT_PRICES` · `approximateCounter` · `countTokens` · `countTokensDetailed` · `createProviderCounter`. - Audit: `createSignedAuditLog` · `createInMemoryAuditStore`. - Devtools: `createDevtoolsServer` · `toSseFrame`. diff --git a/docs/architecture/adrs/0044-durable-cost-store.md b/docs/architecture/adrs/0044-durable-cost-store.md new file mode 100644 index 000000000..a292af077 --- /dev/null +++ b/docs/architecture/adrs/0044-durable-cost-store.md @@ -0,0 +1,64 @@ +# ADR 0044 — Durable tenant cost store + +- **Status**: Proposed (implementation authorized; evidence review remains external) +- **Date**: 2026-10-09 + +## Context + +The cost guards in @agentskit/observability keep spend in closure state. On +hosts that run many short-lived instances, each instance starts at zero, so a +tenant budget is never reached and concurrent requests cannot see each other. +A budget that must hold needs spend recorded outside the process, and admission +that is atomic under concurrency. + +## Decision + +Add a `CostStore` port to @agentskit/observability with four tenant-scoped +operations: `reserve`, `commit`, `release` and `window`. + +- `reserve` holds an estimate against a cap passed by the caller. The cap is an + argument, not stored state, so the host reads it from its own plan data. +- `commit` replaces the hold with the real spend and appends usage ledger rows. +- `release` returns the hold when the call fails. +- Each operation is idempotent per `(tenant, reservationId)`. Settling an + unknown, released or committed reservation raises `CostReservationError`. +- Spend is grouped by an accounting `windowKey`, the UTC month by default. + +Ship two implementations: `createInMemoryCostStore` for development and tests, +and `postgresCostStore` on the optional `/postgres` subpath, with BYO Drizzle +and node-postgres, exported schema and idempotent DDL. Each PostgreSQL operation +is one statement; admission is the conditional update +`spent + reserved + amount <= cap`, so no explicit transaction is required and +the store works with a pooled or a per-request connection. + +Export `costStoreContract` on `/cost-store-contract` as the framework-free +suite any implementation must pass. + +`costGuard`, `multiTenantCostGuard` and `createAdvancedCostGuard` accept an +optional `store`. With it they write each priced `llm:end` to the store and +trip on the stored window total. Without it they keep the existing +process-local behavior and warn once when `NODE_ENV=production`. + +## Consequences + +- Existing guard options and behavior are unchanged when `store` is omitted, + apart from the production warning. +- With a store, a guard budget applies to the store's current accounting + window instead of the lifetime of one guard instance. +- Guards observe spend after it happens and write to the store asynchronously, + so they bound overshoot to in-flight calls. Hosts that must refuse a request + before it spends call `reserve` directly. +- `commit` records the real spend even above the reserved estimate. The cap is + a hard ceiling on reservations, not on provider usage already incurred. +- `drizzle-orm` and `pg` become optional peers, required only by `/postgres`. +- The advanced guard's rolling window caps (`perMinute`, `perDay`, custom) + remain process-local; only the overall budget reads the store. + +## Alternatives considered + +- **Store the cap in the cost tables.** Rejected: plan limits belong to the + host's billing data, and a second copy would drift. +- **Interactive transaction with `SELECT … FOR UPDATE`.** Rejected: it needs a + dedicated connection per reservation and does not suit pooled HTTP drivers. +- **Record spend only, without reservations.** Rejected: concurrent requests + would all pass a check made before any of them commits. diff --git a/docs/architecture/adrs/README.md b/docs/architecture/adrs/README.md index cf7d442ef..bea1ba227 100644 --- a/docs/architecture/adrs/README.md +++ b/docs/architecture/adrs/README.md @@ -79,5 +79,6 @@ Do NOT write an ADR for: routine features, bug fixes, internal refactors, or any | [0042](./0042-durable-tool-decisions.md) | Durable tool decisions in core 1.x | Proposed | | [0043](./0043-partition-bound-rag.md) | Partition-bound RAG composition | Proposed | +| [0044](./0044-durable-cost-store.md) | Durable tenant cost store | Proposed | The 6 core contracts are formalized. Future ADRs will cover specific decisions (semver policy, licensing strategy, etc.) rather than additional foundational contracts. diff --git a/docs/stability/public-api-v1.json b/docs/stability/public-api-v1.json index d75e64383..0a26c60b0 100644 --- a/docs/stability/public-api-v1.json +++ b/docs/stability/public-api-v1.json @@ -7578,6 +7578,12 @@ "type" ] }, + { + "name": "CostCommitInput", + "kinds": [ + "type" + ] + }, { "name": "costGuard", "kinds": [ @@ -7602,12 +7608,79 @@ "type" ] }, + { + "name": "CostGuardStoreOptions", + "kinds": [ + "type" + ] + }, + { + "name": "CostReleaseInput", + "kinds": [ + "type" + ] + }, + { + "name": "CostReservationError", + "kinds": [ + "type", + "value" + ] + }, + { + "name": "CostReserveInput", + "kinds": [ + "type" + ] + }, + { + "name": "CostReserveResult", + "kinds": [ + "type" + ] + }, { "name": "CostSample", "kinds": [ "type" ] }, + { + "name": "CostSpendRecord", + "kinds": [ + "type" + ] + }, + { + "name": "CostStore", + "kinds": [ + "type" + ] + }, + { + "name": "CostStoreOptions", + "kinds": [ + "type" + ] + }, + { + "name": "CostUsageEntry", + "kinds": [ + "type" + ] + }, + { + "name": "CostWindow", + "kinds": [ + "type" + ] + }, + { + "name": "CostWindowInput", + "kinds": [ + "type" + ] + }, { "name": "countTokens", "kinds": [ @@ -7650,6 +7723,12 @@ "value" ] }, + { + "name": "createInMemoryCostStore", + "kinds": [ + "value" + ] + }, { "name": "createProviderCounter", "kinds": [ @@ -7746,6 +7825,18 @@ "type" ] }, + { + "name": "InMemoryCostStore", + "kinds": [ + "type" + ] + }, + { + "name": "InMemoryLedgerRow", + "kinds": [ + "type" + ] + }, { "name": "langsmith", "kinds": [ @@ -7770,6 +7861,12 @@ "type" ] }, + { + "name": "monthlyWindowKey", + "kinds": [ + "value" + ] + }, { "name": "multiTenantCostGuard", "kinds": [ @@ -7866,6 +7963,12 @@ "type" ] }, + { + "name": "recordCostSpend", + "kinds": [ + "value" + ] + }, { "name": "renderTraceViewerHtml", "kinds": [ @@ -8189,6 +8292,33 @@ } ] }, + "./cost-store-contract": { + "conditions": [ + "import", + "require", + "types" + ], + "symbols": [ + { + "name": "costStoreContract", + "kinds": [ + "value" + ] + }, + { + "name": "CostStoreContractOptions", + "kinds": [ + "type" + ] + }, + { + "name": "CostStoreContractResult", + "kinds": [ + "type" + ] + } + ] + }, "./langfuse": { "conditions": [ "import", @@ -8216,6 +8346,51 @@ } ] }, + "./postgres": { + "conditions": [ + "import", + "require", + "types" + ], + "symbols": [ + { + "name": "postgresCostMigrationSql", + "kinds": [ + "value" + ] + }, + { + "name": "postgresCostReservationTable", + "kinds": [ + "value" + ] + }, + { + "name": "postgresCostStore", + "kinds": [ + "value" + ] + }, + { + "name": "PostgresCostStoreOptions", + "kinds": [ + "type" + ] + }, + { + "name": "postgresCostWindowTable", + "kinds": [ + "value" + ] + }, + { + "name": "postgresUsageLedgerTable", + "kinds": [ + "value" + ] + } + ] + }, "./trace-tracker": { "conditions": [ "import", diff --git a/packages/observability/README.md b/packages/observability/README.md index 65bf6de29..b01a1d0dc 100644 --- a/packages/observability/README.md +++ b/packages/observability/README.md @@ -194,6 +194,44 @@ Semantics (current hardening line): Simple `costGuard` aborts via the supplied `AbortController` when the run budget is exceeded (mark + abort before potentially hostile `onExceeded`). Multi-tenant and advanced guards do not abort the runtime by default. +### Durable budgets with `CostStore` + +A guard without a store keeps its totals in process memory: they reset on restart and are not shared between instances or isolates. That is fine in development and wrong on serverless or multi-instance hosts, so the guards warn once when `NODE_ENV=production` and no store is set. + +`CostStore` is the durable port. `reserve` holds an estimate against the tenant cap, `commit` replaces it with the real spend and writes the usage ledger, `release` returns it when the call fails, and `window` reads the current state. All four take the tenant id; `reserve`, `commit` and `release` are idempotent per `reservationId`. + +```ts +import { Pool } from 'pg' +import { drizzle } from 'drizzle-orm/node-postgres' +import { multiTenantCostGuard } from '@agentskit/observability' +import { postgresCostMigrationSql, postgresCostStore } from '@agentskit/observability/postgres' + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }) +await pool.query(postgresCostMigrationSql) // once, from your migration step +const store = postgresCostStore({ db: drizzle(pool) }) + +// Gate a request before calling the model. +const reserved = await store.reserve({ tenant, reservationId: turnId, amountUsd: 0.02, capUsd: planCapUsd }) +if (!reserved.ok) return new Response('quota exceeded', { status: 402 }) +try { + const usage = await runTurn() + await store.commit({ tenant, reservationId: turnId, actualUsd: usage.costUsd, usage: usage.calls }) +} catch (error) { + await store.release({ tenant, reservationId: turnId }) + throw error +} + +// Or let a guard record every priced call and trip on the shared total. +const guard = multiTenantCostGuard({ budgets: { [tenant]: planCapUsd }, store }) +``` + +- **Atomic cap.** The PostgreSQL store admits a reservation in one statement (`reserved + spent + amount <= cap`), so concurrent requests cannot overshoot the cap together. The cap is an argument of `reserve`: read it from your plan or entitlements table. +- **Accounting window.** Spend is grouped by `windowKey`, by default the UTC month (`2026-10`). Pass `windowKey` to the store options or per call for another period. With a store, a guard's budget applies to the current window. +- **Real spend wins.** `commit` records `actualUsd` even when it is above the reserved estimate. Reserve an upper estimate if the cap must never be passed. +- **Guards record after the fact.** A guard writes each priced `llm:end` to the store and trips when the stored total passes the budget. To stop a request before it spends, call `reserve` yourself as above. +- **Connection lifetime is yours.** Use a pool on Node. On Workers, create one `pg` client per request and close it in `waitUntil`. `drizzle-orm` and `pg` are optional peers, needed only for the `/postgres` subpath. +- **Your own store.** Implement `CostStore` over your tables and run `costStoreContract` from `@agentskit/observability/cost-store-contract` in your test suite. `createInMemoryCostStore()` is the development and test implementation. + ## Ecosystem | Package | Role | diff --git a/packages/observability/package.json b/packages/observability/package.json index db46dad69..d29a62327 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -42,6 +42,16 @@ "import": "./dist/cost-guard.js", "require": "./dist/cost-guard.cjs" }, + "./postgres": { + "types": "./dist/postgres.d.ts", + "import": "./dist/postgres.js", + "require": "./dist/postgres.cjs" + }, + "./cost-store-contract": { + "types": "./dist/cost-store-contract.d.ts", + "import": "./dist/cost-store-contract.js", + "require": "./dist/cost-store-contract.cjs" + }, "./langfuse": { "types": "./dist/langfuse.d.ts", "import": "./dist/langfuse.js", @@ -60,7 +70,9 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "lint": "tsc --noEmit", - "dev": "tsup --watch" + "dev": "tsup --watch", + "test:postgres": "pnpm exec turbo run build --filter=@agentskit/observability --concurrency=2 && vitest run tests/postgres.integration.test.ts", + "test:workerd": "pnpm exec turbo run build --filter=@agentskit/observability --concurrency=2 && node tests/postgres-workerd.mjs" }, "dependencies": { "@agentskit/core": "workspace:*", @@ -70,10 +82,18 @@ "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-trace-otlp-http": "^0.220.0 || ^0.221.0", "@opentelemetry/sdk-trace-base": "^2.9.0", + "drizzle-orm": "^0.44.0 || ^0.45.3", "langfuse": "^3.38.20", - "langsmith": "^0.8.1" + "langsmith": "^0.8.1", + "pg": "^8.23.1" }, "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + }, + "pg": { + "optional": true + }, "langfuse": { "optional": true }, @@ -94,12 +114,17 @@ "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-trace-otlp-http": "^0.220.0 || ^0.221.0", "@opentelemetry/sdk-trace-base": "^2.9.0", + "@agentskit/cross-platform": "workspace:*", "@types/node": "^25.9.5", + "@types/pg": "^8.15.6", + "drizzle-orm": "^0.45.3", "langfuse": "^3.38.20", "langsmith": "^0.8.1", + "pg": "^8.23.1", "tsup": "^8.5.1", "typescript": "^6.0.3", - "vitest": "^4.1.11" + "vitest": "^4.1.11", + "wrangler": "^4.148.0" }, "agentskit": { "stability": "beta", diff --git a/packages/observability/src/cost-guard-advanced-types.ts b/packages/observability/src/cost-guard-advanced-types.ts index 2e5d68c26..ad8657c6f 100644 --- a/packages/observability/src/cost-guard-advanced-types.ts +++ b/packages/observability/src/cost-guard-advanced-types.ts @@ -1,4 +1,5 @@ import type { TokenPrice, CostGuardErrorHandler, UnknownModelPolicy } from './cost-guard' +import type { CostGuardStoreOptions } from './cost-guard-store' /** Enforcement policy: report only, expose rejection state, or disable the tenant. */ export type CostGuardMode = 'warn' | 'reject' | 'kill' @@ -56,7 +57,7 @@ export interface CostAlertEvent { export type CostAlertSink = (event: CostAlertEvent) => void | Promise /** Configuration for per-tenant budgets, rolling caps, and enforcement. */ -export interface AdvancedCostGuardOptions { +export interface AdvancedCostGuardOptions extends CostGuardStoreOptions { /** Per-tenant USD budgets (overall, applied alongside windows). */ budgets: Record /** Fallback overall budget for tenants not listed. */ diff --git a/packages/observability/src/cost-guard-advanced.ts b/packages/observability/src/cost-guard-advanced.ts index 9b3d8912b..ba68e4468 100644 --- a/packages/observability/src/cost-guard-advanced.ts +++ b/packages/observability/src/cost-guard-advanced.ts @@ -24,6 +24,7 @@ import { validateAdvancedOptions, type TenantState, } from './cost-guard-advanced-internal' +import { durableSpendRecorder } from './cost-guard-store' export type { CostGuardMode, @@ -90,6 +91,7 @@ export function createAdvancedCostGuard( const tenants = new Map() let activeTenant: string | undefined const safeNow = createSafeNow(clock, onError, reportCostGuardError) + const durable = durableSpendRecorder('createAdvancedCostGuard', options.store, err => reportCostGuardError(onError, err)) const fireAlert = async (event: CostAlertEvent): Promise => { for (const sink of options.alertSinks ?? []) { @@ -243,6 +245,14 @@ export function createAdvancedCostGuard( } } + const settle = (tenant: string, state: TenantState, deltaCost: number): void => { + const { alerts, disable } = recordSpend(tenant, state, deltaCost) + if (alerts.length === 0 && !disable) return + void dispatchAlerts(tenant, alerts, disable).then(undefined, (err: unknown) => { + reportCostGuardError(onError, err) + }) + } + return { name: options.name ?? 'cost-guard-advanced', on(event: AgentEvent) { @@ -280,12 +290,14 @@ export function createAdvancedCostGuard( price, ) if (delta > 0) { - const { alerts, disable } = recordSpend(tenant, state, delta) - if (alerts.length > 0 || disable) { - void dispatchAlerts(tenant, alerts, disable).then(undefined, (err: unknown) => { - reportCostGuardError(onError, err) + settle(tenant, state, delta) + // The stored window total covers every instance; adopt it and re-run the overall check. + durable?.({ tenant, model: state.model, promptTokens: deltaPrompt, completionTokens: deltaCompletion, costUsd: delta }, + window => { + if (window.spentUsd <= state.totalCost) return + state.totalCost = window.spentUsd + settle(tenant, state, 0) }) - } } } }, diff --git a/packages/observability/src/cost-guard-multi-tenant.ts b/packages/observability/src/cost-guard-multi-tenant.ts index e865f8312..6fae0f661 100644 --- a/packages/observability/src/cost-guard-multi-tenant.ts +++ b/packages/observability/src/cost-guard-multi-tenant.ts @@ -11,9 +11,10 @@ import { type TokenPrice, type CostGuardErrorHandler, } from './cost-guard' +import { durableSpendRecorder, type CostGuardStoreOptions } from './cost-guard-store' /** Per-tenant budgets, pricing, tenant resolver, and isolated callbacks. */ -export interface MultiTenantCostGuardOptions { +export interface MultiTenantCostGuardOptions extends CostGuardStoreOptions { /** * Per-tenant USD budgets. Tenants not listed here either inherit * `defaultBudgetUsd` (if set) or are unmetered (no enforcement). @@ -110,6 +111,7 @@ export function multiTenantCostGuard(options: MultiTenantCostGuardOptions): Obse const onError = options.onError const tenants = new Map() let activeTenant: string | undefined + const durable = durableSpendRecorder('multiTenantCostGuard', options.store, err => reportCostGuardError(onError, err)) const resolve = (): string | undefined => { if (!options.tenantOf) return activeTenant @@ -150,11 +152,14 @@ export function multiTenantCostGuard(options: MultiTenantCostGuardOptions): Obse return } // Incremental cost for this event only — never reprice historical tokens. - s.cost += computeCost( - { promptTokens: deltaPrompt, completionTokens: deltaCompletion }, - price, - ) + const delta = computeCost({ promptTokens: deltaPrompt, completionTokens: deltaCompletion }, price) + s.cost += delta const budget = budgetOf(tenant) + const trip = (costUsd: number, budgetUsd: number) => { + if (s.exceeded) return + s.exceeded = true + invokeCostGuardCallback(() => options.onExceeded?.({ tenant, costUsd, budgetUsd }), onError) + } invokeCostGuardCallback(() => { return options.onCost?.({ tenant, @@ -165,12 +170,9 @@ export function multiTenantCostGuard(options: MultiTenantCostGuardOptions): Obse budgetRemainingUsd: budget !== undefined ? Math.max(0, budget - s.cost) : undefined, }) }, onError) - if (budget !== undefined && s.cost > budget && !s.exceeded) { - s.exceeded = true - invokeCostGuardCallback(() => { - return options.onExceeded?.({ tenant, costUsd: s.cost, budgetUsd: budget }) - }, onError) - } + if (budget !== undefined && s.cost > budget) trip(s.cost, budget) + durable?.({ tenant, model: s.model, promptTokens: deltaPrompt, completionTokens: deltaCompletion, costUsd: delta }, + window => { if (budget !== undefined && window.spentUsd > budget) trip(window.spentUsd, budget) }) } return { diff --git a/packages/observability/src/cost-guard-store.ts b/packages/observability/src/cost-guard-store.ts new file mode 100644 index 000000000..84bb2cfe3 --- /dev/null +++ b/packages/observability/src/cost-guard-store.ts @@ -0,0 +1,46 @@ +import { recordCostSpend, warnProcessLocalCostState, type CostStore, type CostWindow } from './cost-store' + +/** Durable accounting shared by `costGuard`, `multiTenantCostGuard` and `createAdvancedCostGuard`. */ +export interface CostGuardStoreOptions { + /** + * Durable store that receives every priced call. With it, the budget is checked + * against spend recorded by all instances in the store's current window; without + * it, totals live in process memory (development only; warns in production). + */ + store?: CostStore +} + +/** One priced `llm:end` event, as a guard sees it. */ +export interface PricedCall { + tenant: string + model: string | undefined + promptTokens: number + completionTokens: number + costUsd: number +} + +/** Writes one priced call and hands the resulting durable window to the guard. */ +export type DurableSpendRecorder = (call: PricedCall, onWindow: (window: CostWindow) => void) => void + +/** + * Build the recorder a guard calls after pricing an `llm:end` event. Store + * failures go to `report` and never escape the observer. + * @param scope Guard name used in the production warning. + * @param store Durable store, or `undefined` to keep process-local totals. + * @param report Isolated error reporter of the guard. + * @returns The recorder, or `undefined` when no store is configured. + */ +export function durableSpendRecorder( + scope: string, + store: CostStore | undefined, + report: (error: unknown) => void, +): DurableSpendRecorder | undefined { + if (!store) { + warnProcessLocalCostState(scope) + return undefined + } + return ({ tenant, model = 'unknown', promptTokens, completionTokens, costUsd }, onWindow) => { + void recordCostSpend(store, { tenant, costUsd, usage: [{ model, promptTokens, completionTokens, costUsd }] }) + .then(onWindow).then(undefined, report) + } +} diff --git a/packages/observability/src/cost-guard.ts b/packages/observability/src/cost-guard.ts index 751aefdd3..3326a2a34 100644 --- a/packages/observability/src/cost-guard.ts +++ b/packages/observability/src/cost-guard.ts @@ -1,4 +1,5 @@ import { ConfigError, ErrorCodes, type AgentEvent, type Observer } from '@agentskit/core' +import { durableSpendRecorder, type CostGuardStoreOptions } from './cost-guard-store' /** * Dollar cost per 1K tokens for input and output. @@ -49,7 +50,9 @@ export const DEFAULT_PRICES: Record = { } /** Configuration for an observer that tracks cumulative token spend. */ -export interface CostGuardOptions { +export interface CostGuardOptions extends CostGuardStoreOptions { + /** Tenant the spend is recorded under in `store`. Default `'default'`. */ + tenant?: string /** Hard budget in USD. Aborts the run when exceeded. */ budgetUsd: number /** @@ -320,6 +323,21 @@ export function costGuard(options: CostGuardOptions): Observer & { let cost = 0 let exceededOnce = false + const tenant = options.tenant ?? 'default' + const durable = durableSpendRecorder('costGuard', options.store, err => reportCostGuardError(onError, err)) + + const trip = (costUsd: number) => { + if (exceededOnce) return + // Mark + abort synchronously before any potentially hostile onExceeded. + exceededOnce = true + try { + controller.abort() + } catch (err) { + reportCostGuardError(onError, err) + } + invokeCostGuardCallback(() => onExceeded?.({ costUsd, budgetUsd }), onError) + } + const update = (deltaPrompt: number, deltaCompletion: number) => { prompt += deltaPrompt completion += deltaCompletion @@ -334,10 +352,8 @@ export function costGuard(options: CostGuardOptions): Observer & { return } // Incremental: price only the tokens from this event with the active model. - cost += computeCost( - { promptTokens: deltaPrompt, completionTokens: deltaCompletion }, - price, - ) + const delta = computeCost({ promptTokens: deltaPrompt, completionTokens: deltaCompletion }, price) + cost += delta invokeCostGuardCallback(() => { return onCost?.({ @@ -348,18 +364,9 @@ export function costGuard(options: CostGuardOptions): Observer & { }) }, onError) - if (cost > budgetUsd && !exceededOnce) { - // Mark + abort synchronously before any potentially hostile onExceeded. - exceededOnce = true - try { - controller.abort() - } catch (err) { - reportCostGuardError(onError, err) - } - invokeCostGuardCallback(() => { - return onExceeded?.({ costUsd: cost, budgetUsd }) - }, onError) - } + if (cost > budgetUsd) trip(cost) + durable?.({ tenant, model: currentModel, promptTokens: deltaPrompt, completionTokens: deltaCompletion, costUsd: delta }, + window => { if (window.spentUsd > budgetUsd) trip(window.spentUsd) }) } return { diff --git a/packages/observability/src/cost-store-contract.ts b/packages/observability/src/cost-store-contract.ts new file mode 100644 index 000000000..3f4b3ff24 --- /dev/null +++ b/packages/observability/src/cost-store-contract.ts @@ -0,0 +1,136 @@ +import { AgentsKitError } from '@agentskit/core' +import { CostReservationError, type CostStore } from './cost-store' + +/** Options of `costStoreContract`. */ +export interface CostStoreContractOptions { + /** Unique prefix for the tenants this run creates, so runs never share state. */ + tenantPrefix: string + /** Parallel reservations raced against the cap. Default 50. */ + concurrency?: number + /** Turns simulated by the release check. Default 100; one in five fails. */ + turns?: number +} + +/** Names of the checks run by `costStoreContract`, each mapped to `'passed'`. */ +export interface CostStoreContractResult { + accounting: 'passed' + cap: 'passed' + idempotency: 'passed' + settlement: 'passed' + isolation: 'passed' + release: 'passed' +} + +function check(condition: boolean, message: string): asserts condition { + if (!condition) throw new AgentsKitError({ code: 'AK_COST_STORE_CONTRACT', message: `CostStore contract: ${message}` }) +} + +const near = (actual: number, expected: number): boolean => Math.abs(actual - expected) < 1e-9 + +async function rejectsWith(code: CostReservationError['code'], run: () => Promise): Promise { + try { + await run() + return false + } catch (error) { + return error instanceof CostReservationError && error.code === code + } +} + +/** + * Framework-free contract every `CostStore` must pass. Runs against the store + * the caller supplies and throws on the first violated guarantee. + * @param store Store under test, already migrated and empty for `tenantPrefix`. + * @param options Tenant prefix and load sizes. + * @returns One `'passed'` entry per check. + * @example + * ```ts + * await costStoreContract(postgresCostStore({ db }), { tenantPrefix: crypto.randomUUID() }) + * ``` + */ +export async function costStoreContract(store: CostStore, options: CostStoreContractOptions): Promise { + const tenant = (name: string): string => `${options.tenantPrefix}:${name}` + const windowKey = 'contract' + + const a = tenant('accounting') + const first = await store.reserve({ tenant: a, reservationId: 'r1', amountUsd: 0.4, capUsd: 1, windowKey }) + check(first.ok && near(first.window.reservedUsd, 0.4) && near(first.window.remainingUsd ?? -1, 0.6), 'reserve holds the amount against the cap') + const committed = await store.commit({ + tenant: a, + reservationId: 'r1', + actualUsd: 0.25, + usage: [{ model: 'contract-model', promptTokens: 100, completionTokens: 20, costUsd: 0.25, source: 'contract', fallback: true }], + }) + check(near(committed.spentUsd, 0.25) && near(committed.reservedUsd, 0), 'commit replaces the reservation with the real spend') + const seen = await store.window({ tenant: a, capUsd: 1, windowKey }) + check(near(seen.spentUsd, 0.25) && near(seen.utilization ?? -1, 0.25), 'window reports committed spend and utilization') + const other = await store.window({ tenant: a, capUsd: 1, windowKey: 'contract-next' }) + check(other.spentUsd === 0 && other.reservedUsd === 0, 'accounting windows are independent') + + const c = tenant('cap') + const concurrency = options.concurrency ?? 50 + const capUsd = 1 + const amountUsd = 0.07 + const raced = await Promise.all(Array.from({ length: concurrency }, (_, index) => + store.reserve({ tenant: c, reservationId: `race-${index}`, amountUsd, capUsd, windowKey }))) + const admitted = raced.filter(result => result.ok).length + const held = await store.window({ tenant: c, capUsd, windowKey }) + check(held.reservedUsd <= capUsd + 1e-9, `concurrent reservations never exceed the cap (reserved ${held.reservedUsd})`) + check(admitted === Math.min(concurrency, Math.floor(capUsd / amountUsd)), `exactly the reservations that fit are admitted (${admitted})`) + check(near(held.reservedUsd, admitted * amountUsd), 'reserved total equals the admitted reservations') + const denied = await store.reserve({ tenant: c, reservationId: 'over', amountUsd: 5, capUsd, windowKey }) + check(!denied.ok && denied.reason === 'quota_exceeded', 'a reservation over the cap is denied') + check(near((await store.window({ tenant: c, windowKey })).reservedUsd, held.reservedUsd), 'a denied reservation holds nothing') + + const i = tenant('idempotency') + const replays = await Promise.all(Array.from({ length: 10 }, () => + store.reserve({ tenant: i, reservationId: 'same', amountUsd: 0.1, capUsd: 1, windowKey }))) + check(replays.every(result => result.ok), 'a repeated reservation id replays the first outcome') + check(near((await store.window({ tenant: i, windowKey })).reservedUsd, 0.1), 'a repeated reservation id is counted once') + await Promise.all(Array.from({ length: 5 }, () => store.commit({ tenant: i, reservationId: 'same', actualUsd: 0.05 }))) + const once = await store.window({ tenant: i, windowKey }) + check(near(once.spentUsd, 0.05) && near(once.reservedUsd, 0), 'a repeated commit is counted once') + + const s = tenant('settlement') + await store.reserve({ tenant: s, reservationId: 'released', amountUsd: 0.2, windowKey }) + await store.release({ tenant: s, reservationId: 'released' }) + const again = await store.release({ tenant: s, reservationId: 'released' }) + check(near(again.reservedUsd, 0) && near(again.spentUsd, 0), 'release returns the reservation and is idempotent') + check(await rejectsWith('AK_COST_RESERVATION_RELEASED', () => store.commit({ tenant: s, reservationId: 'released', actualUsd: 0.2 })), 'commit after release is rejected') + await store.reserve({ tenant: s, reservationId: 'committed', amountUsd: 0.2, windowKey }) + await store.commit({ tenant: s, reservationId: 'committed', actualUsd: 0.3 }) + check(await rejectsWith('AK_COST_RESERVATION_COMMITTED', () => store.release({ tenant: s, reservationId: 'committed' })), 'release after commit is rejected') + check(await rejectsWith('AK_COST_RESERVATION_NOT_FOUND', () => store.commit({ tenant: s, reservationId: 'unknown', actualUsd: 0 })), 'commit of an unknown reservation is rejected') + check(await rejectsWith('AK_COST_RESERVATION_NOT_FOUND', () => store.release({ tenant: s, reservationId: 'unknown' })), 'release of an unknown reservation is rejected') + check(near((await store.window({ tenant: s, windowKey })).spentUsd, 0.3), 'actual spend may exceed the reserved estimate') + + const x = tenant('isolation-a') + const y = tenant('isolation-b') + await store.reserve({ tenant: x, reservationId: 'shared-id', amountUsd: 0.9, capUsd: 1, windowKey }) + const neighbour = await store.reserve({ tenant: y, reservationId: 'own-id', amountUsd: 0.9, capUsd: 1, windowKey }) + check(neighbour.ok && near(neighbour.window.reservedUsd, 0.9), 'tenants do not share caps') + const sameId = await store.reserve({ tenant: y, reservationId: 'shared-id', amountUsd: 0.05, capUsd: 1, windowKey }) + check(sameId.ok && near(sameId.window.reservedUsd, 0.95), 'tenants do not share reservation ids') + check(near((await store.window({ tenant: x, windowKey })).reservedUsd, 0.9), 'a reservation only changes its own tenant') + check(await rejectsWith('AK_COST_RESERVATION_NOT_FOUND', () => store.commit({ tenant: tenant('isolation-c'), reservationId: 'shared-id', actualUsd: 0 })), 'a tenant cannot settle another tenant\'s reservation') + + const r = tenant('release') + const turns = options.turns ?? 100 + let expected = 0 + await Promise.all(Array.from({ length: turns }, async (_, index) => { + const reservationId = `turn-${index}` + const reserved = await store.reserve({ tenant: r, reservationId, amountUsd: 0.01, capUsd: turns, windowKey }) + check(reserved.ok, 'turn reservation is admitted') + if (index % 5 === 0) { + await store.release({ tenant: r, reservationId }) + return + } + const actualUsd = 0.001 * ((index % 7) + 1) + expected += actualUsd + await store.commit({ tenant: r, reservationId, actualUsd, usage: [{ model: 'contract-model', promptTokens: index, completionTokens: 1, costUsd: actualUsd }] }) + })) + const settled = await store.window({ tenant: r, windowKey }) + check(near(settled.reservedUsd, 0), 'failed turns release their reservations') + check(near(settled.spentUsd, expected), `spend equals the committed turns (${settled.spentUsd} vs ${expected})`) + + return { accounting: 'passed', cap: 'passed', idempotency: 'passed', settlement: 'passed', isolation: 'passed', release: 'passed' } +} diff --git a/packages/observability/src/cost-store.ts b/packages/observability/src/cost-store.ts new file mode 100644 index 000000000..1ff8c69c9 --- /dev/null +++ b/packages/observability/src/cost-store.ts @@ -0,0 +1,308 @@ +import { AgentsKitError, ConfigError, ErrorCodes } from '@agentskit/core' + +/** One priced model call, written to the usage ledger when a reservation is committed. */ +export interface CostUsageEntry { + model: string + promptTokens: number + completionTokens: number + costUsd: number + /** Free-form origin of the call (`'chat'`, `'extraction'`, a route name). */ + source?: string + /** True when the call ran on a fallback model. */ + fallback?: boolean +} + +/** Durable spend state of one tenant in one accounting window. */ +export interface CostWindow { + tenant: string + windowKey: string + /** Committed spend in USD. */ + spentUsd: number + /** Reserved, not yet committed or released, in USD. */ + reservedUsd: number + /** Cap this window was evaluated against, when one was supplied. */ + capUsd?: number + /** `capUsd - spentUsd - reservedUsd`, never negative. */ + remainingUsd?: number + /** `(spentUsd + reservedUsd) / capUsd`, always finite. */ + utilization?: number +} + +/** Input of `CostStore.reserve`. */ +export interface CostReserveInput { + tenant: string + /** Caller-chosen idempotency key, unique per tenant. Reusing it replays the first outcome. */ + reservationId: string + /** Upper estimate of the spend to hold, in USD. */ + amountUsd: number + /** Tenant cap for the window, in USD. Omit to hold the amount without a ceiling. */ + capUsd?: number + /** Accounting window. Defaults to the store's current window. */ + windowKey?: string +} + +/** Outcome of `CostStore.reserve`. A denied reservation holds nothing. */ +export type CostReserveResult = + | { ok: true; reservationId: string; window: CostWindow } + | { ok: false; reason: 'quota_exceeded'; window: CostWindow } + +/** Input of `CostStore.commit`. */ +export interface CostCommitInput { + tenant: string + reservationId: string + /** Real spend in USD. Replaces the reserved amount, and may exceed it. */ + actualUsd: number + /** Ledger rows for the calls that produced `actualUsd`. */ + usage?: readonly CostUsageEntry[] +} + +/** Input of `CostStore.release`. */ +export interface CostReleaseInput { + tenant: string + reservationId: string +} + +/** Input of `CostStore.window`. */ +export interface CostWindowInput { + tenant: string + capUsd?: number + windowKey?: string +} + +/** + * Durable, tenant-scoped spend accounting. `reserve` holds an estimate + * atomically against the cap, `commit` replaces it with the real spend, and + * `release` returns it when the call fails. All three are idempotent per + * `reservationId`: a repeated call returns the current window without counting twice. + */ +export interface CostStore { + reserve: (input: CostReserveInput) => Promise + commit: (input: CostCommitInput) => Promise + release: (input: CostReleaseInput) => Promise + window: (input: CostWindowInput) => Promise +} + +/** Options shared by the bundled `CostStore` implementations. */ +export interface CostStoreOptions { + /** Maps the current time to an accounting window. Defaults to the UTC month (`2026-10`). */ + windowKey?: (now: Date) => string + /** Clock override for tests. */ + now?: () => Date +} + +const RESERVATION_ERRORS = { + AK_COST_RESERVATION_NOT_FOUND: 'does not exist', + AK_COST_RESERVATION_RELEASED: 'was already released', + AK_COST_RESERVATION_COMMITTED: 'was already committed', +} as const + +/** Thrown when `commit` or `release` targets a reservation the store cannot settle. */ +export class CostReservationError extends AgentsKitError { + declare readonly code: keyof typeof RESERVATION_ERRORS + + constructor(code: keyof typeof RESERVATION_ERRORS, reservationId: string) { + super({ + code, + message: `cost store: reservation ${JSON.stringify(reservationId)} ${RESERVATION_ERRORS[code]}`, + hint: 'Settle each reservation once: commit it after the call succeeds or release it when the call fails.', + }) + this.name = 'CostReservationError' + } +} + +/** UTC calendar month of `now`, formatted `YYYY-MM`. */ +export function monthlyWindowKey(now: Date): string { + return now.toISOString().slice(0, 7) +} + +/** Build a `CostWindow`, deriving `remainingUsd` and `utilization` when a cap is known. */ +export function describeCostWindow( + tenant: string, + windowKey: string, + spentUsd: number, + reservedUsd: number, + capUsd: number | undefined, +): CostWindow { + if (capUsd === undefined) return { tenant, windowKey, spentUsd, reservedUsd } + const used = spentUsd + reservedUsd + const ratio = capUsd > 0 ? used / capUsd : Math.sign(used) + return { + tenant, + windowKey, + spentUsd, + reservedUsd, + capUsd, + remainingUsd: Math.max(0, capUsd - used), + utilization: Number.isFinite(ratio) ? ratio : 1, + } +} + +function assertKey(name: string, value: string): void { + if (typeof value !== 'string' || !value.trim()) { + throw new ConfigError({ + code: ErrorCodes.AK_CONFIG_INVALID, + message: `cost store: ${name} must be a non-empty string`, + hint: 'Pass the authenticated tenant id and a unique reservation id.', + }) + } +} + +function assertUsd(name: string, value: number): void { + if (!Number.isFinite(value) || value < 0) { + throw new ConfigError({ + code: ErrorCodes.AK_CONFIG_INVALID, + message: `cost store: ${name} must be a finite number ≥ 0 (received ${String(value)})`, + hint: 'Pass amounts and caps in USD as finite non-negative numbers.', + }) + } +} + +/** Validate a reserve input; shared by every `CostStore` implementation. */ +export function validateCostReserve(input: CostReserveInput): void { + assertKey('tenant', input.tenant) + assertKey('reservationId', input.reservationId) + assertUsd('amountUsd', input.amountUsd) + if (input.capUsd !== undefined) assertUsd('capUsd', input.capUsd) +} + +/** Validate a commit input; shared by every `CostStore` implementation. */ +export function validateCostCommit(input: CostCommitInput): void { + assertKey('tenant', input.tenant) + assertKey('reservationId', input.reservationId) + assertUsd('actualUsd', input.actualUsd) + for (const entry of input.usage ?? []) { + assertKey('usage.model', entry.model) + assertUsd('usage.promptTokens', entry.promptTokens) + assertUsd('usage.completionTokens', entry.completionTokens) + assertUsd('usage.costUsd', entry.costUsd) + } +} + +// USD is tracked in integer nano-dollars so repeated sums stay exact. +const NANO = 1e9 +const toNano = (usd: number): number => Math.round(usd * NANO) + +interface MemoryReservation { + windowKey: string + amount: number + status: 'reserved' | 'committed' | 'released' +} + +/** A ledger row as kept by `createInMemoryCostStore`. */ +export interface InMemoryLedgerRow extends CostUsageEntry { + tenant: string + reservationId: string +} + +/** `CostStore` plus the ledger rows written by `commit`, for assertions in tests. */ +export interface InMemoryCostStore extends CostStore { + ledger: () => InMemoryLedgerRow[] +} + +/** + * Process-local `CostStore` for development and tests. State is lost on restart + * and is not shared between instances or isolates; use a durable store in production. + */ +export function createInMemoryCostStore(options: CostStoreOptions = {}): InMemoryCostStore { + const keyOf = options.windowKey ?? monthlyWindowKey + const now = options.now ?? (() => new Date()) + const windows = new Map() + const reservations = new Map() + const ledger: InMemoryLedgerRow[] = [] + + const windowOf = (tenant: string, windowKey: string) => { + const id = JSON.stringify([tenant, windowKey]) + let state = windows.get(id) + if (!state) { + state = { spent: 0, reserved: 0 } + windows.set(id, state) + } + return state + } + const describe = (tenant: string, windowKey: string, capUsd?: number): CostWindow => { + const state = windowOf(tenant, windowKey) + return describeCostWindow(tenant, windowKey, state.spent / NANO, state.reserved / NANO, capUsd) + } + const find = (tenant: string, reservationId: string): MemoryReservation => { + const reservation = reservations.get(JSON.stringify([tenant, reservationId])) + if (!reservation) throw new CostReservationError('AK_COST_RESERVATION_NOT_FOUND', reservationId) + return reservation + } + + return { + async reserve(input) { + validateCostReserve(input) + const id = JSON.stringify([input.tenant, input.reservationId]) + const existing = reservations.get(id) + if (existing) return { ok: true, reservationId: input.reservationId, window: describe(input.tenant, existing.windowKey, input.capUsd) } + const windowKey = input.windowKey ?? keyOf(now()) + const state = windowOf(input.tenant, windowKey) + const amount = toNano(input.amountUsd) + if (input.capUsd !== undefined && state.spent + state.reserved + amount > toNano(input.capUsd)) { + return { ok: false, reason: 'quota_exceeded', window: describe(input.tenant, windowKey, input.capUsd) } + } + state.reserved += amount + reservations.set(id, { windowKey, amount, status: 'reserved' }) + return { ok: true, reservationId: input.reservationId, window: describe(input.tenant, windowKey, input.capUsd) } + }, + async commit(input) { + validateCostCommit(input) + const reservation = find(input.tenant, input.reservationId) + if (reservation.status === 'released') throw new CostReservationError('AK_COST_RESERVATION_RELEASED', input.reservationId) + if (reservation.status === 'reserved') { + const state = windowOf(input.tenant, reservation.windowKey) + state.reserved -= reservation.amount + state.spent += toNano(input.actualUsd) + reservation.status = 'committed' + for (const entry of input.usage ?? []) ledger.push({ ...entry, tenant: input.tenant, reservationId: input.reservationId }) + } + return describe(input.tenant, reservation.windowKey) + }, + async release(input) { + assertKey('tenant', input.tenant) + assertKey('reservationId', input.reservationId) + const reservation = find(input.tenant, input.reservationId) + if (reservation.status === 'committed') throw new CostReservationError('AK_COST_RESERVATION_COMMITTED', input.reservationId) + if (reservation.status === 'reserved') { + windowOf(input.tenant, reservation.windowKey).reserved -= reservation.amount + reservation.status = 'released' + } + return describe(input.tenant, reservation.windowKey) + }, + async window(input) { + assertKey('tenant', input.tenant) + return describe(input.tenant, input.windowKey ?? keyOf(now()), input.capUsd) + }, + ledger: () => ledger.map(row => ({ ...row })), + } +} + +/** Spend that already happened and must be written to a `CostStore` by an observer. */ +export interface CostSpendRecord { + tenant: string + costUsd: number + usage?: readonly CostUsageEntry[] +} + +/** + * Record spend that already happened: reserve it without a ceiling, then commit + * it, and return the resulting window. Cost guards use this after each priced call. + */ +export async function recordCostSpend(store: CostStore, record: CostSpendRecord): Promise { + const reservationId = `spend-${globalThis.crypto.randomUUID()}` + await store.reserve({ tenant: record.tenant, reservationId, amountUsd: record.costUsd }) + return store.commit({ tenant: record.tenant, reservationId, actualUsd: record.costUsd, usage: record.usage }) +} + +const warned = new Set() + +/** + * Warn once per guard when it runs in production without a `CostStore`: + * its totals then live in process memory and do not hold across instances. + */ +export function warnProcessLocalCostState(scope: string): void { + const env = (globalThis as { process?: { env?: Record } }).process?.env + if (env?.NODE_ENV !== 'production' || warned.has(scope)) return + warned.add(scope) + console.warn(`[agentskit] ${scope}: no CostStore configured. Spend is tracked in process memory, so budgets reset on restart and are not shared between instances or isolates. Pass a durable \`store\`.`) +} diff --git a/packages/observability/src/index.ts b/packages/observability/src/index.ts index 8b4e5cb29..5b0ca5905 100644 --- a/packages/observability/src/index.ts +++ b/packages/observability/src/index.ts @@ -30,6 +30,22 @@ export type { TraceSpan, TraceTrackerCallbacks } from './trace-tracker' export { costGuard, priceFor, computeCost, DEFAULT_PRICES } from './cost-guard' export type { CostGuardOptions, TokenPrice, CostGuardErrorHandler, UnknownModelPolicy } from './cost-guard' +export { createInMemoryCostStore, recordCostSpend, monthlyWindowKey, CostReservationError } from './cost-store' +export type { + CostStore, + CostStoreOptions, + CostWindow, + CostUsageEntry, + CostReserveInput, + CostReserveResult, + CostCommitInput, + CostReleaseInput, + CostWindowInput, + CostSpendRecord, + InMemoryCostStore, + InMemoryLedgerRow, +} from './cost-store' +export type { CostGuardStoreOptions } from './cost-guard-store' export { multiTenantCostGuard } from './cost-guard-multi-tenant' export type { MultiTenantCostGuardOptions } from './cost-guard-multi-tenant' diff --git a/packages/observability/src/postgres.ts b/packages/observability/src/postgres.ts new file mode 100644 index 000000000..f90c04900 --- /dev/null +++ b/packages/observability/src/postgres.ts @@ -0,0 +1,199 @@ +import { sql } from 'drizzle-orm' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { bigserial, boolean, integer, numeric, pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core' +import { + CostReservationError, + describeCostWindow, + monthlyWindowKey, + validateCostCommit, + validateCostReserve, + type CostStore, + type CostStoreOptions, + type CostWindow, +} from './cost-store' + +const usd = (name: string) => numeric(name, { precision: 20, scale: 9 }) + +/** Drizzle schema: committed and reserved spend per tenant and accounting window. */ +export const postgresCostWindowTable = pgTable('agentskit_cost_window', { + tenantId: text('tenant_id').notNull(), + windowKey: text('window_key').notNull(), + spentUsd: usd('spent_usd').notNull().default('0'), + reservedUsd: usd('reserved_usd').notNull().default('0'), +}, table => [primaryKey({ columns: [table.tenantId, table.windowKey] })]) + +/** Drizzle schema: one row per reservation, the idempotency record of reserve/commit/release. */ +export const postgresCostReservationTable = pgTable('agentskit_cost_reservation', { + tenantId: text('tenant_id').notNull(), + reservationId: text('reservation_id').notNull(), + windowKey: text('window_key').notNull(), + amountUsd: usd('amount_usd').notNull(), + actualUsd: usd('actual_usd'), + status: text('status').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, table => [primaryKey({ columns: [table.tenantId, table.reservationId] })]) + +/** Drizzle schema: append-only usage ledger written when a reservation is committed. */ +export const postgresUsageLedgerTable = pgTable('agentskit_usage_ledger', { + id: bigserial('id', { mode: 'number' }).primaryKey(), + tenantId: text('tenant_id').notNull(), + reservationId: text('reservation_id').notNull(), + model: text('model').notNull(), + promptTokens: integer('prompt_tokens').notNull(), + completionTokens: integer('completion_tokens').notNull(), + costUsd: usd('cost_usd').notNull(), + source: text('source'), + fallback: boolean('fallback').notNull().default(false), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}) + +/** Idempotent PostgreSQL DDL; execute with the host's migration connection before use. */ +export const postgresCostMigrationSql = `CREATE TABLE IF NOT EXISTS agentskit_cost_window ( + tenant_id text NOT NULL, + window_key text NOT NULL, + spent_usd numeric(20, 9) NOT NULL DEFAULT 0, + reserved_usd numeric(20, 9) NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, window_key) +); +CREATE TABLE IF NOT EXISTS agentskit_cost_reservation ( + tenant_id text NOT NULL, + reservation_id text NOT NULL, + window_key text NOT NULL, + amount_usd numeric(20, 9) NOT NULL, + actual_usd numeric(20, 9), + status text NOT NULL CHECK (status IN ('reserved', 'committed', 'released')), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, reservation_id) +); +CREATE TABLE IF NOT EXISTS agentskit_usage_ledger ( + id bigserial PRIMARY KEY, + tenant_id text NOT NULL, + reservation_id text NOT NULL, + model text NOT NULL, + prompt_tokens integer NOT NULL, + completion_tokens integer NOT NULL, + cost_usd numeric(20, 9) NOT NULL, + source text, + fallback boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS agentskit_usage_ledger_tenant_created ON agentskit_usage_ledger (tenant_id, created_at);` + +/** Host-owned Drizzle connection plus the accounting window policy. */ +export interface PostgresCostStoreOptions extends CostStoreOptions { + db: Pick +} + +type Row = Record + +const UNIQUE_VIOLATION = '23505' + +function isUniqueViolation(error: unknown): boolean { + for (let current: unknown = error, depth = 0; current && depth < 4; depth++) { + if ((current as { code?: unknown }).code === UNIQUE_VIOLATION) return true + current = (current as { cause?: unknown }).cause + } + return false +} + +/** + * Durable `CostStore` on PostgreSQL. Each operation is one statement, so a + * reservation is admitted against the cap atomically without an explicit + * transaction. BYO Drizzle/pg connection; the caller owns migration and lifetime. + */ +export function postgresCostStore({ db, windowKey: keyOf = monthlyWindowKey, now = () => new Date() }: PostgresCostStoreOptions): CostStore { + const rows = async (query: ReturnType): Promise => (await db.execute(query)).rows as Row[] + + const window = async (tenant: string, windowKey: string, capUsd?: number): Promise => { + const [row] = await rows(sql`SELECT spent_usd, reserved_usd FROM agentskit_cost_window WHERE tenant_id = ${tenant} AND window_key = ${windowKey}`) + return describeCostWindow(tenant, windowKey, Number(row?.spent_usd ?? 0), Number(row?.reserved_usd ?? 0), capUsd) + } + + const reservation = async (tenant: string, reservationId: string): Promise<{ windowKey: string; status: string }> => { + const [row] = await rows(sql`SELECT window_key, status FROM agentskit_cost_reservation WHERE tenant_id = ${tenant} AND reservation_id = ${reservationId}`) + if (!row) throw new CostReservationError('AK_COST_RESERVATION_NOT_FOUND', reservationId) + return { windowKey: String(row.window_key), status: String(row.status) } + } + + return { + async reserve(input) { + validateCostReserve(input) + const { tenant, reservationId, amountUsd } = input + const windowKey = input.windowKey ?? keyOf(now()) + const cap = input.capUsd ?? null + let admitted: Row[] + try { + admitted = await rows(sql`WITH held AS ( + INSERT INTO agentskit_cost_window AS w (tenant_id, window_key, reserved_usd) + SELECT ${tenant}, ${windowKey}, ${amountUsd}::numeric + WHERE (${cap}::numeric IS NULL OR ${amountUsd}::numeric <= ${cap}::numeric) + AND NOT EXISTS (SELECT 1 FROM agentskit_cost_reservation WHERE tenant_id = ${tenant} AND reservation_id = ${reservationId}) + ON CONFLICT (tenant_id, window_key) DO UPDATE SET reserved_usd = w.reserved_usd + EXCLUDED.reserved_usd + WHERE ${cap}::numeric IS NULL OR w.spent_usd + w.reserved_usd + EXCLUDED.reserved_usd <= ${cap}::numeric + RETURNING w.spent_usd, w.reserved_usd + ), saved AS ( + INSERT INTO agentskit_cost_reservation (tenant_id, reservation_id, window_key, amount_usd, status) + SELECT ${tenant}, ${reservationId}, ${windowKey}, ${amountUsd}::numeric, 'reserved' FROM held + ) + SELECT spent_usd, reserved_usd FROM held`) + } catch (error) { + // A concurrent reserve with the same id won; this statement rolled back whole. + if (!isUniqueViolation(error)) throw error + admitted = [] + } + const [row] = admitted + if (row) { + return { ok: true, reservationId, window: describeCostWindow(tenant, windowKey, Number(row.spent_usd), Number(row.reserved_usd), input.capUsd) } + } + const [existing] = await rows(sql`SELECT window_key FROM agentskit_cost_reservation WHERE tenant_id = ${tenant} AND reservation_id = ${reservationId}`) + if (existing) return { ok: true, reservationId, window: await window(tenant, String(existing.window_key), input.capUsd) } + return { ok: false, reason: 'quota_exceeded', window: await window(tenant, windowKey, input.capUsd) } + }, + async commit(input) { + validateCostCommit(input) + const { tenant, reservationId, actualUsd } = input + const usage = JSON.stringify((input.usage ?? []).map(entry => ({ + model: entry.model, + prompt_tokens: Math.round(entry.promptTokens), + completion_tokens: Math.round(entry.completionTokens), + cost_usd: entry.costUsd, + source: entry.source ?? null, + fallback: entry.fallback ?? false, + }))) + const [row] = await rows(sql`WITH settled AS ( + UPDATE agentskit_cost_reservation SET status = 'committed', actual_usd = ${actualUsd}::numeric + WHERE tenant_id = ${tenant} AND reservation_id = ${reservationId} AND status = 'reserved' + RETURNING window_key, amount_usd + ), ledger AS ( + INSERT INTO agentskit_usage_ledger (tenant_id, reservation_id, model, prompt_tokens, completion_tokens, cost_usd, source, fallback) + SELECT ${tenant}, ${reservationId}, e.model, e.prompt_tokens, e.completion_tokens, e.cost_usd, e.source, e.fallback + FROM settled, jsonb_to_recordset(${usage}::jsonb) + AS e(model text, prompt_tokens integer, completion_tokens integer, cost_usd numeric, source text, fallback boolean) + ) + UPDATE agentskit_cost_window AS w + SET reserved_usd = w.reserved_usd - settled.amount_usd, spent_usd = w.spent_usd + ${actualUsd}::numeric + FROM settled WHERE w.tenant_id = ${tenant} AND w.window_key = settled.window_key + RETURNING w.window_key, w.spent_usd, w.reserved_usd`) + if (row) return describeCostWindow(tenant, String(row.window_key), Number(row.spent_usd), Number(row.reserved_usd), undefined) + const existing = await reservation(tenant, reservationId) + if (existing.status === 'released') throw new CostReservationError('AK_COST_RESERVATION_RELEASED', reservationId) + return window(tenant, existing.windowKey) + }, + async release(input) { + const { tenant, reservationId } = input + const [row] = await rows(sql`WITH settled AS ( + UPDATE agentskit_cost_reservation SET status = 'released' + WHERE tenant_id = ${tenant} AND reservation_id = ${reservationId} AND status = 'reserved' + RETURNING window_key, amount_usd + ) + UPDATE agentskit_cost_window AS w SET reserved_usd = w.reserved_usd - settled.amount_usd + FROM settled WHERE w.tenant_id = ${tenant} AND w.window_key = settled.window_key + RETURNING w.window_key, w.spent_usd, w.reserved_usd`) + if (row) return describeCostWindow(tenant, String(row.window_key), Number(row.spent_usd), Number(row.reserved_usd), undefined) + const existing = await reservation(tenant, reservationId) + if (existing.status === 'committed') throw new CostReservationError('AK_COST_RESERVATION_COMMITTED', reservationId) + return window(tenant, existing.windowKey) + }, + window: input => window(input.tenant, input.windowKey ?? keyOf(now()), input.capUsd), + } +} diff --git a/packages/observability/tests/cost-guard-store.test.ts b/packages/observability/tests/cost-guard-store.test.ts new file mode 100644 index 000000000..8353305b1 --- /dev/null +++ b/packages/observability/tests/cost-guard-store.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentEvent } from '@agentskit/core' +import { costGuard } from '../src/cost-guard' +import { createAdvancedCostGuard } from '../src/cost-guard-advanced' +import { multiTenantCostGuard } from '../src/cost-guard-multi-tenant' +import { createInMemoryCostStore, type CostStore } from '../src/cost-store' + +const flush = async () => { for (let i = 0; i < 5; i++) await new Promise(resolve => setImmediate(resolve)) } +const prices = { m: { input: 1, output: 0 } } +const call = (promptTokens: number): AgentEvent[] => [ + { type: 'llm:start', spanId: 's', parentSpanId: undefined, model: 'm', attributes: {}, startTime: 0 } as unknown as AgentEvent, + { type: 'llm:end', spanId: 's', usage: { promptTokens, completionTokens: 0 }, endTime: 1 } as unknown as AgentEvent, +] +const send = (observer: { on?: (event: AgentEvent) => void }, promptTokens: number) => { + for (const event of call(promptTokens)) observer.on?.(event) +} + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +describe('cost guards with a CostStore', () => { + it('costGuard aborts on spend recorded by another instance', async () => { + const store = createInMemoryCostStore() + const first = costGuard({ budgetUsd: 1, controller: new AbortController(), prices, store, tenant: 'acme' }) + const controller = new AbortController() + const onExceeded = vi.fn() + const second = costGuard({ budgetUsd: 1, controller, prices, store, tenant: 'acme', onExceeded }) + send(first, 700) + await flush() + send(second, 700) + expect(second.exceeded()).toBe(false) + await flush() + expect(second.costUsd()).toBeCloseTo(0.7) + expect(second.exceeded()).toBe(true) + expect(controller.signal.aborted).toBe(true) + expect(onExceeded).toHaveBeenCalledWith({ costUsd: expect.closeTo(1.4), budgetUsd: 1 }) + expect(store.ledger()).toHaveLength(2) + expect(store.ledger()[0]).toMatchObject({ tenant: 'acme', model: 'm', promptTokens: 700, costUsd: expect.closeTo(0.7) }) + }) + + it('multiTenantCostGuard trips per tenant from the durable window', async () => { + const store = createInMemoryCostStore() + const onExceeded = vi.fn() + const make = () => multiTenantCostGuard({ budgets: { a: 1, b: 1 }, prices, store, onExceeded }) + const [one, two] = [make(), make()] + one.setTenant('a') + two.setTenant('a') + send(one, 600) + await flush() + send(two, 600) + await flush() + expect(two.exceeded('a')).toBe(true) + expect(two.exceeded('b')).toBe(false) + expect(onExceeded).toHaveBeenCalledTimes(1) + expect(await store.window({ tenant: 'a' })).toMatchObject({ spentUsd: expect.closeTo(1.2) }) + }) + + it('createAdvancedCostGuard rejects and kills on the durable overall budget', async () => { + const store = createInMemoryCostStore() + const warm = createAdvancedCostGuard({ budgets: { a: 1 }, prices, store }) + warm.setTenant('a') + send(warm, 900) + await flush() + const reject = createAdvancedCostGuard({ budgets: { a: 1 }, prices, store, mode: 'reject' }) + reject.setTenant('a') + send(reject, 200) + expect(reject.isRejected('a')).toBe(false) + await flush() + expect(reject.isRejected('a')).toBe(true) + const disableRuntime = vi.fn() + const alerts: string[] = [] + const kill = createAdvancedCostGuard({ budgets: { a: 1 }, prices, store, mode: 'kill', disableRuntime, alertSinks: [event => { alerts.push(event.type) }] }) + kill.setTenant('a') + send(kill, 100) + await flush() + expect(kill.isDisabled('a')).toBe(true) + expect(disableRuntime).toHaveBeenCalledWith('a', 'overall budget exceeded ($1 cap)') + expect(alerts).toEqual(['cost:exceeded', 'cost:disabled']) + }) + + it('isolates store failures and reports them through onError', async () => { + const failing: CostStore = { + reserve: async () => { throw new Error('store down') }, + commit: async () => { throw new Error('store down') }, + release: async () => { throw new Error('store down') }, + window: async () => { throw new Error('store down') }, + } + const onError = vi.fn() + const guard = costGuard({ budgetUsd: 1, controller: new AbortController(), prices, store: failing, onError }) + expect(() => send(guard, 100)).not.toThrow() + await flush() + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'store down' })) + expect(guard.costUsd()).toBeCloseTo(0.1) + }) + + it('warns in production when no store is configured', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubEnv('NODE_ENV', 'production') + costGuard({ budgetUsd: 1, controller: new AbortController() }) + multiTenantCostGuard({ budgets: {} }) + createAdvancedCostGuard({ budgets: {} }) + costGuard({ budgetUsd: 1, controller: new AbortController(), store: createInMemoryCostStore() }) + expect(warn.mock.calls.map(args => String(args[0]).split(':')[0])).toEqual([ + '[agentskit] costGuard', '[agentskit] multiTenantCostGuard', '[agentskit] createAdvancedCostGuard', + ]) + }) +}) diff --git a/packages/observability/tests/cost-store-contract.test.ts b/packages/observability/tests/cost-store-contract.test.ts new file mode 100644 index 000000000..55413ccbe --- /dev/null +++ b/packages/observability/tests/cost-store-contract.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { createInMemoryCostStore, type CostStore } from '../src/cost-store' +import { costStoreContract } from '../src/cost-store-contract' + +const run = (store: CostStore) => costStoreContract(store, { tenantPrefix: 'broken', concurrency: 20, turns: 20 }) +const broken = (override: (store: CostStore) => Partial): CostStore => { + const store = createInMemoryCostStore() + return { ...store, ...override(store) } +} + +describe('costStoreContract', () => { + it('fails a store that checks the cap before an asynchronous write', async () => { + const reserved = new Map() + const racy = broken(store => ({ + async reserve(input) { + const held = reserved.get(input.tenant) ?? 0 + await Promise.resolve() + if (input.capUsd !== undefined && held + input.amountUsd > input.capUsd) return { ok: false, reason: 'quota_exceeded', window: await store.window(input) } + reserved.set(input.tenant, held + input.amountUsd) + await store.reserve({ ...input, capUsd: undefined }) + return { ok: true, reservationId: input.reservationId, window: await store.window(input) } + }, + })) + await expect(run(racy)).rejects.toThrow(/never exceed the cap/) + }) + + it('fails a store that counts a repeated reservation id twice', async () => { + let attempt = 0 + const doubled = broken(store => ({ + reserve: input => store.reserve(input.reservationId === 'same' ? { ...input, reservationId: `same-${attempt++}` } : input), + })) + await expect(run(doubled)).rejects.toThrow(/counted once/) + }) + + it('fails a store that keeps a released reservation held', async () => { + const leaky = broken(store => ({ release: input => store.window({ tenant: input.tenant, windowKey: 'contract' }) })) + await expect(run(leaky)).rejects.toThrow(/release returns the reservation|commit after release/) + }) + + it('fails a store that shares reservations between tenants', async () => { + const shared = broken(store => ({ + reserve: input => store.reserve({ ...input, tenant: input.tenant.includes('isolation') ? 'broken:shared' : input.tenant }), + })) + await expect(run(shared)).rejects.toThrow(/tenants do not share caps/) + }) + + it('fails a store that settles unknown reservations silently', async () => { + const silent = broken(store => ({ + commit: async input => { + try { return await store.commit(input) } catch { return store.window({ tenant: input.tenant, windowKey: 'contract' }) } + }, + })) + await expect(run(silent)).rejects.toThrow(/commit after release is rejected/) + }) +}) diff --git a/packages/observability/tests/cost-store.test.ts b/packages/observability/tests/cost-store.test.ts new file mode 100644 index 000000000..efe4d561a --- /dev/null +++ b/packages/observability/tests/cost-store.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ConfigError } from '@agentskit/core' +import { + CostReservationError, + createInMemoryCostStore, + describeCostWindow, + monthlyWindowKey, + recordCostSpend, + warnProcessLocalCostState, +} from '../src/cost-store' +import { costStoreContract } from '../src/cost-store-contract' + +describe('createInMemoryCostStore', () => { + it('passes the shared CostStore contract', async () => { + const result = await costStoreContract(createInMemoryCostStore(), { tenantPrefix: 'memory' }) + expect(Object.values(result)).toEqual(Array(6).fill('passed')) + }) + + it('writes one ledger row per usage entry on the first commit only', async () => { + const store = createInMemoryCostStore() + await store.reserve({ tenant: 't', reservationId: 'r', amountUsd: 1 }) + const usage = [{ model: 'm', promptTokens: 10, completionTokens: 2, costUsd: 0.5, source: 'chat', fallback: true }] + await store.commit({ tenant: 't', reservationId: 'r', actualUsd: 0.5, usage }) + await store.commit({ tenant: 't', reservationId: 'r', actualUsd: 0.5, usage }) + expect(store.ledger()).toEqual([{ ...usage[0], tenant: 't', reservationId: 'r' }]) + }) + + it('uses the UTC month as the default accounting window', async () => { + let now = new Date('2026-10-31T23:59:59Z') + const store = createInMemoryCostStore({ now: () => now }) + expect(monthlyWindowKey(now)).toBe('2026-10') + await recordCostSpend(store, { tenant: 't', costUsd: 2 }) + expect(await store.window({ tenant: 't', capUsd: 4 })).toMatchObject({ windowKey: '2026-10', spentUsd: 2, remainingUsd: 2, utilization: 0.5 }) + now = new Date('2026-11-01T00:00:00Z') + expect(await store.window({ tenant: 't' })).toMatchObject({ windowKey: '2026-11', spentUsd: 0 }) + }) + + it('rejects invalid tenants, ids and amounts before touching state', async () => { + const store = createInMemoryCostStore() + await expect(store.reserve({ tenant: ' ', reservationId: 'r', amountUsd: 1 })).rejects.toBeInstanceOf(ConfigError) + await expect(store.reserve({ tenant: 't', reservationId: '', amountUsd: 1 })).rejects.toBeInstanceOf(ConfigError) + await expect(store.reserve({ tenant: 't', reservationId: 'r', amountUsd: Number.NaN })).rejects.toBeInstanceOf(ConfigError) + await expect(store.reserve({ tenant: 't', reservationId: 'r', amountUsd: 1, capUsd: -1 })).rejects.toBeInstanceOf(ConfigError) + await store.reserve({ tenant: 't', reservationId: 'r', amountUsd: 1 }) + await expect(store.commit({ tenant: 't', reservationId: 'r', actualUsd: -1 })).rejects.toBeInstanceOf(ConfigError) + await expect(store.commit({ tenant: 't', reservationId: 'r', actualUsd: 1, usage: [{ model: '', promptTokens: 1, completionTokens: 1, costUsd: 1 }] })).rejects.toBeInstanceOf(ConfigError) + await expect(store.release({ tenant: 't', reservationId: 'missing' })).rejects.toMatchObject({ code: 'AK_COST_RESERVATION_NOT_FOUND' }) + expect(new CostReservationError('AK_COST_RESERVATION_RELEASED', 'x').message).toContain('already released') + }) + + it('keeps utilization finite for a zero cap', () => { + expect(describeCostWindow('t', 'w', 0, 0, 0)).toMatchObject({ utilization: 0, remainingUsd: 0 }) + expect(describeCostWindow('t', 'w', 1, 0, 0)).toMatchObject({ utilization: 1, remainingUsd: 0 }) + }) +}) + +describe('warnProcessLocalCostState', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + }) + + it('warns once per guard in production and stays silent elsewhere', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + warnProcessLocalCostState('scope-dev') + expect(warn).not.toHaveBeenCalled() + vi.stubEnv('NODE_ENV', 'production') + warnProcessLocalCostState('scope-prod') + warnProcessLocalCostState('scope-prod') + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[0]).toContain('scope-prod: no CostStore configured') + }) +}) diff --git a/packages/observability/tests/postgres-contract.ts b/packages/observability/tests/postgres-contract.ts new file mode 100644 index 000000000..060b0c835 --- /dev/null +++ b/packages/observability/tests/postgres-contract.ts @@ -0,0 +1,39 @@ +import { sql } from 'drizzle-orm' +import type { NodePgDatabase } from 'drizzle-orm/node-postgres' +import { costStoreContract, type CostStoreContractResult } from '@agentskit/observability/cost-store-contract' +import { postgresCostMigrationSql, postgresCostStore } from '@agentskit/observability/postgres' + +export interface PostgresCostContractResult extends CostStoreContractResult { + ledger: 'passed' + replayRace: 'passed' +} + +/** Shared by the Node pool test and the workerd fixture; imports the built package. */ +export async function postgresCostContract(db: NodePgDatabase, tenantPrefix: string): Promise { + await db.execute(sql.raw(postgresCostMigrationSql)) + await db.execute(sql.raw(postgresCostMigrationSql)) + const store = postgresCostStore({ db }) + const contract = await costStoreContract(store, { tenantPrefix }) + + const ledger = await db.execute(sql`SELECT model, prompt_tokens, completion_tokens, cost_usd, source, fallback + FROM agentskit_usage_ledger WHERE tenant_id = ${`${tenantPrefix}:accounting`}`) + const [row] = ledger.rows as Record[] + if (ledger.rows.length !== 1 || row?.model !== 'contract-model' || Number(row.prompt_tokens) !== 100 || Number(row.cost_usd) !== 0.25 || row.source !== 'contract' || row.fallback !== true) { + throw new Error('usage ledger row does not match the committed usage') + } + const totals = await db.execute(sql`SELECT count(*)::int AS rows, sum(cost_usd)::float8 AS cost FROM agentskit_usage_ledger WHERE tenant_id = ${`${tenantPrefix}:release`}`) + const spent = (await store.window({ tenant: `${tenantPrefix}:release`, windowKey: 'contract' })).spentUsd + const total = totals.rows[0] as { rows: number; cost: number } + if (total.rows !== 80 || Math.abs(total.cost - spent) > 1e-9) throw new Error(`ledger total ${total.cost} over ${total.rows} rows does not match the window ${spent}`) + + const tenant = `${tenantPrefix}:replay-race` + for (let round = 0; round < 20; round++) { + const results = await Promise.all(Array.from({ length: 8 }, () => + store.reserve({ tenant, reservationId: `round-${round}`, amountUsd: 0.01, capUsd: 1, windowKey: 'contract' }))) + if (!results.every(result => result.ok)) throw new Error('concurrent replay of one reservation id must succeed') + } + const raced = await store.window({ tenant, windowKey: 'contract' }) + if (Math.abs(raced.reservedUsd - 0.2) > 1e-9) throw new Error(`replayed reservations were counted more than once (${raced.reservedUsd})`) + + return { ...contract, ledger: 'passed', replayRace: 'passed' } +} diff --git a/packages/observability/tests/postgres-worker.ts b/packages/observability/tests/postgres-worker.ts new file mode 100644 index 000000000..f4b50095a --- /dev/null +++ b/packages/observability/tests/postgres-worker.ts @@ -0,0 +1,17 @@ +import { Client } from 'pg' +import { drizzle } from 'drizzle-orm/node-postgres' +import { postgresCostContract } from './postgres-contract' + +const worker = { + async fetch(_request: Request, env: { AK_POSTGRES_TEST_PORT: string }, ctx: { waitUntil(promise: Promise): void }) { + const client = new Client({ host: '127.0.0.1', port: Number(env.AK_POSTGRES_TEST_PORT), user: 'postgres', database: 'postgres' }) + try { + await client.connect() + return Response.json(await postgresCostContract(drizzle(client), `worker-${crypto.randomUUID()}`)) + } catch { + return Response.json({ error: 'Postgres workerd contract failed' }, { status: 500 }) + } finally { ctx.waitUntil(client.end()) } + }, +} +// Wrangler's module entry requires a default export; this is a test-only fixture. +export { worker as default } diff --git a/packages/observability/tests/postgres-workerd.mjs b/packages/observability/tests/postgres-workerd.mjs new file mode 100644 index 000000000..1d5c9a4e8 --- /dev/null +++ b/packages/observability/tests/postgres-workerd.mjs @@ -0,0 +1,25 @@ +import { spawnProcess } from '@agentskit/cross-platform' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import assert from 'node:assert/strict' +import { readJson, retry } from '@agentskit/net' + +assert(process.env.AK_POSTGRES_TEST_PORT, 'Set AK_POSTGRES_TEST_PORT to a local trust-auth PostgreSQL 16 port') +const state = await mkdtemp(join(tmpdir(), 'ak-observability-workerd-')) +const child = spawnProcess('pnpm', ['exec', 'wrangler', 'dev', '--config', 'tests/wrangler.postgres.jsonc', '--local', '--port', '8798', '--persist-to', state, '--var', `AK_POSTGRES_TEST_PORT:${process.env.AK_POSTGRES_TEST_PORT}`], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore', env: { ...process.env, WRANGLER_SEND_METRICS: 'false' } }) +let running = true +void child.exited.then(() => { running = false }, () => { running = false }) +try { + const response = await retry(async () => { + assert(running, 'wrangler exited before acceptance') + return fetch('http://127.0.0.1:8798', { signal: AbortSignal.timeout(15000) }) + }, { retries: 239, minDelayMs: 500, maxDelayMs: 500, jitter: 'none', shouldRetry: () => running }) + assert.equal(response.status, 200, 'real workerd/Postgres request') + assert.deepEqual(await readJson(response, { maxBytes: 4096 }), { accounting: 'passed', cap: 'passed', idempotency: 'passed', settlement: 'passed', isolation: 'passed', release: 'passed', ledger: 'passed', replayRace: 'passed' }) + console.log('workerd + PostgreSQL: CostStore contract, ledger and replay race passed') +} finally { + await child.kill('SIGTERM') + await child.exited.catch(() => {}) + await rm(state, { recursive: true, force: true }) +} diff --git a/packages/observability/tests/postgres.integration.test.ts b/packages/observability/tests/postgres.integration.test.ts new file mode 100644 index 000000000..325a9fd29 --- /dev/null +++ b/packages/observability/tests/postgres.integration.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from 'vitest' +import { Pool } from 'pg' +import { drizzle } from 'drizzle-orm/node-postgres' +import { postgresCostContract } from './postgres-contract' + +it.skipIf(!process.env.AK_POSTGRES_TEST_PORT)('CostStore contract on real PostgreSQL', async () => { + const pool = new Pool({ host: '127.0.0.1', port: Number(process.env.AK_POSTGRES_TEST_PORT), user: 'postgres', database: 'postgres', max: 20 }) + try { + expect((await pool.query('SHOW server_version')).rows[0].server_version).toMatch(/^16\./) + const result = await postgresCostContract(drizzle(pool), `test-${crypto.randomUUID()}`) + expect(Object.values(result)).toEqual(Array(8).fill('passed')) + } finally { await pool.end() } +}, 60_000) diff --git a/packages/observability/tests/postgres.test.ts b/packages/observability/tests/postgres.test.ts new file mode 100644 index 000000000..845765f46 --- /dev/null +++ b/packages/observability/tests/postgres.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { postgresCostMigrationSql, postgresCostReservationTable, postgresCostStore, postgresCostWindowTable, postgresUsageLedgerTable } from '../src/postgres' + +type Rows = Record[] +type Db = Parameters[0]['db'] + +/** Scripted `db.execute`: each call shifts the next result or throws the next error. */ +function scripted(steps: (Rows | Error)[]) { + const statements: string[] = [] + const db = { + execute: async (query: { queryChunks?: unknown[] }) => { + statements.push(JSON.stringify(query.queryChunks)) + const step = steps.shift() + if (!step) throw new Error('unexpected statement') + if (step instanceof Error) throw step + return { rows: step } + }, + } as unknown as Db + return { db, statements } +} + +const now = () => new Date('2026-10-09T12:00:00Z') + +describe('postgresCostStore', () => { + it('exports schema and idempotent DDL for the three tables', () => { + expect(postgresCostMigrationSql.match(/CREATE TABLE IF NOT EXISTS/g)).toHaveLength(3) + expect([postgresCostWindowTable, postgresCostReservationTable, postgresUsageLedgerTable].map(table => Object.keys(table).length > 0)).toEqual([true, true, true]) + }) + + it('admits a reservation from the single atomic statement', async () => { + const { db, statements } = scripted([[{ spent_usd: '0.250000000', reserved_usd: '0.500000000' }]]) + const result = await postgresCostStore({ db, now }).reserve({ tenant: 't', reservationId: 'r', amountUsd: 0.5, capUsd: 1 }) + expect(result).toEqual({ ok: true, reservationId: 'r', window: { tenant: 't', windowKey: '2026-10', spentUsd: 0.25, reservedUsd: 0.5, capUsd: 1, remainingUsd: 0.25, utilization: 0.75 } }) + expect(statements).toHaveLength(1) + expect(statements[0]).toContain('ON CONFLICT (tenant_id, window_key) DO UPDATE') + }) + + it('reports quota_exceeded with the current window when nothing was admitted', async () => { + const { db } = scripted([[], [], [{ spent_usd: '0.9', reserved_usd: '0.1' }]]) + const result = await postgresCostStore({ db, now }).reserve({ tenant: 't', reservationId: 'r', amountUsd: 0.5, capUsd: 1 }) + expect(result).toMatchObject({ ok: false, reason: 'quota_exceeded', window: { spentUsd: 0.9, reservedUsd: 0.1, remainingUsd: 0 } }) + }) + + it('replays an existing reservation, including one that lost a concurrent insert', async () => { + const conflict = Object.assign(new Error('query failed'), { cause: Object.assign(new Error('duplicate key'), { code: '23505' }) }) + const { db } = scripted([conflict, [{ window_key: '2026-09' }], [{ spent_usd: '0', reserved_usd: '0.5' }]]) + const result = await postgresCostStore({ db, now }).reserve({ tenant: 't', reservationId: 'r', amountUsd: 0.5 }) + expect(result).toMatchObject({ ok: true, window: { windowKey: '2026-09', reservedUsd: 0.5 } }) + }) + + it('propagates database errors that are not a replay', async () => { + const { db } = scripted([new Error('connection reset')]) + await expect(postgresCostStore({ db, now }).reserve({ tenant: 't', reservationId: 'r', amountUsd: 0.5 })).rejects.toThrow('connection reset') + }) + + it('commits once and maps settled reservations to typed outcomes', async () => { + const usage = [{ model: 'm', promptTokens: 10.4, completionTokens: 2, costUsd: 0.3 }] + const first = scripted([[{ window_key: 'w', spent_usd: '0.3', reserved_usd: '0' }]]) + expect(await postgresCostStore({ db: first.db }).commit({ tenant: 't', reservationId: 'r', actualUsd: 0.3, usage })).toEqual({ tenant: 't', windowKey: 'w', spentUsd: 0.3, reservedUsd: 0 }) + expect(first.statements[0]).toContain('\\"prompt_tokens\\":10') + + const replay = scripted([[], [{ window_key: 'w', status: 'committed' }], [{ spent_usd: '0.3', reserved_usd: '0' }]]) + expect(await postgresCostStore({ db: replay.db }).commit({ tenant: 't', reservationId: 'r', actualUsd: 0.3 })).toMatchObject({ spentUsd: 0.3 }) + + const released = scripted([[], [{ window_key: 'w', status: 'released' }]]) + await expect(postgresCostStore({ db: released.db }).commit({ tenant: 't', reservationId: 'r', actualUsd: 0.3 })).rejects.toMatchObject({ code: 'AK_COST_RESERVATION_RELEASED' }) + + const missing = scripted([[], []]) + await expect(postgresCostStore({ db: missing.db }).commit({ tenant: 't', reservationId: 'r', actualUsd: 0.3 })).rejects.toMatchObject({ code: 'AK_COST_RESERVATION_NOT_FOUND' }) + }) + + it('releases once and rejects a committed reservation', async () => { + const first = scripted([[{ window_key: 'w', spent_usd: '0', reserved_usd: '0' }]]) + expect(await postgresCostStore({ db: first.db }).release({ tenant: 't', reservationId: 'r' })).toMatchObject({ reservedUsd: 0 }) + const replay = scripted([[], [{ window_key: 'w', status: 'released' }], []]) + expect(await postgresCostStore({ db: replay.db }).release({ tenant: 't', reservationId: 'r' })).toEqual({ tenant: 't', windowKey: 'w', spentUsd: 0, reservedUsd: 0 }) + const committed = scripted([[], [{ window_key: 'w', status: 'committed' }]]) + await expect(postgresCostStore({ db: committed.db }).release({ tenant: 't', reservationId: 'r' })).rejects.toMatchObject({ code: 'AK_COST_RESERVATION_COMMITTED' }) + }) + + it('reads the current window with the default monthly key', async () => { + const { db } = scripted([[]]) + expect(await postgresCostStore({ db, now }).window({ tenant: 't', capUsd: 2 })).toEqual({ tenant: 't', windowKey: '2026-10', spentUsd: 0, reservedUsd: 0, capUsd: 2, remainingUsd: 2, utilization: 0 }) + }) +}) diff --git a/packages/observability/tests/wrangler.postgres.jsonc b/packages/observability/tests/wrangler.postgres.jsonc new file mode 100644 index 000000000..7b15dff84 --- /dev/null +++ b/packages/observability/tests/wrangler.postgres.jsonc @@ -0,0 +1,6 @@ +{ + "name": "agentskit-observability-postgres-test", + "main": "postgres-worker.ts", + "compatibility_date": "2026-10-01", + "compatibility_flags": ["nodejs_compat"] +} diff --git a/packages/observability/tsup.config.ts b/packages/observability/tsup.config.ts index ea64de71f..4e3384161 100644 --- a/packages/observability/tsup.config.ts +++ b/packages/observability/tsup.config.ts @@ -7,12 +7,16 @@ export default defineConfig({ index: 'src/index.ts', 'trace-tracker': 'src/trace-tracker.ts', 'cost-guard': 'src/cost-guard.ts', + postgres: 'src/postgres.ts', + 'cost-store-contract': 'src/cost-store-contract.ts', langfuse: '../observability-langfuse/src/index.ts', }, clean: true, minify: true, external: [ '@agentskit/observability', + 'drizzle-orm', + 'pg', 'langfuse', 'langsmith', '@opentelemetry/api', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8dfe9d06..a93bb5578 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,7 +220,7 @@ importers: version: 2.0.0(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.0))(react@19.2.8)(svelte@5.57.0)(vue@3.5.42(typescript@6.0.3)) '@vinext/cloudflare': specifier: ^1.0.1 - version: 1.0.1(6f4814238090a248c5ffa17615f6bc95) + version: 1.0.1(2cae352fa5404e68b865655694427dac) fumadocs-core: specifier: ^16.15.11 version: 16.15.11(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.46.0(react@19.2.8))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.5) @@ -275,7 +275,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: 2.0.0-beta.sha-52b0dc0e9 - version: 2.0.0-beta.sha-52b0dc0e9(@types/node@26.2.0)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1))(workerd@1.20260930.2) + version: 2.0.0-beta.sha-52b0dc0e9(@types/node@26.2.0)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1))(workerd@1.20261006.1) '@types/mdx': specifier: ^2.0.14 version: 2.0.14 @@ -697,7 +697,7 @@ importers: version: link:../../packages/react '@vinext/cloudflare': specifier: ^1.0.1 - version: 1.0.1(f0d32195ca35246e32cbd916a1dc260f) + version: 1.0.1(d02bd6cb1f1efa29a5fd32350bf8fea3) fumadocs-core: specifier: ^16.15.11 version: 16.15.11(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.17)(lucide-react@1.46.0(react@19.2.7))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@26.2.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(zod@4.6.5) @@ -731,7 +731,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: 2.0.0-beta.sha-52b0dc0e9 - version: 2.0.0-beta.sha-52b0dc0e9(@types/node@26.2.0)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1))(workerd@1.20260930.2) + version: 2.0.0-beta.sha-52b0dc0e9(@types/node@26.2.0)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1))(workerd@1.20261006.1) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -1297,6 +1297,9 @@ importers: specifier: workspace:* version: link:../net devDependencies: + '@agentskit/cross-platform': + specifier: workspace:* + version: link:../cross-platform '@opentelemetry/api': specifier: ^1.9.1 version: 1.9.1 @@ -1309,12 +1312,21 @@ importers: '@types/node': specifier: ^25.9.5 version: 25.9.5 + '@types/pg': + specifier: ^8.15.6 + version: 8.23.1 + drizzle-orm: + specifier: ^0.45.3 + version: 0.45.3(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.23.1)(@upstash/redis@1.38.4)(better-sqlite3@13.0.3)(pg@8.23.1) langfuse: specifier: ^3.38.20 version: 3.39.2 langsmith: specifier: ^0.8.1 version: 0.8.12(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.42.0(ws@8.21.0)(zod@4.6.5))(ws@8.21.0) + pg: + specifier: ^8.23.1 + version: 8.23.1 tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.13)(typescript@6.0.3)(yaml@2.9.1) @@ -1324,6 +1336,9 @@ importers: vitest: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(@vitest/coverage-v8@4.1.11)(happy-dom@20.14.5)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.15.0(@types/node@25.9.5)(typescript@6.0.3))(vite@8.3.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1)) + wrangler: + specifier: ^4.148.0 + version: 4.148.0(@types/node@25.9.5) packages/observability-langfuse: dependencies: @@ -5271,67 +5286,79 @@ packages: resolution: {integrity: sha512-Rvr0/EkPfPgc0C1wx8dz4bv6o4I4yeYI06tcHUL0f4ZdwSyIreABoLHNW51Ap0VNA7aDRVQ4izRNQh/LuykofA==} cpu: [arm64] os: [android] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-darwin-arm64@0.9.5': resolution: {integrity: sha512-sh9DaXzkVmedM9aWvjCAAluRtvz/trk5VSosF7dPhqDf3IhrJW2DOh9iuA+YQzWSktP/ILsKfKili2UZ/ZjRzw==} cpu: [arm64] os: [darwin] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-darwin-x64@0.9.5': resolution: {integrity: sha512-qPKyWlGK92vjwwFdAKwGkfqF3rye4UR8X2WPvzhOSt/plATd2pPJFyMBhF1aclRMBNbk20YnctWYRYLS8FBgAg==} cpu: [x64] os: [darwin] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-freebsd-x64@0.9.5': resolution: {integrity: sha512-LeSbxDQ7k0WFyddM3BiFW3i55eSkWwi4hgG8PmqNBGL9dZ4sysYOwcy3We8vyxc/fyhuhc0CYxa6p6v14+UBow==} cpu: [x64] os: [freebsd] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-arm-gnu@0.9.5': resolution: {integrity: sha512-GVtm5UmUhKMnicVz/Q4QgRr7OFyvxDrr7j9u0Mrgnisj/GmABhcFhzqVwW+rbS+EFnUHY7yIamwY2Fc3CoCTCw==} cpu: [arm] os: [linux] libc: [glibc] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-arm-musl@0.9.5': resolution: {integrity: sha512-c/9lCWXRwp0eaAvbTZslrplIjaLhiFCdc5bF/hCV13HrYYyICSwEcCgx3/OEEpwfHpdQsqejhsntUKc/0Ylbfg==} cpu: [arm] os: [linux] libc: [musl] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-arm64-gnu@0.9.5': resolution: {integrity: sha512-BwZYDPfnuP9gh8KphdjClzN3XRV0smbc2nNGkxvSOEYB/uRW0e+S2Jh82QKMSCK5TptQfovmFWe55Z5lToLAfA==} cpu: [arm64] os: [linux] libc: [glibc] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-arm64-musl@0.9.5': resolution: {integrity: sha512-xglOsssSkPQGwendJdWud5maVWoM6h5SwlouStfg0eUACjxV8uLvnub2NT+euQJQMVzH3aRc5RuPPqzvMFeDTA==} cpu: [arm64] os: [linux] libc: [musl] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-x64-gnu@0.9.5': resolution: {integrity: sha512-X7J/LWSnNsyx1E4hv6CFofXQft4GMePfc4pDn00B4lG6BO3HNR/zGlYZ5ayQsxhRNLxtAlvSFSaguaSuyz6p6g==} cpu: [x64] os: [linux] libc: [glibc] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-linux-x64-musl@0.9.5': resolution: {integrity: sha512-gmi9vazzSs4PLpHjgkFLPGCDMzG7ths3mcip0qZEHhLwLJthH5PTA+LYKi+7FRKHNQT4A50RTVox5CjdUC+prw==} cpu: [x64] os: [linux] libc: [musl] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-win32-arm64@0.9.5': resolution: {integrity: sha512-0iKC5sGOCrFqumzSHLv2qCQa0OTytK605dSJrdHah5CoLT13oStEfk3jwNltYB77J3BiSagg896d5YmTY8HQPA==} cpu: [arm64] os: [win32] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-analyzer/binding-win32-x64@0.9.5': resolution: {integrity: sha512-dSb5o6D9JugPmKRsjVLDLpgHRdSd1oCvNhY6AP6+OCzMwn0kbiNxhf2DwXYtt4xIBjliYQnw+ZsvIy1d0lHtSg==} cpu: [x64] os: [win32] + deprecated: yuku-analyzer runs on yuku-core since 0.14 '@yuku-toolchain/types@0.9.5': resolution: {integrity: sha512-KiuLNNgX9uNealaWAR+G3/cMXnRk9x4TY2EkYe/KIag+UPdwiA0RRf1hr1WAxzTP8KGzCTkqUdLPvqh32sEO3w==} @@ -11169,24 +11196,18 @@ snapshots: - bufferutil - utf-8-validate - '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260930.2)': - dependencies: - unenv: 2.0.0-rc.24 - optionalDependencies: - workerd: 1.20260930.2 - '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261006.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: workerd: 1.20261006.1 - '@cloudflare/vite-plugin@2.0.0-beta.sha-52b0dc0e9(@types/node@26.2.0)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1))(workerd@1.20260930.2)': + '@cloudflare/vite-plugin@2.0.0-beta.sha-52b0dc0e9(@types/node@26.2.0)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1))(workerd@1.20261006.1)': dependencies: '@cloudflare/build-output-utils': 0.8.4 '@cloudflare/config': 0.22.0 '@cloudflare/runtime-types': 0.1.6(@types/node@26.2.0) - '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260930.2) + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261006.1) miniflare: 5.20260930.0-alpha(@types/node@26.2.0) unenv: 2.0.0-rc.24 vite: 8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1) @@ -14154,20 +14175,20 @@ snapshots: svelte: 5.57.0 vue: 3.5.42(typescript@6.0.3) - '@vinext/cloudflare@1.0.1(6f4814238090a248c5ffa17615f6bc95)': + '@vinext/cloudflare@1.0.1(2cae352fa5404e68b865655694427dac)': dependencies: '@cloudflare/workers-response-store': 1.0.1 vinext: 1.0.1(@types/node@26.2.0)(@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1)))(@vitejs/plugin-rsc@0.5.35(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(webpack@5.111.1(esbuild@0.28.1)(postcss@8.5.28)))(react@19.2.8)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1)))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(sass@1.101.0))(react-dom@19.2.8(react@19.2.8))(react-server-dom-webpack@19.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(webpack@5.111.1(esbuild@0.28.1)(postcss@8.5.28)))(react@19.2.8)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1)) optionalDependencies: - '@cloudflare/vite-plugin': 2.0.0-beta.sha-52b0dc0e9(@types/node@26.2.0)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1))(workerd@1.20260930.2) + '@cloudflare/vite-plugin': 2.0.0-beta.sha-52b0dc0e9(@types/node@26.2.0)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1))(workerd@1.20261006.1) cf: 1.0.0-beta.10(@types/node@26.2.0) - '@vinext/cloudflare@1.0.1(f0d32195ca35246e32cbd916a1dc260f)': + '@vinext/cloudflare@1.0.1(d02bd6cb1f1efa29a5fd32350bf8fea3)': dependencies: '@cloudflare/workers-response-store': 1.0.1 vinext: 1.0.1(@types/node@26.2.0)(@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1)))(@vitejs/plugin-rsc@0.5.35(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(webpack@5.111.1(esbuild@0.28.1)(postcss@8.5.28)))(react@19.2.7)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1)))(next@16.3.8(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@26.2.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(webpack@5.111.1(esbuild@0.28.1)(postcss@8.5.28)))(react@19.2.7)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1)) optionalDependencies: - '@cloudflare/vite-plugin': 2.0.0-beta.sha-52b0dc0e9(@types/node@26.2.0)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1))(workerd@1.20260930.2) + '@cloudflare/vite-plugin': 2.0.0-beta.sha-52b0dc0e9(@types/node@26.2.0)(vite@8.3.0(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.1))(workerd@1.20261006.1) cf: 1.0.0-beta.10(@types/node@26.2.0) '@vinext/types@1.0.1': {} @@ -19371,7 +19392,6 @@ snapshots: '@img/sharp-win32-ia32': 0.35.5 '@img/sharp-win32-x64': 0.35.5 '@types/node': 25.9.5 - optional: true sharp@0.35.5(@types/node@26.2.0): dependencies: diff --git a/readme-standard-v1.json b/readme-standard-v1.json index 19446d261..72ee3c379 100644 --- a/readme-standard-v1.json +++ b/readme-standard-v1.json @@ -1065,7 +1065,7 @@ "docs/STABILITY.md", "scripts/readme-fixtures.test.mjs" ], - "sourceHash": "sha256:531973f97d0a6744ac416713a28b03735b26106036ebe59117c16dc65c4ab575" + "sourceHash": "sha256:53e302a7522d80d9c64dfe59f69a41a13d6b79a33210d54d77416b19506edf16" }, "exceptions": [] }, From 88722d4d023121c5fc5e0a76c3535825b7944cf3 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Fri, 9 Oct 2026 22:30:35 -0300 Subject: [PATCH 2/3] docs(observability): refresh generated API symbol routes for CostStore --- apps/docs-next/lib/api-symbol-routes.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/docs-next/lib/api-symbol-routes.json b/apps/docs-next/lib/api-symbol-routes.json index 7b792c05e..cff2b1dcf 100644 --- a/apps/docs-next/lib/api-symbol-routes.json +++ b/apps/docs-next/lib/api-symbol-routes.json @@ -617,6 +617,7 @@ "api/rag/type-aliases/RerankFn": "/docs/api/rag/type-aliases#type-alias-rerankfn", "api/rag/variables/RagErrorCodes": "/docs/api/rag/variables#variable-ragerrorcodes", "api/rag/variables/bm25Rerank": "/docs/api/rag/variables#variable-bm25rerank", + "api/observability/classes/CostReservationError": "/docs/api/observability/classes#class-costreservationerror", "api/observability/functions/appendPiiAuditEvents": "/docs/api/observability/functions#function-appendpiiauditevents", "api/observability/functions/axiomSink": "/docs/api/observability/functions#function-axiomsink", "api/observability/functions/buildTimeline": "/docs/api/observability/functions#function-buildtimeline", @@ -634,6 +635,7 @@ "api/observability/functions/createDevtoolsServer": "/docs/api/observability/functions#function-createdevtoolsserver", "api/observability/functions/createFileTraceSink": "/docs/api/observability/functions#function-createfiletracesink", "api/observability/functions/createInMemoryAuditStore": "/docs/api/observability/functions#function-createinmemoryauditstore", + "api/observability/functions/createInMemoryCostStore": "/docs/api/observability/functions#function-createinmemorycoststore", "api/observability/functions/createProviderCounter": "/docs/api/observability/functions#function-createprovidercounter", "api/observability/functions/createSignedAuditLog": "/docs/api/observability/functions#function-createsignedauditlog", "api/observability/functions/createTopologyGraph": "/docs/api/observability/functions#function-createtopologygraph", @@ -641,11 +643,13 @@ "api/observability/functions/datadogSink": "/docs/api/observability/functions#function-datadogsink", "api/observability/functions/diffState": "/docs/api/observability/functions#function-diffstate", "api/observability/functions/langsmith": "/docs/api/observability/functions#function-langsmith", + "api/observability/functions/monthlyWindowKey": "/docs/api/observability/functions#function-monthlywindowkey", "api/observability/functions/multiTenantCostGuard": "/docs/api/observability/functions#function-multitenantcostguard", "api/observability/functions/newRelicSink": "/docs/api/observability/functions#function-newrelicsink", "api/observability/functions/opentelemetry": "/docs/api/observability/functions#function-opentelemetry", "api/observability/functions/positionAt": "/docs/api/observability/functions#function-positionat", "api/observability/functions/priceFor": "/docs/api/observability/functions#function-pricefor", + "api/observability/functions/recordCostSpend": "/docs/api/observability/functions#function-recordcostspend", "api/observability/functions/renderTraceViewerHtml": "/docs/api/observability/functions#function-rendertraceviewerhtml", "api/observability/functions/replayBisect": "/docs/api/observability/functions#function-replaybisect", "api/observability/functions/replayEvents": "/docs/api/observability/functions#function-replayevents", @@ -672,14 +676,26 @@ "api/observability/interfaces/CostAlertEvent": "/docs/api/observability/interfaces#interface-costalertevent", "api/observability/interfaces/CostCapWindow": "/docs/api/observability/interfaces#interface-costcapwindow", "api/observability/interfaces/CostCaps": "/docs/api/observability/interfaces#interface-costcaps", + "api/observability/interfaces/CostCommitInput": "/docs/api/observability/interfaces#interface-costcommitinput", "api/observability/interfaces/CostGuardOptions": "/docs/api/observability/interfaces#interface-costguardoptions", + "api/observability/interfaces/CostGuardStoreOptions": "/docs/api/observability/interfaces#interface-costguardstoreoptions", + "api/observability/interfaces/CostReleaseInput": "/docs/api/observability/interfaces#interface-costreleaseinput", + "api/observability/interfaces/CostReserveInput": "/docs/api/observability/interfaces#interface-costreserveinput", "api/observability/interfaces/CostSample": "/docs/api/observability/interfaces#interface-costsample", + "api/observability/interfaces/CostSpendRecord": "/docs/api/observability/interfaces#interface-costspendrecord", + "api/observability/interfaces/CostStore": "/docs/api/observability/interfaces#interface-coststore", + "api/observability/interfaces/CostStoreOptions": "/docs/api/observability/interfaces#interface-coststoreoptions", + "api/observability/interfaces/CostUsageEntry": "/docs/api/observability/interfaces#interface-costusageentry", + "api/observability/interfaces/CostWindow": "/docs/api/observability/interfaces#interface-costwindow", + "api/observability/interfaces/CostWindowInput": "/docs/api/observability/interfaces#interface-costwindowinput", "api/observability/interfaces/DatadogSinkConfig": "/docs/api/observability/interfaces#interface-datadogsinkconfig", "api/observability/interfaces/DevtoolsClient": "/docs/api/observability/interfaces#interface-devtoolsclient", "api/observability/interfaces/DevtoolsServer": "/docs/api/observability/interfaces#interface-devtoolsserver", "api/observability/interfaces/DevtoolsServerOptions": "/docs/api/observability/interfaces#interface-devtoolsserveroptions", "api/observability/interfaces/FileTraceSink": "/docs/api/observability/interfaces#interface-filetracesink", "api/observability/interfaces/HttpBatchOptions": "/docs/api/observability/interfaces#interface-httpbatchoptions", + "api/observability/interfaces/InMemoryCostStore": "/docs/api/observability/interfaces#interface-inmemorycoststore", + "api/observability/interfaces/InMemoryLedgerRow": "/docs/api/observability/interfaces#interface-inmemoryledgerrow", "api/observability/interfaces/LangSmithConfig": "/docs/api/observability/interfaces#interface-langsmithconfig", "api/observability/interfaces/LangSmithObserver": "/docs/api/observability/interfaces#interface-langsmithobserver", "api/observability/interfaces/LifecycleObserver": "/docs/api/observability/interfaces#interface-lifecycleobserver", @@ -717,6 +733,7 @@ "api/observability/type-aliases/CostAlertType": "/docs/api/observability/type-aliases#type-alias-costalerttype", "api/observability/type-aliases/CostGuardErrorHandler": "/docs/api/observability/type-aliases#type-alias-costguarderrorhandler", "api/observability/type-aliases/CostGuardMode": "/docs/api/observability/type-aliases#type-alias-costguardmode", + "api/observability/type-aliases/CostReserveResult": "/docs/api/observability/type-aliases#type-alias-costreserveresult", "api/observability/type-aliases/DatadogSinkObserver": "/docs/api/observability/type-aliases#type-alias-datadogsinkobserver", "api/observability/type-aliases/DevtoolsEnvelope": "/docs/api/observability/type-aliases#type-alias-devtoolsenvelope", "api/observability/type-aliases/NewRelicSinkObserver": "/docs/api/observability/type-aliases#type-alias-newrelicsinkobserver", From fdb1e758d84dcb05e6a746f37b400803565d8dab Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Fri, 9 Oct 2026 23:48:34 -0300 Subject: [PATCH 3/3] fix(observability): harden the CostStore after review - match reservation errors by code in the contract suite; each CommonJS subpath bundles its own copy of the class. Add a CommonJS smoke test. - recordCostSpend releases its hold when the commit fails - expirePostgresCostReservations returns holds that were never settled - a guard without onError warns once when the store fails - warnWithoutStore, and Cloudflare Workers detection for the missing-store warning - reserve rejects a released reservation id instead of replaying it as admitted - validate release and window inputs in the PostgreSQL store - ledger token columns are bigint; Drizzle schema carries the CHECK and index - run the contract in workerd on a pool, and in CI with the CommonJS smoke --- .changeset/durable-cost-store.md | 4 +- .github/workflows/ci.yml | 10 +++ .size-limit.json | 4 +- .../content/docs/for-agents/observability.mdx | 3 +- .../adrs/0044-durable-cost-store.md | 20 +++++- docs/stability/public-api-v1.json | 12 ++++ packages/observability/README.md | 9 ++- packages/observability/package.json | 1 + .../observability/src/cost-guard-advanced.ts | 2 +- .../src/cost-guard-multi-tenant.ts | 2 +- .../observability/src/cost-guard-store.ts | 31 +++++++-- packages/observability/src/cost-guard.ts | 2 +- .../observability/src/cost-store-contract.ts | 7 +- packages/observability/src/cost-store.ts | 58 +++++++++++++--- packages/observability/src/postgres.ts | 67 ++++++++++++++++--- packages/observability/tests/cjs-smoke.cjs | 31 +++++++++ .../tests/cost-guard-store.test.ts | 33 +++++++++ .../tests/cost-store-contract.test.ts | 19 ++++++ .../observability/tests/cost-store.test.ts | 49 ++++++++++++++ .../observability/tests/postgres-contract.ts | 16 ++++- .../observability/tests/postgres-worker.ts | 15 +++-- .../observability/tests/postgres-workerd.mjs | 7 +- .../tests/postgres.integration.test.ts | 7 +- packages/observability/tests/postgres.test.ts | 34 +++++++++- readme-standard-v1.json | 2 +- 25 files changed, 388 insertions(+), 57 deletions(-) create mode 100644 packages/observability/tests/cjs-smoke.cjs diff --git a/.changeset/durable-cost-store.md b/.changeset/durable-cost-store.md index a29dde0bc..a2169eb28 100644 --- a/.changeset/durable-cost-store.md +++ b/.changeset/durable-cost-store.md @@ -4,4 +4,6 @@ Add the `CostStore` port (`reserve`, `commit`, `release`, `window`) for durable, tenant-scoped spend accounting, with `createInMemoryCostStore` for development and an optional `@agentskit/observability/postgres` subpath: BYO Drizzle/node-postgres store with atomic cap admission, idempotent reservations, a usage ledger, exported schema and migration SQL. `@agentskit/observability/cost-store-contract` exports the contract suite for custom stores. -`costGuard`, `multiTenantCostGuard` and `createAdvancedCostGuard` accept an optional `store`. With it, each priced call is written to the store and the budget is checked against spend recorded by every instance in the current accounting window (UTC month by default). Without it behavior is unchanged, except for a one-time warning when `NODE_ENV=production`, because process-local totals do not hold across instances or isolates. `costGuard` also accepts `tenant` to name the store partition. +`costGuard`, `multiTenantCostGuard` and `createAdvancedCostGuard` accept an optional `store`. With it, each priced call is written to the store and the budget is checked against spend recorded by every instance in the current accounting window (UTC month by default). Without it behavior is unchanged, except for a one-time warning in production runtimes (`NODE_ENV=production`, or Cloudflare Workers with `NODE_ENV` unset; `warnWithoutStore` overrides it), because process-local totals do not hold across instances or isolates. A store write that fails goes to `onError`, or to one warning when no handler is set. `costGuard` also accepts `tenant` to name the store partition. + +`expirePostgresCostReservations({ db, olderThan })` releases reservations that were never settled; the store has no background reaper. `reserve` with the id of a released reservation throws `AK_COST_RESERVATION_RELEASED`. Match `CostReservationError` by `code`: each CommonJS subpath bundles its own copy of the class. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 485986c47..169b60f7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -230,6 +230,16 @@ jobs: AK_POSTGRES_TEST_PORT: 5432 VITEST_MAX_WORKERS: 2 TURBO_CONCURRENCY: 2 + - name: CostStore contract through CommonJS entry points + run: pnpm --filter @agentskit/observability test:cjs + env: + AK_POSTGRES_TEST_PORT: 5432 + TURBO_CONCURRENCY: 2 + - name: CostStore contract in workerd + run: pnpm --filter @agentskit/observability test:workerd + env: + AK_POSTGRES_TEST_PORT: 5432 + TURBO_CONCURRENCY: 2 quality: name: Lint, Test, Build diff --git a/.size-limit.json b/.size-limit.json index 23570e4e7..3bc5d09fa 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -482,7 +482,7 @@ { "name": "@agentskit/observability (ESM export ./cost-guard)", "path": "node_modules/.cache/size-limit-subpaths/observability/cost-guard.js", - "limit": "2.5 KB", + "limit": "3 KB", "gzip": true }, { @@ -650,7 +650,7 @@ { "name": "@agentskit/observability (ESM export ./postgres)", "path": "node_modules/.cache/size-limit-subpaths/observability/postgres.js", - "limit": "3 KB", + "limit": "3.5 KB", "gzip": true }, { diff --git a/apps/docs-next/content/docs/for-agents/observability.mdx b/apps/docs-next/content/docs/for-agents/observability.mdx index 10f711ce6..ee7ac4666 100644 --- a/apps/docs-next/content/docs/for-agents/observability.mdx +++ b/apps/docs-next/content/docs/for-agents/observability.mdx @@ -51,10 +51,11 @@ All three return `LifecycleObserver` (`flush(): Promise`, idempotent `shut - **`kill`** — requires `disableRuntime`; use **`isDisabled(tenant)`**; fail-closed if disable fails. - Accounting is **incremental per `llm:end` / active model**; hostile usage normalizes to zero; zero-budget payloads stay finite. Callback/sink/clock errors are isolated. - `consoleAlertSink()`, `webhookAlertSink({ url, headers, fetch? })`, `throttle(sink, windowMs)` — built-in alert sinks (`webhook` uses injected or `globalThis.fetch`; no-op if none). -- `store?: CostStore` on all three guards — durable spend shared by every instance. Without it totals are process-local and the guards warn once when `NODE_ENV=production`. With it, each priced `llm:end` is written to the store and the budget is checked against the store's current window. +- `store?: CostStore` on all three guards — durable spend shared by every instance. Without it totals are process-local and the guards warn once when `NODE_ENV=production` (or on Cloudflare Workers with `NODE_ENV` unset); `warnWithoutStore?: boolean` overrides that. Store failures go to `onError`, or to one `console.warn` when it is absent. With it, each priced `llm:end` is written to the store and the budget is checked against the store's current window. - `CostStore { reserve, commit, release, window }` — tenant-scoped port. `reserve({ tenant, reservationId, amountUsd, capUsd?, windowKey? })` returns `{ ok: true, window }` or `{ ok: false, reason: 'quota_exceeded', window }`; `commit({ tenant, reservationId, actualUsd, usage? })` writes real spend and ledger rows; `release({ tenant, reservationId })` returns the hold. All idempotent per `reservationId`; settling an unknown, released or committed reservation throws `CostReservationError` (`AK_COST_RESERVATION_NOT_FOUND` / `_RELEASED` / `_COMMITTED`). - `createInMemoryCostStore()` — development and test store. `recordCostSpend(store, { tenant, costUsd, usage? })` — reserve + commit for spend that already happened. `monthlyWindowKey(date)` — default window (`YYYY-MM`, UTC). - `@agentskit/observability/postgres` — `postgresCostStore({ db })`, `postgresCostMigrationSql`, and the Drizzle tables `postgresCostWindowTable`, `postgresCostReservationTable`, `postgresUsageLedgerTable`. One statement per operation, cap admitted atomically. Optional peers: `drizzle-orm`, `pg`. +- `expirePostgresCostReservations({ db, olderThan })` (`/postgres`) — releases reservations still held and created before `olderThan`; returns the count. No background reaper: the host schedules it. `reserve` with a released id throws `AK_COST_RESERVATION_RELEASED`. Match `CostReservationError` by `code`, not `instanceof` (CommonJS subpaths each bundle the class). - `@agentskit/observability/cost-store-contract` — `costStoreContract(store, { tenantPrefix })`, the framework-free suite a custom `CostStore` must pass. - `chargebackReport(samples, { groupBy, from, to })` + `chargebackReportToCsv(report)` — pure cost-attribution exporter. - `priceFor`, `computeCost`, `DEFAULT_PRICES` — **baseline** prices; override for current provider rates. diff --git a/docs/architecture/adrs/0044-durable-cost-store.md b/docs/architecture/adrs/0044-durable-cost-store.md index a292af077..135220daf 100644 --- a/docs/architecture/adrs/0044-durable-cost-store.md +++ b/docs/architecture/adrs/0044-durable-cost-store.md @@ -37,7 +37,14 @@ suite any implementation must pass. `costGuard`, `multiTenantCostGuard` and `createAdvancedCostGuard` accept an optional `store`. With it they write each priced `llm:end` to the store and trip on the stored window total. Without it they keep the existing -process-local behavior and warn once when `NODE_ENV=production`. +process-local behavior and warn once in a production runtime: `NODE_ENV=production`, +or Cloudflare Workers when `NODE_ENV` is not set. `warnWithoutStore` overrides +the detection. A store write that fails goes to `onError`; a guard without +`onError` logs the first failure once. + +Reservations have no time to live in the store. `expirePostgresCostReservations` +releases the ones still held and older than a cutoff the host chooses; the host +schedules it. `recordCostSpend` releases its own hold when the commit fails. ## Consequences @@ -51,6 +58,17 @@ process-local behavior and warn once when `NODE_ENV=production`. - `commit` records the real spend even above the reserved estimate. The cap is a hard ceiling on reservations, not on provider usage already incurred. - `drizzle-orm` and `pg` become optional peers, required only by `/postgres`. +- A released reservation id is not admitted again: `reserve` raises + `AK_COST_RESERVATION_RELEASED`. Replaying it as admitted would let the call + run without a hold and then fail to commit. +- A reservation abandoned by a crashed process stays held until the host runs + the expiry. A cutoff shorter than a call still in flight releases its hold, + and that call's `commit` is then rejected. +- `reserve` must run outside a host transaction or inside a savepoint. A + concurrent duplicate id ends in a unique violation that the store absorbs, + which still aborts an enclosing transaction. +- The CommonJS build of each subpath has its own copy of + `CostReservationError`. The contract suite and hosts match errors by `code`. - The advanced guard's rolling window caps (`perMinute`, `perDay`, custom) remain process-local; only the overall budget reads the store. diff --git a/docs/stability/public-api-v1.json b/docs/stability/public-api-v1.json index 0a26c60b0..6335e141c 100644 --- a/docs/stability/public-api-v1.json +++ b/docs/stability/public-api-v1.json @@ -8353,6 +8353,18 @@ "types" ], "symbols": [ + { + "name": "expirePostgresCostReservations", + "kinds": [ + "value" + ] + }, + { + "name": "ExpirePostgresCostReservationsOptions", + "kinds": [ + "type" + ] + }, { "name": "postgresCostMigrationSql", "kinds": [ diff --git a/packages/observability/README.md b/packages/observability/README.md index b01a1d0dc..fa354763f 100644 --- a/packages/observability/README.md +++ b/packages/observability/README.md @@ -196,7 +196,7 @@ Simple `costGuard` aborts via the supplied `AbortController` when the run budget ### Durable budgets with `CostStore` -A guard without a store keeps its totals in process memory: they reset on restart and are not shared between instances or isolates. That is fine in development and wrong on serverless or multi-instance hosts, so the guards warn once when `NODE_ENV=production` and no store is set. +A guard without a store keeps its totals in process memory: they reset on restart and are not shared between instances or isolates. That is fine in development and wrong on serverless or multi-instance hosts, so the guards warn once when no store is set and `NODE_ENV=production`, or on Cloudflare Workers when `NODE_ENV` is not set. Pass `warnWithoutStore: true` or `false` to decide it yourself. `CostStore` is the durable port. `reserve` holds an estimate against the tenant cap, `commit` replaces it with the real spend and writes the usage ledger, `release` returns it when the call fails, and `window` reads the current state. All four take the tenant id; `reserve`, `commit` and `release` are idempotent per `reservationId`. @@ -229,7 +229,12 @@ const guard = multiTenantCostGuard({ budgets: { [tenant]: planCapUsd }, store }) - **Accounting window.** Spend is grouped by `windowKey`, by default the UTC month (`2026-10`). Pass `windowKey` to the store options or per call for another period. With a store, a guard's budget applies to the current window. - **Real spend wins.** `commit` records `actualUsd` even when it is above the reserved estimate. Reserve an upper estimate if the cap must never be passed. - **Guards record after the fact.** A guard writes each priced `llm:end` to the store and trips when the stored total passes the budget. To stop a request before it spends, call `reserve` yourself as above. -- **Connection lifetime is yours.** Use a pool on Node. On Workers, create one `pg` client per request and close it in `waitUntil`. `drizzle-orm` and `pg` are optional peers, needed only for the `/postgres` subpath. +- **Connection lifetime is yours.** Use a pool on Node. On Workers, create a `pg` client or a small pool per request and close it in `waitUntil`. `drizzle-orm` and `pg` are optional peers, needed only for the `/postgres` subpath. +- **A released id stays released.** `reserve` with the id of a released reservation throws `AK_COST_RESERVATION_RELEASED` instead of reporting it as admitted. Use a new id to retry a call. +- **Abandoned reservations.** A reservation that is never committed or released (a crashed process) keeps its amount held; the store has no background reaper. Schedule `expirePostgresCostReservations({ db, olderThan })` with a cutoff longer than your slowest call, for example `new Date(Date.now() - 60 * 60_000)`. +- **Store failures.** A guard never throws into the run when the store fails. It reports the error to `onError`; without `onError` it logs one warning for the first failure. Set `onError` in production so a store that stops recording is visible. +- **Match errors by `code`.** In CommonJS each subpath bundles its own copy of `CostReservationError`, so `instanceof` fails across `require('@agentskit/observability')` and `require('@agentskit/observability/postgres')`. Compare `error.code`. +- **Transactions.** Call `reserve` outside your own transaction, or inside a savepoint. When two calls race on one reservation id, the store absorbs the loser's unique violation, but PostgreSQL has already aborted the enclosing transaction. - **Your own store.** Implement `CostStore` over your tables and run `costStoreContract` from `@agentskit/observability/cost-store-contract` in your test suite. `createInMemoryCostStore()` is the development and test implementation. ## Ecosystem diff --git a/packages/observability/package.json b/packages/observability/package.json index d29a62327..9745fbab6 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -72,6 +72,7 @@ "lint": "tsc --noEmit", "dev": "tsup --watch", "test:postgres": "pnpm exec turbo run build --filter=@agentskit/observability --concurrency=2 && vitest run tests/postgres.integration.test.ts", + "test:cjs": "pnpm exec turbo run build --filter=@agentskit/observability --concurrency=2 && node tests/cjs-smoke.cjs", "test:workerd": "pnpm exec turbo run build --filter=@agentskit/observability --concurrency=2 && node tests/postgres-workerd.mjs" }, "dependencies": { diff --git a/packages/observability/src/cost-guard-advanced.ts b/packages/observability/src/cost-guard-advanced.ts index ba68e4468..d436f9042 100644 --- a/packages/observability/src/cost-guard-advanced.ts +++ b/packages/observability/src/cost-guard-advanced.ts @@ -91,7 +91,7 @@ export function createAdvancedCostGuard( const tenants = new Map() let activeTenant: string | undefined const safeNow = createSafeNow(clock, onError, reportCostGuardError) - const durable = durableSpendRecorder('createAdvancedCostGuard', options.store, err => reportCostGuardError(onError, err)) + const durable = durableSpendRecorder('createAdvancedCostGuard', options, err => reportCostGuardError(onError, err)) const fireAlert = async (event: CostAlertEvent): Promise => { for (const sink of options.alertSinks ?? []) { diff --git a/packages/observability/src/cost-guard-multi-tenant.ts b/packages/observability/src/cost-guard-multi-tenant.ts index 6fae0f661..70c74fecb 100644 --- a/packages/observability/src/cost-guard-multi-tenant.ts +++ b/packages/observability/src/cost-guard-multi-tenant.ts @@ -111,7 +111,7 @@ export function multiTenantCostGuard(options: MultiTenantCostGuardOptions): Obse const onError = options.onError const tenants = new Map() let activeTenant: string | undefined - const durable = durableSpendRecorder('multiTenantCostGuard', options.store, err => reportCostGuardError(onError, err)) + const durable = durableSpendRecorder('multiTenantCostGuard', options, err => reportCostGuardError(onError, err)) const resolve = (): string | undefined => { if (!options.tenantOf) return activeTenant diff --git a/packages/observability/src/cost-guard-store.ts b/packages/observability/src/cost-guard-store.ts index 84bb2cfe3..95583124a 100644 --- a/packages/observability/src/cost-guard-store.ts +++ b/packages/observability/src/cost-guard-store.ts @@ -1,5 +1,7 @@ import { recordCostSpend, warnProcessLocalCostState, type CostStore, type CostWindow } from './cost-store' +type StoreErrorHandler = (error: unknown) => void | Promise + /** Durable accounting shared by `costGuard`, `multiTenantCostGuard` and `createAdvancedCostGuard`. */ export interface CostGuardStoreOptions { /** @@ -8,6 +10,12 @@ export interface CostGuardStoreOptions { * it, totals live in process memory (development only; warns in production). */ store?: CostStore + /** + * Controls the warning for a guard that runs without a `store`. By default it + * warns when `NODE_ENV` is `production`, and on Cloudflare Workers when + * `NODE_ENV` is not set. `true` always warns; `false` silences it. + */ + warnWithoutStore?: boolean } /** One priced `llm:end` event, as a guard sees it. */ @@ -24,23 +32,34 @@ export type DurableSpendRecorder = (call: PricedCall, onWindow: (window: CostWin /** * Build the recorder a guard calls after pricing an `llm:end` event. Store - * failures go to `report` and never escape the observer. - * @param scope Guard name used in the production warning. - * @param store Durable store, or `undefined` to keep process-local totals. + * failures go to `report` and never escape the observer. A guard without an + * `onError` handler warns once on the first failure, so a store that stops + * recording is not silent. + * @param scope Guard name used in the warnings. + * @param options The guard's `store`, `warnWithoutStore` and `onError`. * @param report Isolated error reporter of the guard. * @returns The recorder, or `undefined` when no store is configured. */ export function durableSpendRecorder( scope: string, - store: CostStore | undefined, + options: CostGuardStoreOptions & { onError?: StoreErrorHandler }, report: (error: unknown) => void, ): DurableSpendRecorder | undefined { + const { store } = options if (!store) { - warnProcessLocalCostState(scope) + warnProcessLocalCostState(scope, options.warnWithoutStore) return undefined } + let warned = false + const failed = (error: unknown): void => { + if (!options.onError && !warned) { + warned = true + console.warn(`[agentskit] ${scope}: writing to the CostStore failed (${error instanceof Error ? error.message : String(error)}). This spend was not recorded durably and later failures will not be logged. Pass \`onError\` to handle them.`) + } + report(error) + } return ({ tenant, model = 'unknown', promptTokens, completionTokens, costUsd }, onWindow) => { void recordCostSpend(store, { tenant, costUsd, usage: [{ model, promptTokens, completionTokens, costUsd }] }) - .then(onWindow).then(undefined, report) + .then(onWindow).then(undefined, failed) } } diff --git a/packages/observability/src/cost-guard.ts b/packages/observability/src/cost-guard.ts index 3326a2a34..7d97b8803 100644 --- a/packages/observability/src/cost-guard.ts +++ b/packages/observability/src/cost-guard.ts @@ -324,7 +324,7 @@ export function costGuard(options: CostGuardOptions): Observer & { let exceededOnce = false const tenant = options.tenant ?? 'default' - const durable = durableSpendRecorder('costGuard', options.store, err => reportCostGuardError(onError, err)) + const durable = durableSpendRecorder('costGuard', options, err => reportCostGuardError(onError, err)) const trip = (costUsd: number) => { if (exceededOnce) return diff --git a/packages/observability/src/cost-store-contract.ts b/packages/observability/src/cost-store-contract.ts index 3f4b3ff24..1c48bda43 100644 --- a/packages/observability/src/cost-store-contract.ts +++ b/packages/observability/src/cost-store-contract.ts @@ -1,5 +1,5 @@ import { AgentsKitError } from '@agentskit/core' -import { CostReservationError, type CostStore } from './cost-store' +import type { CostReservationError, CostStore } from './cost-store' /** Options of `costStoreContract`. */ export interface CostStoreContractOptions { @@ -32,7 +32,8 @@ async function rejectsWith(code: CostReservationError['code'], run: () => Promis await run() return false } catch (error) { - return error instanceof CostReservationError && error.code === code + // By code, not `instanceof`: a store loaded from another CommonJS entry point throws its own copy of the class. + return (error as { code?: unknown } | null)?.code === code } } @@ -96,6 +97,8 @@ export async function costStoreContract(store: CostStore, options: CostStoreCont const again = await store.release({ tenant: s, reservationId: 'released' }) check(near(again.reservedUsd, 0) && near(again.spentUsd, 0), 'release returns the reservation and is idempotent') check(await rejectsWith('AK_COST_RESERVATION_RELEASED', () => store.commit({ tenant: s, reservationId: 'released', actualUsd: 0.2 })), 'commit after release is rejected') + check(await rejectsWith('AK_COST_RESERVATION_RELEASED', () => store.reserve({ tenant: s, reservationId: 'released', amountUsd: 0.2, windowKey })), 'a released reservation id is not admitted again') + check(near((await store.window({ tenant: s, windowKey })).reservedUsd, 0), 'a rejected replay holds nothing') await store.reserve({ tenant: s, reservationId: 'committed', amountUsd: 0.2, windowKey }) await store.commit({ tenant: s, reservationId: 'committed', actualUsd: 0.3 }) check(await rejectsWith('AK_COST_RESERVATION_COMMITTED', () => store.release({ tenant: s, reservationId: 'committed' })), 'release after commit is rejected') diff --git a/packages/observability/src/cost-store.ts b/packages/observability/src/cost-store.ts index 1ff8c69c9..52f279965 100644 --- a/packages/observability/src/cost-store.ts +++ b/packages/observability/src/cost-store.ts @@ -96,7 +96,11 @@ const RESERVATION_ERRORS = { AK_COST_RESERVATION_COMMITTED: 'was already committed', } as const -/** Thrown when `commit` or `release` targets a reservation the store cannot settle. */ +/** + * Thrown when an operation targets a reservation the store cannot settle. + * Match it by `code`: the CommonJS build of each subpath carries its own copy of + * this class, so `instanceof` fails across `require` entry points. + */ export class CostReservationError extends AgentsKitError { declare readonly code: keyof typeof RESERVATION_ERRORS @@ -165,6 +169,18 @@ export function validateCostReserve(input: CostReserveInput): void { if (input.capUsd !== undefined) assertUsd('capUsd', input.capUsd) } +/** Validate a release input; shared by every `CostStore` implementation. */ +export function validateCostRelease(input: CostReleaseInput): void { + assertKey('tenant', input.tenant) + assertKey('reservationId', input.reservationId) +} + +/** Validate a window input; shared by every `CostStore` implementation. */ +export function validateCostWindow(input: CostWindowInput): void { + assertKey('tenant', input.tenant) + if (input.capUsd !== undefined) assertUsd('capUsd', input.capUsd) +} + /** Validate a commit input; shared by every `CostStore` implementation. */ export function validateCostCommit(input: CostCommitInput): void { assertKey('tenant', input.tenant) @@ -234,7 +250,11 @@ export function createInMemoryCostStore(options: CostStoreOptions = {}): InMemor validateCostReserve(input) const id = JSON.stringify([input.tenant, input.reservationId]) const existing = reservations.get(id) - if (existing) return { ok: true, reservationId: input.reservationId, window: describe(input.tenant, existing.windowKey, input.capUsd) } + if (existing) { + // A released reservation holds nothing, so replaying it as admitted would let the call run unaccounted. + if (existing.status === 'released') throw new CostReservationError('AK_COST_RESERVATION_RELEASED', input.reservationId) + return { ok: true, reservationId: input.reservationId, window: describe(input.tenant, existing.windowKey, input.capUsd) } + } const windowKey = input.windowKey ?? keyOf(now()) const state = windowOf(input.tenant, windowKey) const amount = toNano(input.amountUsd) @@ -259,8 +279,7 @@ export function createInMemoryCostStore(options: CostStoreOptions = {}): InMemor return describe(input.tenant, reservation.windowKey) }, async release(input) { - assertKey('tenant', input.tenant) - assertKey('reservationId', input.reservationId) + validateCostRelease(input) const reservation = find(input.tenant, input.reservationId) if (reservation.status === 'committed') throw new CostReservationError('AK_COST_RESERVATION_COMMITTED', input.reservationId) if (reservation.status === 'reserved') { @@ -270,7 +289,7 @@ export function createInMemoryCostStore(options: CostStoreOptions = {}): InMemor return describe(input.tenant, reservation.windowKey) }, async window(input) { - assertKey('tenant', input.tenant) + validateCostWindow(input) return describe(input.tenant, input.windowKey ?? keyOf(now()), input.capUsd) }, ledger: () => ledger.map(row => ({ ...row })), @@ -287,22 +306,39 @@ export interface CostSpendRecord { /** * Record spend that already happened: reserve it without a ceiling, then commit * it, and return the resulting window. Cost guards use this after each priced call. + * When the commit fails, the hold is released before the error is rethrown, so a + * failed write does not leave the amount reserved. */ export async function recordCostSpend(store: CostStore, record: CostSpendRecord): Promise { const reservationId = `spend-${globalThis.crypto.randomUUID()}` await store.reserve({ tenant: record.tenant, reservationId, amountUsd: record.costUsd }) - return store.commit({ tenant: record.tenant, reservationId, actualUsd: record.costUsd, usage: record.usage }) + try { + return await store.commit({ tenant: record.tenant, reservationId, actualUsd: record.costUsd, usage: record.usage }) + } catch (error) { + // Best effort: when the store is unreachable the release fails too, and the reservation is left for expiry. + await store.release({ tenant: record.tenant, reservationId }).catch(() => {}) + throw error + } } const warned = new Set() +/** True for `NODE_ENV=production`, and for Cloudflare Workers when `NODE_ENV` is not set (isolates expose no `process.env` by default). */ +function looksLikeProduction(): boolean { + const scope = globalThis as { process?: { env?: Record }; navigator?: { userAgent?: string } } + const nodeEnv = scope.process?.env?.NODE_ENV + if (nodeEnv !== undefined) return nodeEnv === 'production' + return scope.navigator?.userAgent === 'Cloudflare-Workers' +} + /** - * Warn once per guard when it runs in production without a `CostStore`: - * its totals then live in process memory and do not hold across instances. + * Warn once per guard when it runs without a `CostStore`: its totals then live + * in process memory and do not hold across instances. + * @param scope Guard name used in the message. + * @param warn `true` always warns, `false` never does; by default it warns in production runtimes. */ -export function warnProcessLocalCostState(scope: string): void { - const env = (globalThis as { process?: { env?: Record } }).process?.env - if (env?.NODE_ENV !== 'production' || warned.has(scope)) return +export function warnProcessLocalCostState(scope: string, warn?: boolean): void { + if (!(warn ?? looksLikeProduction()) || warned.has(scope)) return warned.add(scope) console.warn(`[agentskit] ${scope}: no CostStore configured. Spend is tracked in process memory, so budgets reset on restart and are not shared between instances or isolates. Pass a durable \`store\`.`) } diff --git a/packages/observability/src/postgres.ts b/packages/observability/src/postgres.ts index f90c04900..0e192454d 100644 --- a/packages/observability/src/postgres.ts +++ b/packages/observability/src/postgres.ts @@ -1,12 +1,14 @@ import { sql } from 'drizzle-orm' import type { NodePgDatabase } from 'drizzle-orm/node-postgres' -import { bigserial, boolean, integer, numeric, pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core' +import { bigint, bigserial, boolean, check, index, numeric, pgTable, primaryKey, text, timestamp } from 'drizzle-orm/pg-core' import { CostReservationError, describeCostWindow, monthlyWindowKey, validateCostCommit, + validateCostRelease, validateCostReserve, + validateCostWindow, type CostStore, type CostStoreOptions, type CostWindow, @@ -31,7 +33,10 @@ export const postgresCostReservationTable = pgTable('agentskit_cost_reservation' actualUsd: usd('actual_usd'), status: text('status').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), -}, table => [primaryKey({ columns: [table.tenantId, table.reservationId] })]) +}, table => [ + primaryKey({ columns: [table.tenantId, table.reservationId] }), + check('agentskit_cost_reservation_status_check', sql`${table.status} IN ('reserved', 'committed', 'released')`), +]) /** Drizzle schema: append-only usage ledger written when a reservation is committed. */ export const postgresUsageLedgerTable = pgTable('agentskit_usage_ledger', { @@ -39,13 +44,13 @@ export const postgresUsageLedgerTable = pgTable('agentskit_usage_ledger', { tenantId: text('tenant_id').notNull(), reservationId: text('reservation_id').notNull(), model: text('model').notNull(), - promptTokens: integer('prompt_tokens').notNull(), - completionTokens: integer('completion_tokens').notNull(), + promptTokens: bigint('prompt_tokens', { mode: 'number' }).notNull(), + completionTokens: bigint('completion_tokens', { mode: 'number' }).notNull(), costUsd: usd('cost_usd').notNull(), source: text('source'), fallback: boolean('fallback').notNull().default(false), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), -}) +}, table => [index('agentskit_usage_ledger_tenant_created').on(table.tenantId, table.createdAt)]) /** Idempotent PostgreSQL DDL; execute with the host's migration connection before use. */ export const postgresCostMigrationSql = `CREATE TABLE IF NOT EXISTS agentskit_cost_window ( @@ -70,8 +75,8 @@ CREATE TABLE IF NOT EXISTS agentskit_usage_ledger ( tenant_id text NOT NULL, reservation_id text NOT NULL, model text NOT NULL, - prompt_tokens integer NOT NULL, - completion_tokens integer NOT NULL, + prompt_tokens bigint NOT NULL, + completion_tokens bigint NOT NULL, cost_usd numeric(20, 9) NOT NULL, source text, fallback boolean NOT NULL DEFAULT false, @@ -100,6 +105,15 @@ function isUniqueViolation(error: unknown): boolean { * Durable `CostStore` on PostgreSQL. Each operation is one statement, so a * reservation is admitted against the cap atomically without an explicit * transaction. BYO Drizzle/pg connection; the caller owns migration and lifetime. + * + * Do not call `reserve` inside a transaction of your own without a savepoint: + * when two calls race on one reservation id, the loser's statement ends in a + * unique violation that the store absorbs, but PostgreSQL has already aborted + * the enclosing transaction. + * + * A reservation that is never committed or released keeps its amount held (a + * crashed process, for example). Run `expirePostgresCostReservations` on a + * schedule to return those holds. */ export function postgresCostStore({ db, windowKey: keyOf = monthlyWindowKey, now = () => new Date() }: PostgresCostStoreOptions): CostStore { const rows = async (query: ReturnType): Promise => (await db.execute(query)).rows as Row[] @@ -145,7 +159,9 @@ export function postgresCostStore({ db, windowKey: keyOf = monthlyWindowKey, now if (row) { return { ok: true, reservationId, window: describeCostWindow(tenant, windowKey, Number(row.spent_usd), Number(row.reserved_usd), input.capUsd) } } - const [existing] = await rows(sql`SELECT window_key FROM agentskit_cost_reservation WHERE tenant_id = ${tenant} AND reservation_id = ${reservationId}`) + const [existing] = await rows(sql`SELECT window_key, status FROM agentskit_cost_reservation WHERE tenant_id = ${tenant} AND reservation_id = ${reservationId}`) + // A released reservation holds nothing, so replaying it as admitted would let the call run unaccounted. + if (existing?.status === 'released') throw new CostReservationError('AK_COST_RESERVATION_RELEASED', reservationId) if (existing) return { ok: true, reservationId, window: await window(tenant, String(existing.window_key), input.capUsd) } return { ok: false, reason: 'quota_exceeded', window: await window(tenant, windowKey, input.capUsd) } }, @@ -168,7 +184,7 @@ export function postgresCostStore({ db, windowKey: keyOf = monthlyWindowKey, now INSERT INTO agentskit_usage_ledger (tenant_id, reservation_id, model, prompt_tokens, completion_tokens, cost_usd, source, fallback) SELECT ${tenant}, ${reservationId}, e.model, e.prompt_tokens, e.completion_tokens, e.cost_usd, e.source, e.fallback FROM settled, jsonb_to_recordset(${usage}::jsonb) - AS e(model text, prompt_tokens integer, completion_tokens integer, cost_usd numeric, source text, fallback boolean) + AS e(model text, prompt_tokens bigint, completion_tokens bigint, cost_usd numeric, source text, fallback boolean) ) UPDATE agentskit_cost_window AS w SET reserved_usd = w.reserved_usd - settled.amount_usd, spent_usd = w.spent_usd + ${actualUsd}::numeric @@ -180,6 +196,7 @@ export function postgresCostStore({ db, windowKey: keyOf = monthlyWindowKey, now return window(tenant, existing.windowKey) }, async release(input) { + validateCostRelease(input) const { tenant, reservationId } = input const [row] = await rows(sql`WITH settled AS ( UPDATE agentskit_cost_reservation SET status = 'released' @@ -194,6 +211,36 @@ export function postgresCostStore({ db, windowKey: keyOf = monthlyWindowKey, now if (existing.status === 'committed') throw new CostReservationError('AK_COST_RESERVATION_COMMITTED', reservationId) return window(tenant, existing.windowKey) }, - window: input => window(input.tenant, input.windowKey ?? keyOf(now()), input.capUsd), + async window(input) { + validateCostWindow(input) + return window(input.tenant, input.windowKey ?? keyOf(now()), input.capUsd) + }, } } + +/** Options of `expirePostgresCostReservations`. */ +export interface ExpirePostgresCostReservationsOptions { + db: Pick + /** Reservations still held and created before this instant are released. */ + olderThan: Date +} + +/** + * Release reservations that were never committed or released, and return how + * many. The store has no background reaper: schedule this with a cutoff longer + * than your slowest call (for example, one hour), or a call still running loses + * its hold and its later `commit` is rejected as already released. + */ +export async function expirePostgresCostReservations({ db, olderThan }: ExpirePostgresCostReservationsOptions): Promise { + const result = await db.execute(sql`WITH expired AS ( + UPDATE agentskit_cost_reservation SET status = 'released' + WHERE status = 'reserved' AND created_at < ${olderThan.toISOString()}::timestamptz + RETURNING tenant_id, window_key, amount_usd + ), totals AS ( + SELECT tenant_id, window_key, sum(amount_usd) AS amount_usd, count(*) AS reservations FROM expired GROUP BY tenant_id, window_key + ) + UPDATE agentskit_cost_window AS w SET reserved_usd = w.reserved_usd - totals.amount_usd + FROM totals WHERE w.tenant_id = totals.tenant_id AND w.window_key = totals.window_key + RETURNING totals.reservations`) + return (result.rows as Row[]).reduce((released, row) => released + Number(row.reservations), 0) +} diff --git a/packages/observability/tests/cjs-smoke.cjs b/packages/observability/tests/cjs-smoke.cjs new file mode 100644 index 000000000..3b33fb932 --- /dev/null +++ b/packages/observability/tests/cjs-smoke.cjs @@ -0,0 +1,31 @@ +// CommonJS consumers load each subpath as a separate bundle. This runs the contract through `require`, where the +// store and the contract do not share module instances. +const assert = require('node:assert/strict') +const { createInMemoryCostStore } = require('@agentskit/observability') +const { costStoreContract } = require('@agentskit/observability/cost-store-contract') +const { postgresCostMigrationSql, postgresCostStore } = require('@agentskit/observability/postgres') + +const passed = result => assert.deepEqual([...new Set(Object.values(result))], ['passed']) + +async function main() { + passed(await costStoreContract(createInMemoryCostStore(), { tenantPrefix: `cjs-${crypto.randomUUID()}` })) + console.log('CommonJS: in-memory CostStore passed the contract') + const port = process.env.AK_POSTGRES_TEST_PORT + if (!port) { + assert.equal(typeof postgresCostStore, 'function') + console.log('CommonJS: PostgreSQL run skipped (AK_POSTGRES_TEST_PORT is not set)') + return + } + const { Pool } = require('pg') + const { drizzle } = require('drizzle-orm/node-postgres') + const { sql } = require('drizzle-orm') + const pool = new Pool({ host: '127.0.0.1', port: Number(port), user: 'postgres', database: 'postgres', max: 20 }) + try { + const db = drizzle(pool) + await db.execute(sql.raw(postgresCostMigrationSql)) + passed(await costStoreContract(postgresCostStore({ db }), { tenantPrefix: `cjs-${crypto.randomUUID()}` })) + console.log('CommonJS: PostgreSQL CostStore passed the contract') + } finally { await pool.end() } +} + +main().catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/packages/observability/tests/cost-guard-store.test.ts b/packages/observability/tests/cost-guard-store.test.ts index 8353305b1..d815c5507 100644 --- a/packages/observability/tests/cost-guard-store.test.ts +++ b/packages/observability/tests/cost-guard-store.test.ts @@ -95,6 +95,39 @@ describe('cost guards with a CostStore', () => { expect(guard.costUsd()).toBeCloseTo(0.1) }) + it('warns once when the store fails and no onError handler is set', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const failing: CostStore = { + reserve: async () => { throw new Error('store down') }, + commit: async () => { throw new Error('store down') }, + release: async () => { throw new Error('store down') }, + window: async () => { throw new Error('store down') }, + } + const guard = costGuard({ budgetUsd: 1, controller: new AbortController(), prices, store: failing }) + send(guard, 100) + await flush() + send(guard, 100) + await flush() + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[0]).toContain('costGuard: writing to the CostStore failed (store down)') + const handled = costGuard({ budgetUsd: 1, controller: new AbortController(), prices, store: failing, onError: () => {} }) + send(handled, 100) + await flush() + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('lets a host force or silence the missing-store warning', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // A fresh module instance: the warning fires once per guard name in a process. + vi.resetModules() + const fresh = await import('../src/cost-guard-multi-tenant') + fresh.multiTenantCostGuard({ budgets: {}, warnWithoutStore: true }) + expect(warn).toHaveBeenCalledTimes(1) + vi.stubEnv('NODE_ENV', 'production') + costGuard({ budgetUsd: 1, controller: new AbortController(), warnWithoutStore: false }) + expect(warn).toHaveBeenCalledTimes(1) + }) + it('warns in production when no store is configured', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.stubEnv('NODE_ENV', 'production') diff --git a/packages/observability/tests/cost-store-contract.test.ts b/packages/observability/tests/cost-store-contract.test.ts index 55413ccbe..b15e53c93 100644 --- a/packages/observability/tests/cost-store-contract.test.ts +++ b/packages/observability/tests/cost-store-contract.test.ts @@ -44,6 +44,25 @@ describe('costStoreContract', () => { await expect(run(shared)).rejects.toThrow(/tenants do not share caps/) }) + it('fails a store that admits a released reservation id again', async () => { + const lenient = broken(store => ({ + reserve: async input => { + try { return await store.reserve(input) } catch { return { ok: true, reservationId: input.reservationId, window: await store.window(input) } } + }, + })) + await expect(run(lenient)).rejects.toThrow(/released reservation id is not admitted again/) + }) + + it('accepts typed errors from another copy of the error class, as across CommonJS entry points', async () => { + const foreign = (error: unknown): never => { throw Object.assign(new Error('foreign'), { code: (error as { code?: unknown }).code }) } + const store = broken(inner => ({ + reserve: input => inner.reserve(input).catch(foreign), + commit: input => inner.commit(input).catch(foreign), + release: input => inner.release(input).catch(foreign), + })) + await expect(run(store)).resolves.toMatchObject({ settlement: 'passed' }) + }) + it('fails a store that settles unknown reservations silently', async () => { const silent = broken(store => ({ commit: async input => { diff --git a/packages/observability/tests/cost-store.test.ts b/packages/observability/tests/cost-store.test.ts index efe4d561a..15f3eadf1 100644 --- a/packages/observability/tests/cost-store.test.ts +++ b/packages/observability/tests/cost-store.test.ts @@ -48,15 +48,42 @@ describe('createInMemoryCostStore', () => { expect(new CostReservationError('AK_COST_RESERVATION_RELEASED', 'x').message).toContain('already released') }) + it('rejects a released reservation id instead of replaying it as admitted', async () => { + const store = createInMemoryCostStore() + await store.reserve({ tenant: 't', reservationId: 'r', amountUsd: 1 }) + await store.release({ tenant: 't', reservationId: 'r' }) + await expect(store.reserve({ tenant: 't', reservationId: 'r', amountUsd: 1 })).rejects.toMatchObject({ code: 'AK_COST_RESERVATION_RELEASED' }) + expect(await store.window({ tenant: 't' })).toMatchObject({ reservedUsd: 0, spentUsd: 0 }) + await expect(store.window({ tenant: ' ' })).rejects.toBeInstanceOf(ConfigError) + await expect(store.window({ tenant: 't', capUsd: -1 })).rejects.toBeInstanceOf(ConfigError) + await expect(store.release({ tenant: 't', reservationId: ' ' })).rejects.toBeInstanceOf(ConfigError) + }) + it('keeps utilization finite for a zero cap', () => { expect(describeCostWindow('t', 'w', 0, 0, 0)).toMatchObject({ utilization: 0, remainingUsd: 0 }) expect(describeCostWindow('t', 'w', 1, 0, 0)).toMatchObject({ utilization: 1, remainingUsd: 0 }) }) }) +describe('recordCostSpend', () => { + it('releases the hold when the commit fails and rethrows the commit error', async () => { + const store = createInMemoryCostStore() + const failing = { ...store, commit: async () => { throw new Error('commit failed') } } + await expect(recordCostSpend(failing, { tenant: 't', costUsd: 0.4 })).rejects.toThrow('commit failed') + expect(await store.window({ tenant: 't' })).toMatchObject({ reservedUsd: 0, spentUsd: 0 }) + }) + + it('still reports the commit error when the release fails too', async () => { + const store = createInMemoryCostStore() + const down = { ...store, commit: async () => { throw new Error('commit failed') }, release: async () => { throw new Error('release failed') } } + await expect(recordCostSpend(down, { tenant: 't', costUsd: 0.4 })).rejects.toThrow('commit failed') + }) +}) + describe('warnProcessLocalCostState', () => { afterEach(() => { vi.unstubAllEnvs() + vi.unstubAllGlobals() vi.restoreAllMocks() }) @@ -70,4 +97,26 @@ describe('warnProcessLocalCostState', () => { expect(warn).toHaveBeenCalledTimes(1) expect(warn.mock.calls[0]?.[0]).toContain('scope-prod: no CostStore configured') }) + + it('follows an explicit choice over the detected runtime', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + warnProcessLocalCostState('scope-forced', true) + expect(warn).toHaveBeenCalledTimes(1) + vi.stubEnv('NODE_ENV', 'production') + warnProcessLocalCostState('scope-silenced', false) + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('warns on Cloudflare Workers, where NODE_ENV is not set', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubEnv('NODE_ENV', undefined) + warnProcessLocalCostState('scope-node-unset') + expect(warn).not.toHaveBeenCalled() + vi.stubGlobal('navigator', { userAgent: 'Cloudflare-Workers' }) + warnProcessLocalCostState('scope-worker') + expect(warn).toHaveBeenCalledTimes(1) + vi.stubEnv('NODE_ENV', 'development') + warnProcessLocalCostState('scope-worker-dev') + expect(warn).toHaveBeenCalledTimes(1) + }) }) diff --git a/packages/observability/tests/postgres-contract.ts b/packages/observability/tests/postgres-contract.ts index 060b0c835..a1d8d9e60 100644 --- a/packages/observability/tests/postgres-contract.ts +++ b/packages/observability/tests/postgres-contract.ts @@ -1,11 +1,12 @@ import { sql } from 'drizzle-orm' import type { NodePgDatabase } from 'drizzle-orm/node-postgres' import { costStoreContract, type CostStoreContractResult } from '@agentskit/observability/cost-store-contract' -import { postgresCostMigrationSql, postgresCostStore } from '@agentskit/observability/postgres' +import { expirePostgresCostReservations, postgresCostMigrationSql, postgresCostStore } from '@agentskit/observability/postgres' export interface PostgresCostContractResult extends CostStoreContractResult { ledger: 'passed' replayRace: 'passed' + expiry: 'passed' } /** Shared by the Node pool test and the workerd fixture; imports the built package. */ @@ -35,5 +36,16 @@ export async function postgresCostContract(db: NodePgDatabase, tenantPrefix: str const raced = await store.window({ tenant, windowKey: 'contract' }) if (Math.abs(raced.reservedUsd - 0.2) > 1e-9) throw new Error(`replayed reservations were counted more than once (${raced.reservedUsd})`) - return { ...contract, ledger: 'passed', replayRace: 'passed' } + // Last: expiry releases every reservation still held in this test database, including the ones raced above. + const abandoned = `${tenantPrefix}:expiry` + await store.reserve({ tenant: abandoned, reservationId: 'abandoned', amountUsd: 0.3, windowKey: 'contract' }) + if (await expirePostgresCostReservations({ db, olderThan: new Date(0) }) !== 0) throw new Error('a cutoff before every reservation must release nothing') + const released = await expirePostgresCostReservations({ db, olderThan: new Date(Date.now() + 60_000) }) + const after = await store.window({ tenant: abandoned, windowKey: 'contract' }) + if (released < 1 || Math.abs(after.reservedUsd) > 1e-9) throw new Error(`expiry released ${released} reservations and left ${after.reservedUsd} held`) + const late = await store.commit({ tenant: abandoned, reservationId: 'abandoned', actualUsd: 0.3 }).then(() => undefined, (error: { code?: unknown }) => error.code) + if (late !== 'AK_COST_RESERVATION_RELEASED') throw new Error('a commit after expiry must be rejected as released') + if (Math.abs((await store.window({ tenant, windowKey: 'contract' })).reservedUsd) > 1e-9) throw new Error('expiry must return the holds of every tenant') + + return { ...contract, ledger: 'passed', replayRace: 'passed', expiry: 'passed' } } diff --git a/packages/observability/tests/postgres-worker.ts b/packages/observability/tests/postgres-worker.ts index f4b50095a..3434d16ed 100644 --- a/packages/observability/tests/postgres-worker.ts +++ b/packages/observability/tests/postgres-worker.ts @@ -1,16 +1,17 @@ -import { Client } from 'pg' +import { Pool } from 'pg' import { drizzle } from 'drizzle-orm/node-postgres' import { postgresCostContract } from './postgres-contract' const worker = { async fetch(_request: Request, env: { AK_POSTGRES_TEST_PORT: string }, ctx: { waitUntil(promise: Promise): void }) { - const client = new Client({ host: '127.0.0.1', port: Number(env.AK_POSTGRES_TEST_PORT), user: 'postgres', database: 'postgres' }) + // A pool, not one client: the cap race must run on several connections here as well. workerd allows six + // simultaneous outbound connections per request. + const pool = new Pool({ host: '127.0.0.1', port: Number(env.AK_POSTGRES_TEST_PORT), user: 'postgres', database: 'postgres', max: 5 }) try { - await client.connect() - return Response.json(await postgresCostContract(drizzle(client), `worker-${crypto.randomUUID()}`)) - } catch { - return Response.json({ error: 'Postgres workerd contract failed' }, { status: 500 }) - } finally { ctx.waitUntil(client.end()) } + return Response.json(await postgresCostContract(drizzle(pool), `worker-${crypto.randomUUID()}`)) + } catch (error) { + return Response.json({ error: 'Postgres workerd contract failed', detail: error instanceof Error ? error.message : String(error) }, { status: 500 }) + } finally { ctx.waitUntil(pool.end()) } }, } // Wrangler's module entry requires a default export; this is a test-only fixture. diff --git a/packages/observability/tests/postgres-workerd.mjs b/packages/observability/tests/postgres-workerd.mjs index 1d5c9a4e8..e4ec6a74b 100644 --- a/packages/observability/tests/postgres-workerd.mjs +++ b/packages/observability/tests/postgres-workerd.mjs @@ -15,9 +15,10 @@ try { assert(running, 'wrangler exited before acceptance') return fetch('http://127.0.0.1:8798', { signal: AbortSignal.timeout(15000) }) }, { retries: 239, minDelayMs: 500, maxDelayMs: 500, jitter: 'none', shouldRetry: () => running }) - assert.equal(response.status, 200, 'real workerd/Postgres request') - assert.deepEqual(await readJson(response, { maxBytes: 4096 }), { accounting: 'passed', cap: 'passed', idempotency: 'passed', settlement: 'passed', isolation: 'passed', release: 'passed', ledger: 'passed', replayRace: 'passed' }) - console.log('workerd + PostgreSQL: CostStore contract, ledger and replay race passed') + const body = await readJson(response, { maxBytes: 4096 }) + assert.equal(response.status, 200, `real workerd/Postgres request: ${JSON.stringify(body)}`) + assert.deepEqual(body, { accounting: 'passed', cap: 'passed', idempotency: 'passed', settlement: 'passed', isolation: 'passed', release: 'passed', ledger: 'passed', replayRace: 'passed', expiry: 'passed' }) + console.log('workerd + PostgreSQL (pool of 5): CostStore contract, ledger, replay race and expiry passed') } finally { await child.kill('SIGTERM') await child.exited.catch(() => {}) diff --git a/packages/observability/tests/postgres.integration.test.ts b/packages/observability/tests/postgres.integration.test.ts index 325a9fd29..1120355b2 100644 --- a/packages/observability/tests/postgres.integration.test.ts +++ b/packages/observability/tests/postgres.integration.test.ts @@ -1,13 +1,12 @@ import { expect, it } from 'vitest' -import { Pool } from 'pg' -import { drizzle } from 'drizzle-orm/node-postgres' -import { postgresCostContract } from './postgres-contract' it.skipIf(!process.env.AK_POSTGRES_TEST_PORT)('CostStore contract on real PostgreSQL', async () => { + // Loaded here, not at module scope: the contract imports the built package, which a skipped run may not have. + const [{ Pool }, { drizzle }, { postgresCostContract }] = await Promise.all([import('pg'), import('drizzle-orm/node-postgres'), import('./postgres-contract')]) const pool = new Pool({ host: '127.0.0.1', port: Number(process.env.AK_POSTGRES_TEST_PORT), user: 'postgres', database: 'postgres', max: 20 }) try { expect((await pool.query('SHOW server_version')).rows[0].server_version).toMatch(/^16\./) const result = await postgresCostContract(drizzle(pool), `test-${crypto.randomUUID()}`) - expect(Object.values(result)).toEqual(Array(8).fill('passed')) + expect(Object.values(result)).toEqual(Array(9).fill('passed')) } finally { await pool.end() } }, 60_000) diff --git a/packages/observability/tests/postgres.test.ts b/packages/observability/tests/postgres.test.ts index 845765f46..5b7c7cdff 100644 --- a/packages/observability/tests/postgres.test.ts +++ b/packages/observability/tests/postgres.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { postgresCostMigrationSql, postgresCostReservationTable, postgresCostStore, postgresCostWindowTable, postgresUsageLedgerTable } from '../src/postgres' +import { getTableConfig } from 'drizzle-orm/pg-core' +import { expirePostgresCostReservations, postgresCostMigrationSql, postgresCostReservationTable, postgresCostStore, postgresCostWindowTable, postgresUsageLedgerTable } from '../src/postgres' type Rows = Record[] type Db = Parameters[0]['db'] @@ -27,6 +28,14 @@ describe('postgresCostStore', () => { expect([postgresCostWindowTable, postgresCostReservationTable, postgresUsageLedgerTable].map(table => Object.keys(table).length > 0)).toEqual([true, true, true]) }) + it('keeps the Drizzle schema aligned with the DDL constraints', () => { + expect(getTableConfig(postgresCostReservationTable).checks.map(constraint => constraint.name)).toEqual(['agentskit_cost_reservation_status_check']) + expect(getTableConfig(postgresUsageLedgerTable).indexes.map(entry => entry.config.name)).toEqual(['agentskit_usage_ledger_tenant_created']) + expect(postgresUsageLedgerTable.promptTokens.getSQLType()).toBe('bigint') + expect(postgresCostMigrationSql).toContain('prompt_tokens bigint NOT NULL') + expect(postgresCostMigrationSql).toContain('completion_tokens bigint NOT NULL') + }) + it('admits a reservation from the single atomic statement', async () => { const { db, statements } = scripted([[{ spent_usd: '0.250000000', reserved_usd: '0.500000000' }]]) const result = await postgresCostStore({ db, now }).reserve({ tenant: 't', reservationId: 'r', amountUsd: 0.5, capUsd: 1 }) @@ -48,6 +57,29 @@ describe('postgresCostStore', () => { expect(result).toMatchObject({ ok: true, window: { windowKey: '2026-09', reservedUsd: 0.5 } }) }) + it('rejects a released reservation id instead of replaying it as admitted', async () => { + const { db } = scripted([[], [{ window_key: '2026-09', status: 'released' }]]) + await expect(postgresCostStore({ db, now }).reserve({ tenant: 't', reservationId: 'r', amountUsd: 0.5 })).rejects.toMatchObject({ code: 'AK_COST_RESERVATION_RELEASED' }) + }) + + it('validates release and window inputs before any statement', async () => { + const { db, statements } = scripted([]) + const store = postgresCostStore({ db, now }) + await expect(store.release({ tenant: ' ', reservationId: 'r' })).rejects.toMatchObject({ code: 'AK_CONFIG_INVALID' }) + await expect(store.release({ tenant: 't', reservationId: '' })).rejects.toMatchObject({ code: 'AK_CONFIG_INVALID' }) + await expect(store.window({ tenant: '' })).rejects.toMatchObject({ code: 'AK_CONFIG_INVALID' }) + await expect(store.window({ tenant: 't', capUsd: Number.NaN })).rejects.toMatchObject({ code: 'AK_CONFIG_INVALID' }) + expect(statements).toHaveLength(0) + }) + + it('expires abandoned reservations in one statement and counts them', async () => { + const { db, statements } = scripted([[{ reservations: '2' }, { reservations: 3 }], []]) + expect(await expirePostgresCostReservations({ db, olderThan: new Date('2026-10-09T11:00:00Z') })).toBe(5) + expect(statements[0]).toContain("status = 'reserved' AND created_at < ") + expect(statements[0]).toContain('2026-10-09T11:00:00.000Z') + expect(await expirePostgresCostReservations({ db, olderThan: new Date(0) })).toBe(0) + }) + it('propagates database errors that are not a replay', async () => { const { db } = scripted([new Error('connection reset')]) await expect(postgresCostStore({ db, now }).reserve({ tenant: 't', reservationId: 'r', amountUsd: 0.5 })).rejects.toThrow('connection reset') diff --git a/readme-standard-v1.json b/readme-standard-v1.json index 72ee3c379..050e7474e 100644 --- a/readme-standard-v1.json +++ b/readme-standard-v1.json @@ -1065,7 +1065,7 @@ "docs/STABILITY.md", "scripts/readme-fixtures.test.mjs" ], - "sourceHash": "sha256:53e302a7522d80d9c64dfe59f69a41a13d6b79a33210d54d77416b19506edf16" + "sourceHash": "sha256:6cfb38c8e4527f34e92a201e90c4a96d58468b1301db73511104c699f5ccd2c6" }, "exceptions": [] },