From b0782743b63e220a8bf6e4b96b296a1f3adbb43d Mon Sep 17 00:00:00 2001 From: Tom Ribuot Date: Tue, 6 Oct 2026 03:09:51 +0200 Subject: [PATCH] feat: usage report transaction IDs and the usage history Kaiten now makes usage reports idempotent with a client key and keeps their history. - contracts/openapi.yaml is synced from the Kaiten release that adds them, and both generated clients are regenerated. - @kaitencloud/client: ReportUsageInput.transactionId. reportUsage marks a keyed report idempotent, so the transport retries it on network errors and 5xx under the same key; a report without a key is still sent once. A malformed key throws a TypeError before any request; the reused-key 409 throws TransactionIdReusedError (a KaitenError), never retried. - usage.reportDetailed / reportUsageDetailed also return replayed (Idempotent-Replayed) and metadataDropped (Kaiten-Metadata-Dropped); the transport gains runWithResponse to read them. - An API without transactionId refuses the field: the report is resent without it and the client stops sending keys for its lifetime, warning once. - usage.history / listUsageReports walks an instance's entitlement usage reports by afterSeq, with from, to, transactionId and a limit. - @kaitencloud/server: isTransactionIdReused, and the README shows the history operations and how to stream an export. Signed-off-by: Tom Ribuot --- .changeset/usage-report-keys-and-history.md | 9 + contracts/openapi.yaml | 490 +++++++++++++++++- packages/client/README.md | 37 +- packages/client/src/client/client.ts | 177 ++++++- packages/client/src/client/types.ts | 51 +- packages/client/src/core/generated/index.ts | 20 + packages/client/src/core/generated/sdk.gen.ts | 65 ++- .../client/src/core/generated/types.gen.ts | 327 +++++++++++- packages/client/src/core/index.ts | 2 +- packages/client/src/core/runtime/errors.ts | 17 + packages/client/src/core/transport.ts | 31 +- packages/client/src/index.ts | 1 + packages/client/tests/usage.test.ts | 241 +++++++++ packages/server/README.md | 23 + packages/server/src/errors.ts | 19 + packages/server/src/generated/index.ts | 17 + packages/server/src/generated/sdk.gen.ts | 72 ++- packages/server/src/generated/types.gen.ts | 327 +++++++++++- packages/server/src/index.ts | 1 + packages/server/tests/index.test.ts | 97 ++++ 20 files changed, 1993 insertions(+), 31 deletions(-) create mode 100644 .changeset/usage-report-keys-and-history.md create mode 100644 packages/client/tests/usage.test.ts diff --git a/.changeset/usage-report-keys-and-history.md b/.changeset/usage-report-keys-and-history.md new file mode 100644 index 0000000..ba5d29f --- /dev/null +++ b/.changeset/usage-report-keys-and-history.md @@ -0,0 +1,9 @@ +--- +"@kaitencloud/client": minor +"@kaitencloud/server": minor +--- + +Usage report transaction IDs and the usage history. + +- `@kaitencloud/client`: `ReportUsageInput.transactionId` makes a report idempotent, so a keyed report is retried on network errors and 5xx under the same key; a report without a key is still sent once. A key already used for a different report throws `TransactionIdReusedError` (never retried); a malformed one throws a `TypeError` before any request. `usage.reportDetailed` (and `reportUsageDetailed`) also returns `replayed` and `metadataDropped`. Against an API without `transactionId`, the report is resent without it and the client stops sending keys, warning once. `usage.history` (and `listUsageReports`) lists an instance's entitlement usage reports, following the pages. +- `@kaitencloud/server`: the contract gains `transactionId` on the usage report body and the `listUsageReports`, `exportUsageReports` and `exportOrganizationUsageReports` operations. `isTransactionIdReused` recognises the reused-key conflict. diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index e09e19c..0d21213 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -2720,8 +2720,13 @@ components: type: string metadata: additionalProperties: {} - description: Optional metadata for the usage report + description: "Optional metadata for the usage report, a JSON object stored with it in the usage history when its compact encoding is at most 4 KiB. Above that it is not stored, the report is still counted, and the response carries Kaiten-Metadata-Dropped: too_large. It must contain no personal data: anyone who can read the organization's instances can read it, for as long as the usage history is kept. Numbers are read as 64-bit floats, so send large identifiers as strings." type: object + transactionId: + description: "Optional idempotency key, 1 to 128 characters of [A-Za-z0-9._:-], matched exactly and case-sensitively. A report sent again with the same key and the same behavior and value within KAITEN_USAGE_IDEMPOTENCY_WINDOW (35 days by default) is applied once: the retry answers 200 with the original response and the Idempotent-Replayed header, and changes nothing. The same key with another behavior or value answers 409 ReportEntitlementUsageMetric.TransactionIdReused. A rejected report does not consume its key. Scoped to the instance and entitlement: one business event may feed two meters under one key." + examples: + - llm-call-9f2c:tokens + type: string value: description: Reported entitlement value, discriminated by the 'type' field. Usage reporting accepts the number variant only. discriminator: @@ -3150,6 +3155,135 @@ components: - createdAt - createdBy type: object + UsageReport: + additionalProperties: false + properties: + aggregationMethod: + description: The entitlement's aggregation method when the report was accepted + examples: + - SUM + type: string + behavior: + description: append adds the value through the aggregation method; set overwrites the counter + enum: + - append + - set + type: string + delta: + description: valueAfter minus valueBefore; negative for a set that lowered the counter + examples: + - "600" + type: string + entitlementId: + description: The entitlement the report was made for. It may since have been deleted. + format: uuid + type: string + eventCountAfter: + description: Reports counted in the window after this one + examples: + - 1 + format: int32 + type: integer + instanceId: + description: The instance the report was made for. It may since have been deleted. + format: uuid + type: string + licenseId: + description: The instance's licence when the report was accepted + format: uuid + type: string + limitValue: + description: The limit in force when the report was accepted. Null when unlimited. + examples: + - "1000" + type: string + overageDelta: + description: "How much the usage above limitValue moved: max(0, valueAfter - limitValue) - max(0, valueBefore - limitValue). 0 when unlimited." + examples: + - "0" + type: string + overagePercent: + description: The overage allowed above limitValue, in percent, when the report was accepted. Null when unlimited. + examples: + - 50 + format: int32 + type: integer + properties: + additionalProperties: {} + description: The report's metadata, when it was stored + type: object + reportSeq: + description: Position of the report among the pair's accepted reports, from 1, without gaps + examples: + - 42 + format: int64 + type: integer + reportedAt: + description: When the server accepted the report (UTC, millisecond precision) + examples: + - "2026-10-05T08:00:00.000Z" + format: date-time + type: string + reportedValue: + description: "The value as sent: a delta for append, an absolute value for set" + examples: + - "600" + type: string + transactionId: + description: The report's idempotency key, when it was sent with one + examples: + - llm-call-9f2c:tokens + type: string + valueAfter: + description: The counter after the report + examples: + - "600" + type: string + valueBefore: + description: The counter before the report, after any window reset + examples: + - "0" + type: string + windowEnd: + description: End of the usage window the report counted in (exclusive). Null for a lifetime entitlement. + format: date-time + type: string + windowStart: + description: Start of the usage window the report counted in (inclusive). Null for a lifetime entitlement. + format: date-time + type: string + required: + - instanceId + - entitlementId + - reportSeq + - reportedAt + - behavior + - aggregationMethod + - reportedValue + - valueBefore + - valueAfter + - delta + - overageDelta + - eventCountAfter + - licenseId + type: object + UsageReportPage: + additionalProperties: false + properties: + items: + description: The reports, in reportSeq order + items: + $ref: "#/components/schemas/UsageReport" + type: array + nextAfterSeq: + description: Pass as afterSeq to read the next page. Absent on the last page. + examples: + - 100 + format: int64 + type: integer + required: + - items + type: object User: additionalProperties: false properties: @@ -6850,7 +6984,7 @@ paths: tags: - instances post: - description: Report a usage metric for a specific entitlement in a given instance. This endpoint allows you to report the usage of an entitlement, including optional metadata and a timestamp. + description: "Report a usage metric for a specific entitlement in a given instance, with optional metadata. The server dates every report on receipt; the request carries no timestamp. Send a transactionId to make retries safe: without one, a report sent twice counts twice." operationId: reportEntitlementUsageMetric parameters: - description: Instance slug @@ -6884,6 +7018,15 @@ paths: schema: $ref: "#/components/schemas/EntitlementUsage" description: OK + headers: + Idempotent-Replayed: + schema: + description: "true when the report replays an earlier one sent with the same transactionId: the body is that report's original response and nothing was counted again" + type: string + Kaiten-Metadata-Dropped: + schema: + description: too_large when the report's metadata was above 4 KiB and was not stored; the report itself was counted + type: string "400": content: application/problem+json: @@ -6926,12 +7069,238 @@ paths: schema: $ref: "#/components/schemas/Problem" description: Internal Server Error + "503": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Service Unavailable security: - bearerAuth: - write:instances summary: Report entitlement usage metric for an instance tags: - instances + /instances/{instanceSlug}/entitlements/{entitlementSlug}/usage/reports: + get: + description: "The usage history of one instance and entitlement: every accepted report in the range, with the counter before and after it and the limit in force, in reportSeq order, paged with afterSeq. Decimals are strings. Reports are kept for the organization's usage history retention." + operationId: listUsageReports + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + - description: Entitlement slug + in: path + name: entitlementSlug + required: true + schema: + description: Entitlement slug + examples: + - entitlement-slug + type: string + - description: Start of the range, inclusive (RFC 3339). Defaults to 30 days before to, moved up to the start of the organization's usage history when that is later. An explicit from before it answers 422 ListUsageReports.OutsideRetention. + explode: false + in: query + name: from + schema: + description: Start of the range, inclusive (RFC 3339). Defaults to 30 days before to, moved up to the start of the organization's usage history when that is later. An explicit from before it answers 422 ListUsageReports.OutsideRetention. + format: date-time + type: string + - description: End of the range, exclusive (RFC 3339). Defaults to now. + explode: false + in: query + name: to + schema: + description: End of the range, exclusive (RFC 3339). Defaults to now. + format: date-time + type: string + - description: "Return the reports after this reportSeq: the nextAfterSeq of the previous page" + explode: false + in: query + name: afterSeq + schema: + description: "Return the reports after this reportSeq: the nextAfterSeq of the previous page" + format: int64 + minimum: 0 + type: integer + - description: Maximum number of reports to return (default 100, max 500) + explode: false + in: query + name: limit + schema: + description: Maximum number of reports to return (default 100, max 500) + format: int32 + maximum: 500 + minimum: 1 + type: integer + - description: Only the report sent with this idempotency key + explode: false + in: query + name: transactionId + schema: + description: Only the report sent with this idempotency key + maxLength: 128 + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/UsageReportPage" + description: OK + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:instances + summary: List an entitlement's usage reports for an instance + tags: + - instances + /instances/{instanceSlug}/entitlements/{entitlementSlug}/usage/reports/export: + get: + description: Streams every report of one instance and entitlement in the range as CSV or NDJSON, in reportSeq order. Decimals are written exactly as the journal stores them. + operationId: exportUsageReports + parameters: + - description: Instance slug + in: path + name: instanceSlug + required: true + schema: + description: Instance slug + examples: + - instance-slug + type: string + - description: Entitlement slug + in: path + name: entitlementSlug + required: true + schema: + description: Entitlement slug + examples: + - entitlement-slug + type: string + - description: Start of the range, inclusive (RFC 3339). Defaults to 30 days before to, moved up to the start of the organization's usage history when that is later. An explicit from before it answers 422 ExportUsageReports.OutsideRetention. + explode: false + in: query + name: from + schema: + description: Start of the range, inclusive (RFC 3339). Defaults to 30 days before to, moved up to the start of the organization's usage history when that is later. An explicit from before it answers 422 ExportUsageReports.OutsideRetention. + format: date-time + type: string + - description: End of the range, exclusive (RFC 3339). Defaults to now. At most 366 days after from. + explode: false + in: query + name: to + schema: + description: End of the range, exclusive (RFC 3339). Defaults to now. At most 366 days after from. + format: date-time + type: string + - description: "csv (the default): RFC 4180 with a header row. json: NDJSON, one report per line, in the shape listUsageReports returns. Anything else answers 422 ExportUsageReports.InvalidFormat." + explode: false + in: query + name: format + schema: + description: "csv (the default): RFC 4180 with a header row. json: NDJSON, one report per line, in the shape listUsageReports returns. Anything else answers 422 ExportUsageReports.InvalidFormat." + examples: + - csv + type: string + responses: + "200": + content: + application/x-ndjson: + schema: + type: string + text/csv: + schema: + type: string + description: "The reports, as an attachment: CSV with a header row for format=csv, one JSON object per line for format=json" + headers: + Content-Disposition: + description: attachment, with a file name naming the range + schema: + type: string + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:instances + summary: Export an entitlement's usage reports for an instance + tags: + - instances /instances/{instanceSlug}/integrations/{integrationName}: delete: description: Delete a single integration for an instance @@ -9939,6 +10308,123 @@ paths: summary: Revoke a token for a service account tags: - service-accounts + /usage/reports/export: + get: + description: Streams every usage report of the organization in the range, across instances and entitlements, as CSV or NDJSON, ordered by reportedAt. Filters narrow it to one instance or one entitlement, including deleted ones by ID. Use it to keep the usage history before deleting the organization. + operationId: exportOrganizationUsageReports + parameters: + - description: Start of the range, inclusive (RFC 3339). Defaults to 30 days before to, moved up to the start of the organization's usage history when that is later. An explicit from before it answers 422 ExportUsageReports.OutsideRetention. + explode: false + in: query + name: from + schema: + description: Start of the range, inclusive (RFC 3339). Defaults to 30 days before to, moved up to the start of the organization's usage history when that is later. An explicit from before it answers 422 ExportUsageReports.OutsideRetention. + format: date-time + type: string + - description: End of the range, exclusive (RFC 3339). Defaults to now. At most 31 days after from. + explode: false + in: query + name: to + schema: + description: End of the range, exclusive (RFC 3339). Defaults to now. At most 31 days after from. + format: date-time + type: string + - description: "csv (the default): RFC 4180 with a header row. json: NDJSON, one report per line. Anything else answers 422 ExportUsageReports.InvalidFormat." + explode: false + in: query + name: format + schema: + description: "csv (the default): RFC 4180 with a header row. json: NDJSON, one report per line. Anything else answers 422 ExportUsageReports.InvalidFormat." + examples: + - csv + type: string + - description: Only this instance's reports + explode: false + in: query + name: instanceSlug + schema: + description: Only this instance's reports + type: string + - description: Only this instance's reports. Reaches a deleted instance, whose reports are kept. + explode: false + in: query + name: instanceId + schema: + description: Only this instance's reports. Reaches a deleted instance, whose reports are kept. + format: uuid + type: string + - description: Only this entitlement's reports + explode: false + in: query + name: entitlementSlug + schema: + description: Only this entitlement's reports + type: string + - description: Only this entitlement's reports. Reaches a deleted entitlement, whose reports are kept. + explode: false + in: query + name: entitlementId + schema: + description: Only this entitlement's reports. Reaches a deleted entitlement, whose reports are kept. + format: uuid + type: string + responses: + "200": + content: + application/x-ndjson: + schema: + type: string + text/csv: + schema: + type: string + description: "The reports, as an attachment: CSV with a header row for format=csv, one JSON object per line for format=json" + headers: + Content-Disposition: + description: attachment, with a file name naming the range + schema: + type: string + "400": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Bad Request + "401": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unauthorized + "403": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Forbidden + "404": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Not Found + "422": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Unprocessable Entity + "500": + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + description: Internal Server Error + security: + - bearerAuth: + - read:instances + summary: Export the organization's usage reports + tags: + - instances /v1/notification-preferences: get: description: "Every notifiable event, with this user's effective value per channel: their own choice where they made one, the catalogue default otherwise. Events absent from the catalogue of a deployment never appear, which is what keeps high-volume system events out of the feed." diff --git a/packages/client/README.md b/packages/client/README.md index c7ff53b..0736cca 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -75,9 +75,44 @@ const snapshot = await kaiten.components.getSnapshot("acme-corp"); - `kaiten.components` — embedded monetization: `getCatalog()`, `getSnapshot(customerId, instanceId?)` - `kaiten.customers` / `kaiten.instances` / `kaiten.licenses` — curated reads -- `kaiten.usage` — entitlement usage (read + report) +- `kaiten.usage` — entitlement usage (read + report, `reportDetailed`, `history`) - `kaiten.flags` — feature-flag evaluation (`evaluate`, `evaluateAll`) +### Reporting usage safely + +A report without a key is sent once and never retried: if its request fails, it may or +may not have been counted, and sending it again could count it twice. Give it a +`transactionId` and the API applies it at most once (per instance, entitlement and key, +within 35 days by default), so the client retries it on network errors and 5xx, every +attempt under the same key. + +```ts +import { TransactionIdReusedError } from "@kaitencloud/client"; + +try { + const { usage, replayed } = await kaiten.usage.reportDetailed("acme-production", "tokens", { + value: { type: "number", value: 1200 }, + behavior: "append", + transactionId: "llm-call:9f2c:tokens", // a UUID, or the business event id plus the meter + }); + // replayed: the API had already counted this report, and `usage` is its original answer. +} catch (error) { + if (error instanceof TransactionIdReusedError) { + // The key was already used for a different report: a bug in how keys are made, + // not retryable. A correction is a new report under a new key. + log.error({ original: error.errors?.[0]?.value }, "usage report key reused"); + } + throw error; +} +``` + +An API older than `transactionId` refuses the field: the report is then resent without it, +and the client stops sending keys for its lifetime, warning once. + +`kaiten.usage.history(instanceSlug, entitlementSlug, { from, to, transactionId, limit })` +returns the accepted reports, oldest first, with the counter before and after each one and +the limit it was gated on. It needs `read:instances`, so it belongs on a server. + ## Testing without an API — `@kaitencloud/client/testing` Building or testing a Kaiten integration used to need a Kaiten account and a diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 9cefd27..46d4ce9 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -12,8 +12,10 @@ import { getLicenseEntitlements, getLicenses, listCustomers, + listUsageReports, reportEntitlementUsageMetric, } from "../core/index.ts"; +import { KaitenError } from "../core/index.ts"; import type { KaitenClientConfig } from "../core/index.ts"; import { defaultActions, @@ -44,8 +46,11 @@ import type { LicensingSnapshot, Plan, ReportUsageInput, + ReportUsageResult, ResolvedEntitlement, ResolvedFeatureFlag, + UsageHistoryOptions, + UsageReport, } from "./types.ts"; interface GraphQLEnvelope { @@ -117,6 +122,18 @@ export interface KaitenUsageModule { entitlementSlug: string, input: ReportUsageInput, ) => Promise; + /** `report`, with whether the report was a replay and whether its metadata was kept. */ + reportDetailed: ( + instanceSlug: string, + entitlementSlug: string, + input: ReportUsageInput, + ) => Promise; + /** The accepted reports of one instance and entitlement, oldest first. */ + history: ( + instanceSlug: string, + entitlementSlug: string, + options?: UsageHistoryOptions, + ) => Promise; getByGroup: ( groupSlug: string, instanceSlug: string, @@ -338,6 +355,8 @@ export class KaitenClient implements KaitenClientLike { public readonly licenses: KaitenLicensesModule; public readonly usage: KaitenUsageModule; public readonly flags: KaitenFlagsModule; + /** Set once an API refuses usage report keys: later reports go out without them. */ + private transactionIdsUnsupported = false; constructor(config: KaitenClientConfig) { // No default scheme: publishable keys are not served yet, and a default that @@ -380,6 +399,10 @@ export class KaitenClient implements KaitenClientLike { this.getInstanceEntitlementUsage(instanceSlug, entitlementSlug), report: (instanceSlug, entitlementSlug, input) => this.reportUsage(instanceSlug, entitlementSlug, input), + reportDetailed: (instanceSlug, entitlementSlug, input) => + this.reportUsageDetailed(instanceSlug, entitlementSlug, input), + history: (instanceSlug, entitlementSlug, options) => + this.listUsageReports(instanceSlug, entitlementSlug, options), getByGroup: (groupSlug, instanceSlug) => this.getEntitlementGroupUsage(groupSlug, instanceSlug), }; @@ -1064,24 +1087,112 @@ export class KaitenClient implements KaitenClientLike { ); } - reportUsage( + /** + * Report usage of one entitlement for one instance, and return the resulting + * usage. + * + * Without `transactionId` the report is sent once: a report that timed out may + * or may not have been counted, and sending it again could count it twice. + * With one, the API applies it at most once, so it is retried on network + * errors and 5xx under the same key. A key already used for a different report + * throws {@link TransactionIdReusedError}, never retried. + */ + async reportUsage( instanceSlug: string, entitlementSlug: string, input: ReportUsageInput, ): Promise { - return this.transport.run( - (client) => - reportEntitlementUsageMetric({ - client, - path: { instanceSlug, entitlementSlug }, - body: input, - }), - `reportUsage(${instanceSlug}, ${entitlementSlug})`, - // Never replayed. The endpoint carries no idempotency key, so a retry - // after a timeout that the server had already committed would count the - // same consumption twice — and this counter is the billing base. - { idempotent: false }, - ); + const { usage } = await this.reportUsageDetailed(instanceSlug, entitlementSlug, input); + return usage; + } + + /** + * {@link reportUsage}, also returning what the API said about the report: + * whether it replayed an earlier report with the same key, and whether its + * metadata was too large to store. + * + * An API older than `transactionId` refuses the field. The report is then sent + * again without it, and this client stops sending keys (and so retrying + * reports) for its lifetime, warning once. + */ + async reportUsageDetailed( + instanceSlug: string, + entitlementSlug: string, + input: ReportUsageInput, + ): Promise { + const key = input.transactionId; + if (key !== undefined && !TRANSACTION_ID.test(key)) { + throw new TypeError( + `Kaiten transactionId ${JSON.stringify(key)} must be 1 to 128 characters of letters, digits, '.', '_', ':' and '-'.`, + ); + } + + const { transactionId: _omitted, ...withoutKey } = input; + const send = (body: ReportUsageInput) => + this.transport.runWithResponse( + (client) => + reportEntitlementUsageMetric({ + client, + path: { instanceSlug, entitlementSlug }, + body, + }), + `reportUsage(${instanceSlug}, ${entitlementSlug})`, + // Replayed only with a key: without one, a retry after a timeout the + // server had already committed would count the same consumption twice + // -- and this counter is the billing base. + { idempotent: body.transactionId !== undefined }, + ); + + const keyed = key !== undefined && !this.transactionIdsUnsupported; + try { + return toReportUsageResult(await send(keyed ? input : withoutKey)); + } catch (error) { + if (!keyed || !refusesTransactionId(error)) throw error; + this.transactionIdsUnsupported = true; + console.warn( + "Kaiten: this API does not support usage report transactionId; reports are sent " + + "without it and are not retried for the rest of this client's life. Upgrade Kaiten " + + "to make retries safe.", + ); + return toReportUsageResult(await send(withoutKey)); + } + } + + /** + * The usage history of one entitlement for one instance: every report the API + * accepted in the range, oldest first, up to `options.limit`. It follows the + * API's pages itself. + */ + async listUsageReports( + instanceSlug: string, + entitlementSlug: string, + options: UsageHistoryOptions = {}, + ): Promise { + const reports: UsageReport[] = []; + let afterSeq: number | undefined; + for (;;) { + const remaining = + options.limit === undefined ? USAGE_HISTORY_PAGE : options.limit - reports.length; + const page = await this.transport.run( + (client) => + listUsageReports({ + client, + path: { instanceSlug, entitlementSlug }, + query: { + from: toTimestamp(options.from), + to: toTimestamp(options.to), + transactionId: options.transactionId, + afterSeq, + limit: Math.min(USAGE_HISTORY_PAGE, remaining), + }, + }), + `listUsageReports(${instanceSlug}, ${entitlementSlug})`, + ); + reports.push(...page.items); + const done = options.limit !== undefined && reports.length >= options.limit; + if (page.nextAfterSeq === undefined || page.nextAfterSeq === null || done) return reports; + afterSeq = page.nextAfterSeq; + } } getEntitlementGroupUsage( @@ -1126,3 +1237,41 @@ export class KaitenClient implements KaitenClientLike { }; } } + +/** The format the API accepts for a usage report `transactionId`. */ +const TRANSACTION_ID = /^[A-Za-z0-9._:-]{1,128}$/; + +/** The largest page the usage history endpoint serves. */ +const USAGE_HISTORY_PAGE = 500; + +const INVALID_TRANSACTION_ID = "ReportEntitlementUsageMetric.InvalidTransactionId"; + +function toReportUsageResult({ + data, + response, +}: { + data: EntitlementUsage; + response?: Response; +}): ReportUsageResult { + return { + usage: data, + replayed: response?.headers.get("Idempotent-Replayed") === "true", + metadataDropped: Boolean(response?.headers.get("Kaiten-Metadata-Dropped")), + }; +} + +/** + * Whether `error` is an API without `transactionId` refusing the field: a + * validation problem located on it, from an API that does not know the code + * newer ones answer a malformed key with. The key was checked before sending. + */ +function refusesTransactionId(error: unknown): boolean { + if (!(error instanceof KaitenError)) return false; + if (error.status !== 422 && error.status !== 400) return false; + if (error.code === INVALID_TRANSACTION_ID) return false; + return (error.errors ?? []).some((detail) => detail.location === "body.transactionId"); +} + +function toTimestamp(value: Date | string | undefined): string | undefined { + return value instanceof Date ? value.toISOString() : value; +} diff --git a/packages/client/src/client/types.ts b/packages/client/src/client/types.ts index cc1f697..4f3f996 100644 --- a/packages/client/src/client/types.ts +++ b/packages/client/src/client/types.ts @@ -6,6 +6,7 @@ export type { EvaluationFailure, EvaluationSuccess, NumberEntitlementValue, + UsageReport, User, } from "../core/index.ts"; @@ -19,6 +20,7 @@ export type { EntitlementGroupUsage as EntitlementGroupUsageItem } from "../core import type { BooleanEntitlementValue, ConfigEntitlementValue, + EntitlementUsage, EvaluationFailure, EvaluationSuccess, NumberEntitlementValue, @@ -215,13 +217,58 @@ export interface LicenseEntitlementRow { * and for a `LICENSE_START` anchor, from the instance's own license start date — * so a caller-supplied clock has nothing to say about which window a report * belongs to. `ReportEntitlementUsageBody` declares `additionalProperties: false` - * over `value`, `behavior` and `metadata`, so sending one is a rejected request, - * not an ignored field. + * over `value`, `behavior`, `metadata` and `transactionId`, so sending one is a + * rejected request, not an ignored field. */ export interface ReportUsageInput { value: EntitlementValue; behavior: UsageBehavior; + /** + * Stored with the report in the usage history when its compact JSON encoding + * is at most 4 KiB; above that the report still counts and + * {@link ReportUsageResult.metadataDropped} is set. It must contain no personal + * data. + */ metadata?: Record; + /** + * Makes the report idempotent: the API applies a report at most once per key, + * instance and entitlement within its idempotency window (35 days by default), + * and answers a repeat with the original result. Use a UUID, or a business + * event id plus the meter (`llm-call:9f2c:tokens`). A report with a key is + * retried on network errors and 5xx; one without is sent once. 1 to 128 + * characters of letters, digits, `.`, `_`, `:` and `-`. + */ + transactionId?: string; +} + +/** What an accepted usage report answers. */ +export interface ReportUsageResult { + /** The entitlement's usage after the report. */ + usage: EntitlementUsage; + /** + * The report carried a `transactionId` the API had already accepted: `usage` + * is that report's original answer and nothing was counted again. It may + * describe a window that has since closed; do not read it as the current gauge. + */ + replayed: boolean; + /** The metadata was above 4 KiB and was not stored. The report was counted. */ + metadataDropped: boolean; +} + +/** Narrows {@link KaitenUsageModule.history}. */ +export interface UsageHistoryOptions { + /** + * Start of the range, inclusive. Defaults to 30 days before `to`; a `from` + * earlier than the start of the organization's usage history is refused with + * the code `ListUsageReports.OutsideRetention`. + */ + from?: Date | string; + /** End of the range, exclusive. Defaults to now. */ + to?: Date | string; + /** Only the report sent with this key. */ + transactionId?: string; + /** Fetch at most this many reports; every report in the range when absent. */ + limit?: number; } // --------------------------------------------------------------------------- diff --git a/packages/client/src/core/generated/index.ts b/packages/client/src/core/generated/index.ts index 3f0f167..1ace4d9 100644 --- a/packages/client/src/core/generated/index.ts +++ b/packages/client/src/core/generated/index.ts @@ -38,6 +38,8 @@ export { dryRunMetadataField, evaluateFlag, evaluateFlagsBulk, + exportOrganizationUsageReports, + exportUsageReports, getAuditTrails, getComponent, getConnector, @@ -82,6 +84,7 @@ export { listMetadataFields, listNotifications, listReleases, + listUsageReports, markNotificationsRead, type Options, patchInstance, @@ -336,6 +339,16 @@ export type { EvaluationFailure, EvaluationRequest, EvaluationSuccess, + ExportOrganizationUsageReportsData, + ExportOrganizationUsageReportsError, + ExportOrganizationUsageReportsErrors, + ExportOrganizationUsageReportsResponse, + ExportOrganizationUsageReportsResponses, + ExportUsageReportsData, + ExportUsageReportsError, + ExportUsageReportsErrors, + ExportUsageReportsResponse, + ExportUsageReportsResponses, FeatureFlag, FeatureFlagEvaluated, FeatureFlagWritable, @@ -582,6 +595,11 @@ export type { ListReleasesErrors, ListReleasesResponse, ListReleasesResponses, + ListUsageReportsData, + ListUsageReportsError, + ListUsageReportsErrors, + ListUsageReportsResponse, + ListUsageReportsResponses, ManifestEnvelope, ManifestFlag, MarkNotificationsReadData, @@ -893,6 +911,8 @@ export type { UpsertInstanceIntegrationByExternalIdErrors, UpsertInstanceIntegrationByExternalIdResponse, UpsertInstanceIntegrationByExternalIdResponses, + UsageReport, + UsageReportPage, User, Variant, Webhooks, diff --git a/packages/client/src/core/generated/sdk.gen.ts b/packages/client/src/core/generated/sdk.gen.ts index 269ff31..9c963b2 100644 --- a/packages/client/src/core/generated/sdk.gen.ts +++ b/packages/client/src/core/generated/sdk.gen.ts @@ -120,6 +120,12 @@ import type { EvaluateFlagsBulkData, EvaluateFlagsBulkErrors, EvaluateFlagsBulkResponses, + ExportOrganizationUsageReportsData, + ExportOrganizationUsageReportsErrors, + ExportOrganizationUsageReportsResponses, + ExportUsageReportsData, + ExportUsageReportsErrors, + ExportUsageReportsResponses, GetAuditTrailsData, GetAuditTrailsErrors, GetAuditTrailsResponses, @@ -252,6 +258,9 @@ import type { ListReleasesData, ListReleasesErrors, ListReleasesResponses, + ListUsageReportsData, + ListUsageReportsErrors, + ListUsageReportsResponses, MarkNotificationsReadData, MarkNotificationsReadErrors, MarkNotificationsReadResponses, @@ -1409,7 +1418,7 @@ export const getEntitlementUsageMetrics = /** * Report entitlement usage metric for an instance * - * Report a usage metric for a specific entitlement in a given instance. This endpoint allows you to report the usage of an entitlement, including optional metadata and a timestamp. + * Report a usage metric for a specific entitlement in a given instance, with optional metadata. The server dates every report on receipt; the request carries no timestamp. Send a transactionId to make retries safe: without one, a report sent twice counts twice. */ export const reportEntitlementUsageMetric = ( options: Options, @@ -1432,6 +1441,38 @@ export const reportEntitlementUsageMetric = ( + options: Options, +): RequestResult => + (options.client ?? client).get({ + security: [{ scheme: "bearer", type: "http" }], + url: "/instances/{instanceSlug}/entitlements/{entitlementSlug}/usage/reports", + ...options, + }); + +/** + * Export an entitlement's usage reports for an instance + * + * Streams every report of one instance and entitlement in the range as CSV or NDJSON, in reportSeq order. Decimals are written exactly as the journal stores them. + */ +export const exportUsageReports = ( + options: Options, +): RequestResult => + (options.client ?? client).get< + ExportUsageReportsResponses, + ExportUsageReportsErrors, + ThrowOnError + >({ + security: [{ scheme: "bearer", type: "http" }], + url: "/instances/{instanceSlug}/entitlements/{entitlementSlug}/usage/reports/export", + ...options, + }); + /** * Delete an instance integration * @@ -2286,6 +2327,28 @@ export const deleteServiceAccountToken = ( ...options, }); +/** + * Export the organization's usage reports + * + * Streams every usage report of the organization in the range, across instances and entitlements, as CSV or NDJSON, ordered by reportedAt. Filters narrow it to one instance or one entitlement, including deleted ones by ID. Use it to keep the usage history before deleting the organization. + */ +export const exportOrganizationUsageReports = ( + options?: Options, +): RequestResult< + ExportOrganizationUsageReportsResponses, + ExportOrganizationUsageReportsErrors, + ThrowOnError +> => + (options?.client ?? client).get< + ExportOrganizationUsageReportsResponses, + ExportOrganizationUsageReportsErrors, + ThrowOnError + >({ + security: [{ scheme: "bearer", type: "http" }], + url: "/usage/reports/export", + ...options, + }); + /** * Read the caller's notification preferences * diff --git a/packages/client/src/core/generated/types.gen.ts b/packages/client/src/core/generated/types.gen.ts index d06957a..1e85cf1 100644 --- a/packages/client/src/core/generated/types.gen.ts +++ b/packages/client/src/core/generated/types.gen.ts @@ -1848,11 +1848,15 @@ export type ReportEntitlementUsageBody = { */ behavior?: "append" | "set"; /** - * Optional metadata for the usage report + * Optional metadata for the usage report, a JSON object stored with it in the usage history when its compact encoding is at most 4 KiB. Above that it is not stored, the report is still counted, and the response carries Kaiten-Metadata-Dropped: too_large. It must contain no personal data: anyone who can read the organization's instances can read it, for as long as the usage history is kept. Numbers are read as 64-bit floats, so send large identifiers as strings. */ metadata?: { [key: string]: unknown; }; + /** + * Optional idempotency key, 1 to 128 characters of [A-Za-z0-9._:-], matched exactly and case-sensitively. A report sent again with the same key and the same behavior and value within KAITEN_USAGE_IDEMPOTENCY_WINDOW (35 days by default) is applied once: the retry answers 200 with the original response and the Idempotent-Replayed header, and changes nothing. The same key with another behavior or value answers 409 ReportEntitlementUsageMetric.TransactionIdReused. A rejected report does not consume its key. Scoped to the instance and entitlement: one business event may feed two meters under one key. + */ + transactionId?: string; /** * Reported entitlement value, discriminated by the 'type' field. Usage reporting accepts the number variant only. */ @@ -2102,6 +2106,98 @@ export type Token = { slug?: string; }; +export type UsageReport = { + /** + * The entitlement's aggregation method when the report was accepted + */ + aggregationMethod: string; + /** + * append adds the value through the aggregation method; set overwrites the counter + */ + behavior: "append" | "set"; + /** + * valueAfter minus valueBefore; negative for a set that lowered the counter + */ + delta: string; + /** + * The entitlement the report was made for. It may since have been deleted. + */ + entitlementId: string; + /** + * Reports counted in the window after this one + */ + eventCountAfter: number; + /** + * The instance the report was made for. It may since have been deleted. + */ + instanceId: string; + /** + * The instance's licence when the report was accepted + */ + licenseId: string; + /** + * The limit in force when the report was accepted. Null when unlimited. + */ + limitValue?: string; + /** + * How much the usage above limitValue moved: max(0, valueAfter - limitValue) - max(0, valueBefore - limitValue). 0 when unlimited. + */ + overageDelta: string; + /** + * The overage allowed above limitValue, in percent, when the report was accepted. Null when unlimited. + */ + overagePercent?: number; + /** + * The report's metadata, when it was stored + */ + properties?: { + [key: string]: unknown; + }; + /** + * Position of the report among the pair's accepted reports, from 1, without gaps + */ + reportSeq: number; + /** + * When the server accepted the report (UTC, millisecond precision) + */ + reportedAt: string; + /** + * The value as sent: a delta for append, an absolute value for set + */ + reportedValue: string; + /** + * The report's idempotency key, when it was sent with one + */ + transactionId?: string; + /** + * The counter after the report + */ + valueAfter: string; + /** + * The counter before the report, after any window reset + */ + valueBefore: string; + /** + * End of the usage window the report counted in (exclusive). Null for a lifetime entitlement. + */ + windowEnd?: string; + /** + * Start of the usage window the report counted in (inclusive). Null for a lifetime entitlement. + */ + windowStart?: string; +}; + +export type UsageReportPage = { + /** + * The reports, in reportSeq order + */ + items: Array; + /** + * Pass as afterSeq to read the next page. Absent on the last page. + */ + nextAfterSeq?: number; +}; + export type User = { /** * ID of the user @@ -2866,11 +2962,15 @@ export type ReportEntitlementUsageBodyWritable = { */ behavior?: "append" | "set"; /** - * Optional metadata for the usage report + * Optional metadata for the usage report, a JSON object stored with it in the usage history when its compact encoding is at most 4 KiB. Above that it is not stored, the report is still counted, and the response carries Kaiten-Metadata-Dropped: too_large. It must contain no personal data: anyone who can read the organization's instances can read it, for as long as the usage history is kept. Numbers are read as 64-bit floats, so send large identifiers as strings. */ metadata?: { [key: string]: unknown; }; + /** + * Optional idempotency key, 1 to 128 characters of [A-Za-z0-9._:-], matched exactly and case-sensitively. A report sent again with the same key and the same behavior and value within KAITEN_USAGE_IDEMPOTENCY_WINDOW (35 days by default) is applied once: the retry answers 200 with the original response and the Idempotent-Replayed header, and changes nothing. The same key with another behavior or value answers 409 ReportEntitlementUsageMetric.TransactionIdReused. A rejected report does not consume its key. Scoped to the instance and entitlement: one business event may feed two meters under one key. + */ + transactionId?: string; /** * Reported entitlement value, discriminated by the 'type' field. Usage reporting accepts the number variant only. */ @@ -5859,6 +5959,10 @@ export type ReportEntitlementUsageMetricErrors = { * Internal Server Error */ 500: Problem; + /** + * Service Unavailable + */ + 503: Problem; }; export type ReportEntitlementUsageMetricError = @@ -5874,6 +5978,149 @@ export type ReportEntitlementUsageMetricResponses = { export type ReportEntitlementUsageMetricResponse = ReportEntitlementUsageMetricResponses[keyof ReportEntitlementUsageMetricResponses]; +export type ListUsageReportsData = { + body?: never; + path: { + /** + * Instance slug + */ + instanceSlug: string; + /** + * Entitlement slug + */ + entitlementSlug: string; + }; + query?: { + /** + * Start of the range, inclusive (RFC 3339). Defaults to 30 days before to, moved up to the start of the organization's usage history when that is later. An explicit from before it answers 422 ListUsageReports.OutsideRetention. + */ + from?: string; + /** + * End of the range, exclusive (RFC 3339). Defaults to now. + */ + to?: string; + /** + * Return the reports after this reportSeq: the nextAfterSeq of the previous page + */ + afterSeq?: number; + /** + * Maximum number of reports to return (default 100, max 500) + */ + limit?: number; + /** + * Only the report sent with this idempotency key + */ + transactionId?: string; + }; + url: "/instances/{instanceSlug}/entitlements/{entitlementSlug}/usage/reports"; +}; + +export type ListUsageReportsErrors = { + /** + * Bad Request + */ + 400: Problem; + /** + * Unauthorized + */ + 401: Problem; + /** + * Forbidden + */ + 403: Problem; + /** + * Not Found + */ + 404: Problem; + /** + * Unprocessable Entity + */ + 422: Problem; + /** + * Internal Server Error + */ + 500: Problem; +}; + +export type ListUsageReportsError = ListUsageReportsErrors[keyof ListUsageReportsErrors]; + +export type ListUsageReportsResponses = { + /** + * OK + */ + 200: UsageReportPage; +}; + +export type ListUsageReportsResponse = ListUsageReportsResponses[keyof ListUsageReportsResponses]; + +export type ExportUsageReportsData = { + body?: never; + path: { + /** + * Instance slug + */ + instanceSlug: string; + /** + * Entitlement slug + */ + entitlementSlug: string; + }; + query?: { + /** + * Start of the range, inclusive (RFC 3339). Defaults to 30 days before to, moved up to the start of the organization's usage history when that is later. An explicit from before it answers 422 ExportUsageReports.OutsideRetention. + */ + from?: string; + /** + * End of the range, exclusive (RFC 3339). Defaults to now. At most 366 days after from. + */ + to?: string; + /** + * csv (the default): RFC 4180 with a header row. json: NDJSON, one report per line, in the shape listUsageReports returns. Anything else answers 422 ExportUsageReports.InvalidFormat. + */ + format?: string; + }; + url: "/instances/{instanceSlug}/entitlements/{entitlementSlug}/usage/reports/export"; +}; + +export type ExportUsageReportsErrors = { + /** + * Bad Request + */ + 400: Problem; + /** + * Unauthorized + */ + 401: Problem; + /** + * Forbidden + */ + 403: Problem; + /** + * Not Found + */ + 404: Problem; + /** + * Unprocessable Entity + */ + 422: Problem; + /** + * Internal Server Error + */ + 500: Problem; +}; + +export type ExportUsageReportsError = ExportUsageReportsErrors[keyof ExportUsageReportsErrors]; + +export type ExportUsageReportsResponses = { + /** + * The reports, as an attachment: CSV with a header row for format=csv, one JSON object per line for format=json + */ + 200: string; +}; + +export type ExportUsageReportsResponse = + ExportUsageReportsResponses[keyof ExportUsageReportsResponses]; + export type DeleteInstanceIntegrationData = { body?: never; path: { @@ -8216,6 +8463,82 @@ export type DeleteServiceAccountTokenResponses = { export type DeleteServiceAccountTokenResponse = DeleteServiceAccountTokenResponses[keyof DeleteServiceAccountTokenResponses]; +export type ExportOrganizationUsageReportsData = { + body?: never; + path?: never; + query?: { + /** + * Start of the range, inclusive (RFC 3339). Defaults to 30 days before to, moved up to the start of the organization's usage history when that is later. An explicit from before it answers 422 ExportUsageReports.OutsideRetention. + */ + from?: string; + /** + * End of the range, exclusive (RFC 3339). Defaults to now. At most 31 days after from. + */ + to?: string; + /** + * csv (the default): RFC 4180 with a header row. json: NDJSON, one report per line. Anything else answers 422 ExportUsageReports.InvalidFormat. + */ + format?: string; + /** + * Only this instance's reports + */ + instanceSlug?: string; + /** + * Only this instance's reports. Reaches a deleted instance, whose reports are kept. + */ + instanceId?: string; + /** + * Only this entitlement's reports + */ + entitlementSlug?: string; + /** + * Only this entitlement's reports. Reaches a deleted entitlement, whose reports are kept. + */ + entitlementId?: string; + }; + url: "/usage/reports/export"; +}; + +export type ExportOrganizationUsageReportsErrors = { + /** + * Bad Request + */ + 400: Problem; + /** + * Unauthorized + */ + 401: Problem; + /** + * Forbidden + */ + 403: Problem; + /** + * Not Found + */ + 404: Problem; + /** + * Unprocessable Entity + */ + 422: Problem; + /** + * Internal Server Error + */ + 500: Problem; +}; + +export type ExportOrganizationUsageReportsError = + ExportOrganizationUsageReportsErrors[keyof ExportOrganizationUsageReportsErrors]; + +export type ExportOrganizationUsageReportsResponses = { + /** + * The reports, as an attachment: CSV with a header row for format=csv, one JSON object per line for format=json + */ + 200: string; +}; + +export type ExportOrganizationUsageReportsResponse = + ExportOrganizationUsageReportsResponses[keyof ExportOrganizationUsageReportsResponses]; + export type GetNotificationPreferencesData = { body?: never; path?: never; diff --git a/packages/client/src/core/index.ts b/packages/client/src/core/index.ts index 4039edd..9389005 100644 --- a/packages/client/src/core/index.ts +++ b/packages/client/src/core/index.ts @@ -6,7 +6,7 @@ export * from "./generated/index.ts"; export { client } from "./generated/client.gen.ts"; export type { CreateClientConfig } from "./generated/client.gen.ts"; -export { KaitenError, KaitenNetworkError } from "./runtime/errors.ts"; +export { KaitenError, KaitenNetworkError, TransactionIdReusedError } from "./runtime/errors.ts"; export type { KaitenApiError, KaitenThrownError } from "./runtime/errors.ts"; export { assertPublishableKey, isPublishableKey } from "./runtime/keys.ts"; export { retry } from "./runtime/retry.ts"; diff --git a/packages/client/src/core/runtime/errors.ts b/packages/client/src/core/runtime/errors.ts index 8ab12e1..4fb8771 100644 --- a/packages/client/src/core/runtime/errors.ts +++ b/packages/client/src/core/runtime/errors.ts @@ -47,6 +47,23 @@ export class KaitenError extends Error { } } +/** + * Thrown by a usage report whose `transactionId` was already used, within the + * API's idempotency window, for a report with another behavior or value. It is + * a bug in how keys are made, not a transient failure: the same report under + * the same key fails the same way, so it is never retried. Send a correction as + * a new report under a new key. + */ +export class TransactionIdReusedError extends KaitenError { + constructor(apiError: KaitenApiError) { + super(apiError); + this.name = "TransactionIdReusedError"; + } +} + +/** The problem code the API answers a reused usage report key with. */ +export const TRANSACTION_ID_REUSED = "ReportEntitlementUsageMetric.TransactionIdReused"; + /** * Thrown when the request never reached the API (offline, DNS, CORS, abort) — * there is no HTTP status. Narrow with `instanceof KaitenNetworkError` or the diff --git a/packages/client/src/core/transport.ts b/packages/client/src/core/transport.ts index aecd947..dc8e122 100644 --- a/packages/client/src/core/transport.ts +++ b/packages/client/src/core/transport.ts @@ -1,6 +1,12 @@ import { createClient, createConfig } from "./generated/client/index.ts"; import type { Client } from "./generated/client/index.ts"; -import { KaitenError, KaitenNetworkError, type KaitenApiError } from "./runtime/errors.ts"; +import { + KaitenError, + KaitenNetworkError, + TRANSACTION_ID_REUSED, + TransactionIdReusedError, + type KaitenApiError, +} from "./runtime/errors.ts"; import { retry } from "./runtime/retry.ts"; import { isBrowserRuntime, secretKeyPrefix } from "./runtime/keys.ts"; import type { KaitenAuthScheme, KaitenClientConfig, KaitenFetch } from "./config.ts"; @@ -173,6 +179,19 @@ export class KaitenTransport { label: string, options: KaitenRunOptions = {}, ): Promise { + const { data } = await this.runWithResponse(operation, label, options); + return data; + } + + /** + * {@link run}, also returning the response the data came from, for a caller + * that reads its headers. + */ + async runWithResponse( + operation: (client: Client) => Promise | Record>, + label: string, + options: KaitenRunOptions = {}, + ): Promise<{ data: T; response?: Response }> { // Retrying is only safe when replaying the call cannot change server state // twice. Reads qualify, and so do the OFREP evaluations — they are POSTs // only because the evaluation context travels in a body. @@ -208,10 +227,12 @@ export class KaitenTransport { // credential, and hammering a rejected one just burns rate budget. this.onAuthError?.({ status, operation: label, error: result.error }); } - throw new KaitenError(toKaitenApiError(result.error, status)); + const apiError = toKaitenApiError(result.error, status); + if (apiError.code === TRANSACTION_ID_REUSED) throw new TransactionIdReusedError(apiError); + throw new KaitenError(apiError); } - return result.data as T; + return { data: result.data as T, response: result.response }; }, { retryImmediately: true, @@ -220,8 +241,8 @@ export class KaitenTransport { // the client-side timeout abort, so a backend that committed the write // and then answered slowly would otherwise be asked to commit it again // — on `reportUsage` that is double-counted usage, and usage is the - // billing base. There is no idempotency key on the wire to make the - // replay safe, so the only correct number of attempts is one. + // billing base. A usage report is idempotent only when it carries a + // transactionId, which reportUsage says by passing `idempotent`. if (!idempotent) return false; if (iteration >= 3) return false; if (error instanceof KaitenNetworkError) return true; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 0885aa0..a9ecdf0 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -17,6 +17,7 @@ export { KaitenError, KaitenNetworkError, KaitenTransport, + TransactionIdReusedError, assertPublishableKey, isPublishableKey, retry, diff --git a/packages/client/tests/usage.test.ts b/packages/client/tests/usage.test.ts new file mode 100644 index 0000000..e9b553e --- /dev/null +++ b/packages/client/tests/usage.test.ts @@ -0,0 +1,241 @@ +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; + +import { KaitenClient } from "../src/client/client.ts"; +import { KaitenError, TransactionIdReusedError } from "../src/core/index.ts"; + +type Answer = (request: Request) => Response | Promise; + +const usage = { + entitlementId: "e", + entitlementSlug: "tokens", + licenseId: "l", + value: { type: "number", value: 1200, eventCount: 1 }, + limit: { type: "number", value: 5000 }, +}; + +function json(data: unknown, status = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +} + +function problem(status: number, body: Record): Response { + return new Response(JSON.stringify({ title: "Error", status, ...body }), { + status, + headers: { "Content-Type": "application/problem+json" }, + }); +} + +/** A client whose requests are answered by `answers` in turn, the last one repeating. */ +function givenClient(...answers: Answer[]) { + const requests: Request[] = []; + const bodies: Array> = []; + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + requests.push(request); + const text = await request.clone().text(); + bodies.push(text ? (JSON.parse(text) as Record) : {}); + const answer = answers[Math.min(requests.length - 1, answers.length - 1)]!; + return answer(request); + }); + const client = new KaitenClient({ + apiUrl: "https://api.test", + authScheme: "bearer", + tokenProvider: () => "ksh_test", + fetch, + }); + return { client, requests, bodies }; +} + +const report = { value: { type: "number" as const, value: 1200 }, behavior: "append" as const }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("usage report transactionId", () => { + test("sends the key only when given", async () => { + const keyed = givenClient(() => json(usage)); + await keyed.client.reportUsage("acme", "tokens", { + ...report, + transactionId: "llm-call:9f2c:tokens", + }); + expect(keyed.bodies[0]).toMatchObject({ transactionId: "llm-call:9f2c:tokens" }); + + const plain = givenClient(() => json(usage)); + await plain.client.reportUsage("acme", "tokens", report); + expect(plain.bodies[0]).not.toHaveProperty("transactionId"); + }); + + test("a keyed report is retried on 5xx under the same key; an unkeyed one is not", async () => { + const keyed = givenClient( + () => problem(503, { code: "ReportEntitlementUsageMetric.LedgerUnavailable" }), + () => json(usage), + ); + await keyed.client.reportUsage("acme", "tokens", { ...report, transactionId: "evt-1" }); + expect(keyed.requests).toHaveLength(2); + expect(keyed.bodies.map((body) => body.transactionId)).toEqual(["evt-1", "evt-1"]); + + const plain = givenClient( + () => problem(503, {}), + () => json(usage), + ); + await expect(plain.client.reportUsage("acme", "tokens", report)).rejects.toBeInstanceOf( + KaitenError, + ); + expect(plain.requests).toHaveLength(1); + }); + + test("a reused key throws TransactionIdReusedError, never retried", async () => { + const { client, requests } = givenClient(() => + problem(409, { + code: "ReportEntitlementUsageMetric.TransactionIdReused", + errors: [ + { + location: "body.transactionId", + message: "the report this transactionId was first accepted with", + value: { reportSeq: 2 }, + }, + ], + }), + ); + + const error = await client + .reportUsage("acme", "tokens", { ...report, transactionId: "evt-1" }) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(TransactionIdReusedError); + expect(error).toBeInstanceOf(KaitenError); + expect((error as KaitenError).errors?.[0]?.value).toEqual({ reportSeq: 2 }); + expect(requests).toHaveLength(1); + }); + + test("a malformed key is refused before any request", async () => { + const { client, requests } = givenClient(() => json(usage)); + for (const key of ["has space", "x".repeat(129), "a/b", ""]) { + await expect( + client.reportUsage("acme", "tokens", { ...report, transactionId: key }), + ).rejects.toBeInstanceOf(TypeError); + } + expect(requests).toHaveLength(0); + }); + + test("reportDetailed reads the replay and dropped-metadata headers", async () => { + const { client } = givenClient(() => + json(usage, 200, { "Idempotent-Replayed": "true", "Kaiten-Metadata-Dropped": "too_large" }), + ); + const result = await client.usage.reportDetailed("acme", "tokens", { + ...report, + transactionId: "evt-1", + }); + expect(result).toMatchObject({ replayed: true, metadataDropped: true }); + expect(result.usage.entitlementSlug).toBe("tokens"); + + const plain = givenClient(() => json(usage)); + expect(await plain.client.usage.reportDetailed("acme", "tokens", report)).toMatchObject({ + replayed: false, + metadataDropped: false, + }); + }); + + test("an API without transactionId: the report is resent without it, and keys stop for good", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { client, requests, bodies } = givenClient((request) => + request + .clone() + .text() + .then((text) => + text.includes("transactionId") + ? problem(422, { + code: "UNPROCESSABLE", + errors: [{ location: "body.transactionId", message: "unexpected property" }], + }) + : json(usage), + ), + ); + + await client.reportUsage("acme", "tokens", { ...report, transactionId: "evt-1" }); + expect(requests).toHaveLength(2); + expect(bodies[1]).not.toHaveProperty("transactionId"); + + await client.reportUsage("acme", "tokens", { ...report, transactionId: "evt-2" }); + expect(requests).toHaveLength(3); + expect(bodies[2]).not.toHaveProperty("transactionId"); + expect(warn).toHaveBeenCalledTimes(1); + }); + + test("a new API's malformed-key code is not mistaken for an old API", async () => { + const { client, requests } = givenClient(() => + problem(422, { + code: "ReportEntitlementUsageMetric.InvalidTransactionId", + errors: [{ location: "body.transactionId", message: "invalid" }], + }), + ); + await expect( + client.reportUsage("acme", "tokens", { ...report, transactionId: "evt-1" }), + ).rejects.toBeInstanceOf(KaitenError); + expect(requests).toHaveLength(1); + }); +}); + +describe("usage history", () => { + const item = (reportSeq: number) => ({ + reportSeq, + reportedAt: "2026-10-05T08:00:00Z", + behavior: "append", + aggregationMethod: "SUM", + reportedValue: "1", + valueBefore: String(reportSeq - 1), + valueAfter: String(reportSeq), + delta: "1", + overageDelta: "0", + eventCountAfter: reportSeq, + instanceId: "11111111-1111-1111-1111-111111111111", + entitlementId: "22222222-2222-2222-2222-222222222222", + licenseId: "33333333-3333-3333-3333-333333333333", + }); + + test("walks the pages by afterSeq", async () => { + const { client, requests } = givenClient( + () => json({ items: [item(1), item(2)], nextAfterSeq: 2 }), + () => json({ items: [item(3)] }), + ); + + const reports = await client.usage.history("acme", "tokens", { + from: new Date("2026-10-01T00:00:00Z"), + transactionId: "evt-1", + }); + + expect(reports.map((report) => report.reportSeq)).toEqual([1, 2, 3]); + const first = new URL(requests[0]!.url); + const second = new URL(requests[1]!.url); + expect(first.pathname).toMatch(/\/instances\/acme\/entitlements\/tokens\/usage\/reports$/); + expect(first.searchParams.get("from")).toBe("2026-10-01T00:00:00.000Z"); + expect(first.searchParams.get("transactionId")).toBe("evt-1"); + expect(first.searchParams.get("limit")).toBe("500"); + expect(first.searchParams.has("afterSeq")).toBe(false); + expect(second.searchParams.get("afterSeq")).toBe("2"); + }); + + test("stops at the limit", async () => { + const { client, requests } = givenClient(() => + json({ items: [item(1), item(2)], nextAfterSeq: 2 }), + ); + const reports = await client.usage.history("acme", "tokens", { limit: 2 }); + expect(reports).toHaveLength(2); + expect(requests).toHaveLength(1); + expect(new URL(requests[0]!.url).searchParams.get("limit")).toBe("2"); + }); + + test("an out-of-retention range reaches the caller as a KaitenError with its code", async () => { + const { client } = givenClient(() => + problem(422, { code: "ListUsageReports.OutsideRetention" }), + ); + const error = await client.usage + .history("acme", "tokens", { from: "2025-01-01T00:00:00Z" }) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(KaitenError); + expect((error as KaitenError).code).toBe("ListUsageReports.OutsideRetention"); + }); +}); diff --git a/packages/server/README.md b/packages/server/README.md index ef02a67..843eed1 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -83,6 +83,29 @@ conflicts, such as a slug already taken, so the predicate is safe in a shared er That refusal is the single most important response this package can return, and the easiest one to miss. +A usage report sent with a `transactionId` is applied at most once per key, so it is safe to +retry. `isTransactionIdReused` recognises the one refusal a retry cannot fix: the key was +already used for a report with another value (`ReportEntitlementUsageMetric.TransactionIdReused`, +the original report in `error.errors[0].value`). Send a correction as a new report under a +new key. + +The usage history is `Instances.listUsageReports` (paged by `afterSeq`/`nextAfterSeq`), and +its exports are `Instances.exportUsageReports` and `Instances.exportOrganizationUsageReports`. +Pass `parseAs: "stream"` to an export to read it as it arrives instead of buffering it: + +```ts +const { data } = await Instances.exportUsageReports({ + client, + path: { instanceSlug, entitlementSlug: "tokens" }, + query: { format: "csv" }, + parseAs: "stream", +}); +await pipeline( + Readable.fromWeb(data as unknown as ReadableStream), + createWriteStream("tokens.csv"), +); +``` + Every `KaitenError` also carries the problem's `code`, a stable machine-readable identifier such as `License.NotFound`, and its `errorId`, the correlation id of the API's log entry for the failure: quote it when you report a problem. diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts index 5fc6fe0..f280207 100644 --- a/packages/server/src/errors.ts +++ b/packages/server/src/errors.ts @@ -139,3 +139,22 @@ const THRESHOLD_EXCEEDED = "ReportEntitlementUsageMetric.ThresholdExceeded"; export function isThresholdExceeded(error: unknown): error is KaitenError { return error instanceof KaitenError && error.status === 409 && error.code === THRESHOLD_EXCEEDED; } + +/** The code the API returns when a usage report's transactionId was used for another report. */ +const TRANSACTION_ID_REUSED = "ReportEntitlementUsageMetric.TransactionIdReused"; + +/** + * True when the API refused a usage report because its `transactionId` was + * already used, within the idempotency window, for a report with another + * behavior or value: HTTP 409 with the code + * `ReportEntitlementUsageMetric.TransactionIdReused`. + * + * It is a bug in how keys are made, not a transient failure: retrying the same + * report under the same key fails the same way. Send a correction as a new + * report under a new key. The original report is in `error.errors[0].value`. + */ +export function isTransactionIdReused(error: unknown): error is KaitenError { + return ( + error instanceof KaitenError && error.status === 409 && error.code === TRANSACTION_ID_REUSED + ); +} diff --git a/packages/server/src/generated/index.ts b/packages/server/src/generated/index.ts index 30701a0..4c084a3 100644 --- a/packages/server/src/generated/index.ts +++ b/packages/server/src/generated/index.ts @@ -246,6 +246,16 @@ export type { EvaluationFailure, EvaluationRequest, EvaluationSuccess, + ExportOrganizationUsageReportsData, + ExportOrganizationUsageReportsError, + ExportOrganizationUsageReportsErrors, + ExportOrganizationUsageReportsResponse, + ExportOrganizationUsageReportsResponses, + ExportUsageReportsData, + ExportUsageReportsError, + ExportUsageReportsErrors, + ExportUsageReportsResponse, + ExportUsageReportsResponses, FeatureFlag, FeatureFlagEvaluated, FeatureFlagWritable, @@ -492,6 +502,11 @@ export type { ListReleasesErrors, ListReleasesResponse, ListReleasesResponses, + ListUsageReportsData, + ListUsageReportsError, + ListUsageReportsErrors, + ListUsageReportsResponse, + ListUsageReportsResponses, ManifestEnvelope, ManifestFlag, MarkNotificationsReadData, @@ -803,6 +818,8 @@ export type { UpsertInstanceIntegrationByExternalIdErrors, UpsertInstanceIntegrationByExternalIdResponse, UpsertInstanceIntegrationByExternalIdResponses, + UsageReport, + UsageReportPage, User, Variant, Webhooks, diff --git a/packages/server/src/generated/sdk.gen.ts b/packages/server/src/generated/sdk.gen.ts index 7536f1a..d56557b 100644 --- a/packages/server/src/generated/sdk.gen.ts +++ b/packages/server/src/generated/sdk.gen.ts @@ -120,6 +120,12 @@ import type { EvaluateFlagsBulkData, EvaluateFlagsBulkErrors, EvaluateFlagsBulkResponses, + ExportOrganizationUsageReportsData, + ExportOrganizationUsageReportsErrors, + ExportOrganizationUsageReportsResponses, + ExportUsageReportsData, + ExportUsageReportsErrors, + ExportUsageReportsResponses, GetAuditTrailsData, GetAuditTrailsErrors, GetAuditTrailsResponses, @@ -252,6 +258,9 @@ import type { ListReleasesData, ListReleasesErrors, ListReleasesResponses, + ListUsageReportsData, + ListUsageReportsErrors, + ListUsageReportsResponses, MarkNotificationsReadData, MarkNotificationsReadErrors, MarkNotificationsReadResponses, @@ -1637,7 +1646,7 @@ export class Instances { /** * Report entitlement usage metric for an instance * - * Report a usage metric for a specific entitlement in a given instance. This endpoint allows you to report the usage of an entitlement, including optional metadata and a timestamp. + * Report a usage metric for a specific entitlement in a given instance, with optional metadata. The server dates every report on receipt; the request carries no timestamp. Send a transactionId to make retries safe: without one, a report sent twice counts twice. */ public static reportEntitlementUsageMetric( options: Options, @@ -1661,6 +1670,44 @@ export class Instances { }); } + /** + * List an entitlement's usage reports for an instance + * + * The usage history of one instance and entitlement: every accepted report in the range, with the counter before and after it and the limit in force, in reportSeq order, paged with afterSeq. Decimals are strings. Reports are kept for the organization's usage history retention. + */ + public static listUsageReports( + options: Options, + ): RequestResult { + return (options.client ?? client).get< + ListUsageReportsResponses, + ListUsageReportsErrors, + ThrowOnError + >({ + security: [{ scheme: "bearer", type: "http" }], + url: "/instances/{instanceSlug}/entitlements/{entitlementSlug}/usage/reports", + ...options, + }); + } + + /** + * Export an entitlement's usage reports for an instance + * + * Streams every report of one instance and entitlement in the range as CSV or NDJSON, in reportSeq order. Decimals are written exactly as the journal stores them. + */ + public static exportUsageReports( + options: Options, + ): RequestResult { + return (options.client ?? client).get< + ExportUsageReportsResponses, + ExportUsageReportsErrors, + ThrowOnError + >({ + security: [{ scheme: "bearer", type: "http" }], + url: "/instances/{instanceSlug}/entitlements/{entitlementSlug}/usage/reports/export", + ...options, + }); + } + /** * Delete an instance integration * @@ -1806,6 +1853,29 @@ export class Instances { }, }); } + + /** + * Export the organization's usage reports + * + * Streams every usage report of the organization in the range, across instances and entitlements, as CSV or NDJSON, ordered by reportedAt. Filters narrow it to one instance or one entitlement, including deleted ones by ID. Use it to keep the usage history before deleting the organization. + */ + public static exportOrganizationUsageReports( + options?: Options, + ): RequestResult< + ExportOrganizationUsageReportsResponses, + ExportOrganizationUsageReportsErrors, + ThrowOnError + > { + return (options?.client ?? client).get< + ExportOrganizationUsageReportsResponses, + ExportOrganizationUsageReportsErrors, + ThrowOnError + >({ + security: [{ scheme: "bearer", type: "http" }], + url: "/usage/reports/export", + ...options, + }); + } } export class Integrations { diff --git a/packages/server/src/generated/types.gen.ts b/packages/server/src/generated/types.gen.ts index d06957a..1e85cf1 100644 --- a/packages/server/src/generated/types.gen.ts +++ b/packages/server/src/generated/types.gen.ts @@ -1848,11 +1848,15 @@ export type ReportEntitlementUsageBody = { */ behavior?: "append" | "set"; /** - * Optional metadata for the usage report + * Optional metadata for the usage report, a JSON object stored with it in the usage history when its compact encoding is at most 4 KiB. Above that it is not stored, the report is still counted, and the response carries Kaiten-Metadata-Dropped: too_large. It must contain no personal data: anyone who can read the organization's instances can read it, for as long as the usage history is kept. Numbers are read as 64-bit floats, so send large identifiers as strings. */ metadata?: { [key: string]: unknown; }; + /** + * Optional idempotency key, 1 to 128 characters of [A-Za-z0-9._:-], matched exactly and case-sensitively. A report sent again with the same key and the same behavior and value within KAITEN_USAGE_IDEMPOTENCY_WINDOW (35 days by default) is applied once: the retry answers 200 with the original response and the Idempotent-Replayed header, and changes nothing. The same key with another behavior or value answers 409 ReportEntitlementUsageMetric.TransactionIdReused. A rejected report does not consume its key. Scoped to the instance and entitlement: one business event may feed two meters under one key. + */ + transactionId?: string; /** * Reported entitlement value, discriminated by the 'type' field. Usage reporting accepts the number variant only. */ @@ -2102,6 +2106,98 @@ export type Token = { slug?: string; }; +export type UsageReport = { + /** + * The entitlement's aggregation method when the report was accepted + */ + aggregationMethod: string; + /** + * append adds the value through the aggregation method; set overwrites the counter + */ + behavior: "append" | "set"; + /** + * valueAfter minus valueBefore; negative for a set that lowered the counter + */ + delta: string; + /** + * The entitlement the report was made for. It may since have been deleted. + */ + entitlementId: string; + /** + * Reports counted in the window after this one + */ + eventCountAfter: number; + /** + * The instance the report was made for. It may since have been deleted. + */ + instanceId: string; + /** + * The instance's licence when the report was accepted + */ + licenseId: string; + /** + * The limit in force when the report was accepted. Null when unlimited. + */ + limitValue?: string; + /** + * How much the usage above limitValue moved: max(0, valueAfter - limitValue) - max(0, valueBefore - limitValue). 0 when unlimited. + */ + overageDelta: string; + /** + * The overage allowed above limitValue, in percent, when the report was accepted. Null when unlimited. + */ + overagePercent?: number; + /** + * The report's metadata, when it was stored + */ + properties?: { + [key: string]: unknown; + }; + /** + * Position of the report among the pair's accepted reports, from 1, without gaps + */ + reportSeq: number; + /** + * When the server accepted the report (UTC, millisecond precision) + */ + reportedAt: string; + /** + * The value as sent: a delta for append, an absolute value for set + */ + reportedValue: string; + /** + * The report's idempotency key, when it was sent with one + */ + transactionId?: string; + /** + * The counter after the report + */ + valueAfter: string; + /** + * The counter before the report, after any window reset + */ + valueBefore: string; + /** + * End of the usage window the report counted in (exclusive). Null for a lifetime entitlement. + */ + windowEnd?: string; + /** + * Start of the usage window the report counted in (inclusive). Null for a lifetime entitlement. + */ + windowStart?: string; +}; + +export type UsageReportPage = { + /** + * The reports, in reportSeq order + */ + items: Array; + /** + * Pass as afterSeq to read the next page. Absent on the last page. + */ + nextAfterSeq?: number; +}; + export type User = { /** * ID of the user @@ -2866,11 +2962,15 @@ export type ReportEntitlementUsageBodyWritable = { */ behavior?: "append" | "set"; /** - * Optional metadata for the usage report + * Optional metadata for the usage report, a JSON object stored with it in the usage history when its compact encoding is at most 4 KiB. Above that it is not stored, the report is still counted, and the response carries Kaiten-Metadata-Dropped: too_large. It must contain no personal data: anyone who can read the organization's instances can read it, for as long as the usage history is kept. Numbers are read as 64-bit floats, so send large identifiers as strings. */ metadata?: { [key: string]: unknown; }; + /** + * Optional idempotency key, 1 to 128 characters of [A-Za-z0-9._:-], matched exactly and case-sensitively. A report sent again with the same key and the same behavior and value within KAITEN_USAGE_IDEMPOTENCY_WINDOW (35 days by default) is applied once: the retry answers 200 with the original response and the Idempotent-Replayed header, and changes nothing. The same key with another behavior or value answers 409 ReportEntitlementUsageMetric.TransactionIdReused. A rejected report does not consume its key. Scoped to the instance and entitlement: one business event may feed two meters under one key. + */ + transactionId?: string; /** * Reported entitlement value, discriminated by the 'type' field. Usage reporting accepts the number variant only. */ @@ -5859,6 +5959,10 @@ export type ReportEntitlementUsageMetricErrors = { * Internal Server Error */ 500: Problem; + /** + * Service Unavailable + */ + 503: Problem; }; export type ReportEntitlementUsageMetricError = @@ -5874,6 +5978,149 @@ export type ReportEntitlementUsageMetricResponses = { export type ReportEntitlementUsageMetricResponse = ReportEntitlementUsageMetricResponses[keyof ReportEntitlementUsageMetricResponses]; +export type ListUsageReportsData = { + body?: never; + path: { + /** + * Instance slug + */ + instanceSlug: string; + /** + * Entitlement slug + */ + entitlementSlug: string; + }; + query?: { + /** + * Start of the range, inclusive (RFC 3339). Defaults to 30 days before to, moved up to the start of the organization's usage history when that is later. An explicit from before it answers 422 ListUsageReports.OutsideRetention. + */ + from?: string; + /** + * End of the range, exclusive (RFC 3339). Defaults to now. + */ + to?: string; + /** + * Return the reports after this reportSeq: the nextAfterSeq of the previous page + */ + afterSeq?: number; + /** + * Maximum number of reports to return (default 100, max 500) + */ + limit?: number; + /** + * Only the report sent with this idempotency key + */ + transactionId?: string; + }; + url: "/instances/{instanceSlug}/entitlements/{entitlementSlug}/usage/reports"; +}; + +export type ListUsageReportsErrors = { + /** + * Bad Request + */ + 400: Problem; + /** + * Unauthorized + */ + 401: Problem; + /** + * Forbidden + */ + 403: Problem; + /** + * Not Found + */ + 404: Problem; + /** + * Unprocessable Entity + */ + 422: Problem; + /** + * Internal Server Error + */ + 500: Problem; +}; + +export type ListUsageReportsError = ListUsageReportsErrors[keyof ListUsageReportsErrors]; + +export type ListUsageReportsResponses = { + /** + * OK + */ + 200: UsageReportPage; +}; + +export type ListUsageReportsResponse = ListUsageReportsResponses[keyof ListUsageReportsResponses]; + +export type ExportUsageReportsData = { + body?: never; + path: { + /** + * Instance slug + */ + instanceSlug: string; + /** + * Entitlement slug + */ + entitlementSlug: string; + }; + query?: { + /** + * Start of the range, inclusive (RFC 3339). Defaults to 30 days before to, moved up to the start of the organization's usage history when that is later. An explicit from before it answers 422 ExportUsageReports.OutsideRetention. + */ + from?: string; + /** + * End of the range, exclusive (RFC 3339). Defaults to now. At most 366 days after from. + */ + to?: string; + /** + * csv (the default): RFC 4180 with a header row. json: NDJSON, one report per line, in the shape listUsageReports returns. Anything else answers 422 ExportUsageReports.InvalidFormat. + */ + format?: string; + }; + url: "/instances/{instanceSlug}/entitlements/{entitlementSlug}/usage/reports/export"; +}; + +export type ExportUsageReportsErrors = { + /** + * Bad Request + */ + 400: Problem; + /** + * Unauthorized + */ + 401: Problem; + /** + * Forbidden + */ + 403: Problem; + /** + * Not Found + */ + 404: Problem; + /** + * Unprocessable Entity + */ + 422: Problem; + /** + * Internal Server Error + */ + 500: Problem; +}; + +export type ExportUsageReportsError = ExportUsageReportsErrors[keyof ExportUsageReportsErrors]; + +export type ExportUsageReportsResponses = { + /** + * The reports, as an attachment: CSV with a header row for format=csv, one JSON object per line for format=json + */ + 200: string; +}; + +export type ExportUsageReportsResponse = + ExportUsageReportsResponses[keyof ExportUsageReportsResponses]; + export type DeleteInstanceIntegrationData = { body?: never; path: { @@ -8216,6 +8463,82 @@ export type DeleteServiceAccountTokenResponses = { export type DeleteServiceAccountTokenResponse = DeleteServiceAccountTokenResponses[keyof DeleteServiceAccountTokenResponses]; +export type ExportOrganizationUsageReportsData = { + body?: never; + path?: never; + query?: { + /** + * Start of the range, inclusive (RFC 3339). Defaults to 30 days before to, moved up to the start of the organization's usage history when that is later. An explicit from before it answers 422 ExportUsageReports.OutsideRetention. + */ + from?: string; + /** + * End of the range, exclusive (RFC 3339). Defaults to now. At most 31 days after from. + */ + to?: string; + /** + * csv (the default): RFC 4180 with a header row. json: NDJSON, one report per line. Anything else answers 422 ExportUsageReports.InvalidFormat. + */ + format?: string; + /** + * Only this instance's reports + */ + instanceSlug?: string; + /** + * Only this instance's reports. Reaches a deleted instance, whose reports are kept. + */ + instanceId?: string; + /** + * Only this entitlement's reports + */ + entitlementSlug?: string; + /** + * Only this entitlement's reports. Reaches a deleted entitlement, whose reports are kept. + */ + entitlementId?: string; + }; + url: "/usage/reports/export"; +}; + +export type ExportOrganizationUsageReportsErrors = { + /** + * Bad Request + */ + 400: Problem; + /** + * Unauthorized + */ + 401: Problem; + /** + * Forbidden + */ + 403: Problem; + /** + * Not Found + */ + 404: Problem; + /** + * Unprocessable Entity + */ + 422: Problem; + /** + * Internal Server Error + */ + 500: Problem; +}; + +export type ExportOrganizationUsageReportsError = + ExportOrganizationUsageReportsErrors[keyof ExportOrganizationUsageReportsErrors]; + +export type ExportOrganizationUsageReportsResponses = { + /** + * The reports, as an attachment: CSV with a header row for format=csv, one JSON object per line for format=json + */ + 200: string; +}; + +export type ExportOrganizationUsageReportsResponse = + ExportOrganizationUsageReportsResponses[keyof ExportOrganizationUsageReportsResponses]; + export type GetNotificationPreferencesData = { body?: never; path?: never; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index ac586a7..d9a0e8d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -113,6 +113,7 @@ export { KaitenError, KaitenNetworkError, isThresholdExceeded, + isTransactionIdReused, toKaitenError, type KaitenApiError, type KaitenThrownError, diff --git a/packages/server/tests/index.test.ts b/packages/server/tests/index.test.ts index 0522a2e..b4ce05c 100644 --- a/packages/server/tests/index.test.ts +++ b/packages/server/tests/index.test.ts @@ -6,6 +6,7 @@ import { KaitenError, KaitenNetworkError, isThresholdExceeded, + isTransactionIdReused, } from "../src/index.ts"; const jsonOk = async () => @@ -185,3 +186,99 @@ describe("@kaitencloud/server", () => { }); }); }); + +describe("usage report keys and the usage history", () => { + const problem = (status: number, code: string) => + new Response(JSON.stringify({ title: "Conflict", status, code }), { + status, + headers: { "content-type": "application/problem+json" }, + }); + + test("a usage report sends its transactionId", async () => { + const { seen, fetch } = recordingFetch( + async () => + new Response(JSON.stringify({ entitlementSlug: "seats" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const client = createKaitenClient({ token: "t", baseUrl: "https://example.test", fetch }); + + await Instances.reportEntitlementUsageMetric({ + client, + path: { instanceSlug: "inst-1", entitlementSlug: "seats" }, + body: { value: { type: "number", value: 1 }, behavior: "append", transactionId: "evt-1" }, + }); + + expect(await seen[0]?.json()).toMatchObject({ transactionId: "evt-1" }); + }); + + test("isTransactionIdReused recognises the reused-key conflict, and only it", async () => { + const client = createKaitenClient({ + token: "t", + baseUrl: "https://example.test", + fetch: async () => problem(409, "ReportEntitlementUsageMetric.TransactionIdReused"), + }); + const error = await Instances.reportEntitlementUsageMetric({ + client, + path: { instanceSlug: "inst-1", entitlementSlug: "seats" }, + body: { value: { type: "number", value: 1 }, behavior: "append", transactionId: "evt-1" }, + }).catch((caught: unknown) => caught); + + expect(isTransactionIdReused(error)).toBe(true); + expect(isThresholdExceeded(error)).toBe(false); + expect( + isTransactionIdReused( + new KaitenError({ + title: "Conflict", + status: 409, + code: "ReportEntitlementUsageMetric.ThresholdExceeded", + }), + ), + ).toBe(false); + }); + + test("the usage history is a paged read by afterSeq", async () => { + const { seen, fetch } = recordingFetch( + async () => + new Response(JSON.stringify({ items: [], nextAfterSeq: 2 }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const client = createKaitenClient({ token: "t", baseUrl: "https://example.test", fetch }); + + const { data } = await Instances.listUsageReports({ + client, + path: { instanceSlug: "inst-1", entitlementSlug: "seats" }, + query: { afterSeq: 1, limit: 2, transactionId: "evt-1" }, + }); + + expect(data?.nextAfterSeq).toBe(2); + const url = new URL(seen[0]!.url); + expect(url.pathname).toBe("/api/instances/inst-1/entitlements/seats/usage/reports"); + expect(url.searchParams.get("afterSeq")).toBe("1"); + expect(url.searchParams.get("transactionId")).toBe("evt-1"); + }); + + test("an export streams when asked to", async () => { + const csv = "organization_id,instance_id\no,i\n"; + const client = createKaitenClient({ + token: "t", + baseUrl: "https://example.test", + fetch: async () => + new Response(csv, { status: 200, headers: { "content-type": "text/csv" } }), + }); + + const { data } = await Instances.exportUsageReports({ + client, + path: { instanceSlug: "inst-1", entitlementSlug: "seats" }, + query: { format: "csv" }, + parseAs: "stream", + }); + + expect(data).toBeInstanceOf(ReadableStream); + // Typed as the body the contract declares; parseAs "stream" hands back the stream. + expect(await new Response(data as unknown as ReadableStream).text()).toBe(csv); + }); +});