Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/usage-report-keys-and-history.md
Original file line number Diff line number Diff line change
@@ -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.
490 changes: 488 additions & 2 deletions contracts/openapi.yaml

Large diffs are not rendered by default.

37 changes: 36 additions & 1 deletion packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
177 changes: 163 additions & 14 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -44,8 +46,11 @@ import type {
LicensingSnapshot,
Plan,
ReportUsageInput,
ReportUsageResult,
ResolvedEntitlement,
ResolvedFeatureFlag,
UsageHistoryOptions,
UsageReport,
} from "./types.ts";

interface GraphQLEnvelope<T> {
Expand Down Expand Up @@ -117,6 +122,18 @@ export interface KaitenUsageModule {
entitlementSlug: string,
input: ReportUsageInput,
) => Promise<EntitlementUsage>;
/** `report`, with whether the report was a replay and whether its metadata was kept. */
reportDetailed: (
instanceSlug: string,
entitlementSlug: string,
input: ReportUsageInput,
) => Promise<ReportUsageResult>;
/** The accepted reports of one instance and entitlement, oldest first. */
history: (
instanceSlug: string,
entitlementSlug: string,
options?: UsageHistoryOptions,
) => Promise<UsageReport[]>;
getByGroup: (
groupSlug: string,
instanceSlug: string,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -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<EntitlementUsage> {
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<ReportUsageResult> {
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<EntitlementUsage>(
(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<UsageReport[]> {
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(
Expand Down Expand Up @@ -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;
}
51 changes: 49 additions & 2 deletions packages/client/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type {
EvaluationFailure,
EvaluationSuccess,
NumberEntitlementValue,
UsageReport,
User,
} from "../core/index.ts";

Expand All @@ -19,6 +20,7 @@ export type { EntitlementGroupUsage as EntitlementGroupUsageItem } from "../core
import type {
BooleanEntitlementValue,
ConfigEntitlementValue,
EntitlementUsage,
EvaluationFailure,
EvaluationSuccess,
NumberEntitlementValue,
Expand Down Expand Up @@ -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<string, unknown>;
/**
* 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;
}

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading