From bfcc5d19abdfa6240f4a50fe90ea58a147daba64 Mon Sep 17 00:00:00 2001 From: Tom Ribuot Date: Mon, 5 Oct 2026 22:43:02 +0200 Subject: [PATCH] feat: usage report transaction IDs, usage history and exports Kaiten now makes usage reports idempotent and keeps their history; the Go SDK models both (kaitencloud/sdk-go#1). The CLI follows. - instances usage report --transaction-id: Kaiten applies the report at most once per key, so the SDK retries it on transient failures and running the command again is safe. A replayed report and dropped metadata are noted on stderr; stdout stays the usage document. The flag wins over a key in --file or --payload. - A malformed key exits 2 before any request; a key already used for a different report is a 409 and exits 5. - instances usage history : the accepted reports, with the counter before and after each one, the delta, the limit and the key; --from, --to, --transaction-id, --limit. - instances usage export [ ]: CSV or NDJSON streamed to stdout or --output-file, for one pair (366 days per run) or, without arguments, the whole organization (31 days per run) with --instance, --instance-id, --entitlement and --entitlement-id. Exports run on a client without an overall timeout and outside the command's 15 s deadline; a file is written as .partial and renamed once complete. - github.com/kaitencloud/sdk-go is pinned to the branch of kaitencloud/sdk-go#1 until it is released. Signed-off-by: Tom Ribuot --- README.md | 34 ++++- go.mod | 2 +- go.sum | 4 +- internal/cmd/common.go | 6 +- internal/cmd/exit.go | 6 + internal/cmd/instances.go | 38 +++++- internal/cmd/usage.go | 230 +++++++++++++++++++++++++++++++++ internal/cmd/usage_test.go | 257 +++++++++++++++++++++++++++++++++++++ 8 files changed, 567 insertions(+), 10 deletions(-) create mode 100644 internal/cmd/usage.go create mode 100644 internal/cmd/usage_test.go diff --git a/README.md b/README.md index 2a42aef..882ba8d 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ accepted. | Command | Operations | | -------------------------- | ----------------------------------------------------------------------------------------------- | | `kaiten customers` | `list`, `get`, `create`, `update`, `delete` | -| `kaiten instances` | `list`, `get`, `create`, `update`, `delete`, `audit-trails`, `usage list\|get\|report` | +| `kaiten instances` | `list`, `get`, `create`, `update`, `delete`, `audit-trails`, `usage list\|get\|report\|history\|export` | | `kaiten licenses` | `list`, `get`, `create`, `update`, `delete`, `entitlements list\|get\|associate\|update\|delete` | | `kaiten entitlements` | `list`, `get`, `create`, `update`, `delete` | | `kaiten entitlement-groups`| `list`, `get`, `create`, `update`, `delete`, `add-entitlement`, `remove-entitlement`, `usage` | @@ -156,6 +156,38 @@ kaiten instances usage list acme-production kaiten entitlement-groups usage compute acme-production ``` +A report without a key is sent once and never retried: if it fails in flight, it may +or may not have been counted. Give it a `--transaction-id` and Kaiten applies it at +most once per key, so the CLI retries it on network errors and on 500, 502, 503 and +504, and running the same command again is safe. A report the server had already +counted is answered from the first time, with a note on stderr; stdout is unchanged. + +```shell +kaiten instances usage report acme-production tokens --value 1200 \ + --transaction-id llm-call:9f2c:tokens +``` + +A key already used for a different report exits `5`: send a correction as a new +report under a new key. + +The usage history lists every accepted report, with the counter before and after it +and the limit it was gated on. The range defaults to the last 30 days. + +```shell +kaiten instances usage history acme-production tokens --from 2026-10-01T00:00:00Z + +# Stream it as CSV (default) or NDJSON: one pair, up to 366 days per run... +kaiten instances usage export acme-production tokens > tokens.csv +# ...or the whole organization, up to 31 days per run, narrowed if needed +kaiten instances usage export --from 2026-09-01T00:00:00Z --to 2026-10-01T00:00:00Z \ + --format json --output-file usage-september.ndjson +``` + +`--instance-id` and `--entitlement-id` reach deleted instances and entitlements, +whose reports are kept. A `--from` before the start of the organization's history +exits `5`. Exports are written as they arrive and are not bound by the CLI's request +timeout; `--output-file` only takes its final name once the export is complete. + ### Service account tokens ```shell diff --git a/go.mod b/go.mod index 1757be6..7621e2a 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/kaitencloud/cli go 1.25.0 require ( - github.com/kaitencloud/sdk-go v0.0.1 + github.com/kaitencloud/sdk-go v0.0.2-0.20261005202017-deeb018edc00 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 9634a97..c5fc6a5 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/kaitencloud/sdk-go v0.0.1 h1:WfR1qFNG++WkcGu55pOcT1nIt520VPMT0a81aOSj03M= -github.com/kaitencloud/sdk-go v0.0.1/go.mod h1:KE77iZ8i+SfjjhUf+qPaQrLHq3/EpFuz4+UPtZ7WFeM= +github.com/kaitencloud/sdk-go v0.0.2-0.20261005202017-deeb018edc00 h1:kPf8B3ecEZZjLFBikkY57yAI8kvdQn6zvDZ7cz16cDM= +github.com/kaitencloud/sdk-go v0.0.2-0.20261005202017-deeb018edc00/go.mod h1:KE77iZ8i+SfjjhUf+qPaQrLHq3/EpFuz4+UPtZ7WFeM= github.com/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g= github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/cmd/common.go b/internal/cmd/common.go index 85cb679..da2368c 100644 --- a/internal/cmd/common.go +++ b/internal/cmd/common.go @@ -67,7 +67,9 @@ func runtimeConfig(cmd *cobra.Command) (config.Runtime, error) { return config.Resolve(baseURL, authToken, format) } -func newClient(cmd *cobra.Command) (*sdk.Client, config.Runtime, error) { +// newClient builds an SDK client from the resolved configuration. extra options +// are applied after the CLI's own, so a command can replace the HTTP client. +func newClient(cmd *cobra.Command, extra ...sdk.Option) (*sdk.Client, config.Runtime, error) { cfg, err := runtimeConfig(cmd) if err != nil { return nil, config.Runtime{}, err @@ -81,7 +83,7 @@ func newClient(cmd *cobra.Command) (*sdk.Client, config.Runtime, error) { opts = append(opts, sdk.WithBearerToken(cfg.AuthToken)) } - client, err := sdk.NewClient(cfg.BaseURL, opts...) + client, err := sdk.NewClient(cfg.BaseURL, append(opts, extra...)...) if err != nil { return nil, cfg, err } diff --git a/internal/cmd/exit.go b/internal/cmd/exit.go index 21ee531..6fdfeda 100644 --- a/internal/cmd/exit.go +++ b/internal/cmd/exit.go @@ -102,6 +102,12 @@ func exitCode(cmd *cobra.Command, err error) int { return exitRejected } + // The SDK refuses a malformed --transaction-id before sending anything: the + // invocation was wrong, not the API's answer. + if errors.Is(err, sdk.ErrInvalidTransactionID) { + return exitUsage + } + var netErr net.Error if errors.As(err, &netErr) || errors.Is(err, context.DeadlineExceeded) { return exitUnavailable diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index 5847d74..94402ff 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -241,6 +241,8 @@ func newInstancesUsageCommand() *cobra.Command { cmd.AddCommand(newInstancesUsageListCommand()) cmd.AddCommand(newInstancesUsageGetCommand()) cmd.AddCommand(newInstancesUsageReportCommand()) + cmd.AddCommand(newInstancesUsageHistoryCommand()) + cmd.AddCommand(newInstancesUsageExportCommand()) return cmd } @@ -304,17 +306,32 @@ func newInstancesUsageReportCommand() *cobra.Command { var behavior string var metadataFile string var metadataPayload string + var transactionID string cmd := &cobra.Command{ Use: "report --value ", Short: "Report entitlement usage for an instance", - Args: cobra.ExactArgs(2), + Long: `Report entitlement usage for an instance. + +Without --transaction-id a report is sent once: if it fails in flight it may or +may not have been counted, so it is not sent again. With one, Kaiten applies the +report at most once per key, so it is retried on network errors and on 500, 502, +503 and 504, and running the same command again is safe -- a report the server +had already counted is answered from the first time, noted on stderr. + +A key already used for a different report is refused (exit code 5): send a +correction as a new report under a new key.`, + Args: cobra.ExactArgs(2), Example: ` # Add to the recorded usage kaiten instances usage report acme-prod seats --value 3 # Overwrite it instead, and attach metadata kaiten instances usage report acme-prod seats --value 12 --behavior set \ - --metadata-payload '{"source":"nightly-sync"}'`, + --metadata-payload '{"source":"nightly-sync"}' + + # Make it safe to retry: one key per measurement + kaiten instances usage report acme-prod tokens --value 1200 \ + --transaction-id llm-call:9f2c:tokens`, RunE: func(cmd *cobra.Command, args []string) error { hasInline := anyFlagChanged(cmd, "value", "behavior", "metadata-file", "metadata-payload") inputValue, err := resolveInput(file, payload, hasInline, func() (sdk.UsageReportInput, error) { @@ -335,6 +352,11 @@ func newInstancesUsageReportCommand() *cobra.Command { if err != nil { return err } + // The flag wins over a key in --file or --payload, so one payload file + // can be replayed under per-run keys. + if transactionID != "" { + inputValue.TransactionID = transactionID + } client, _, err := newClient(cmd) if err != nil { @@ -342,11 +364,18 @@ func newInstancesUsageReportCommand() *cobra.Command { } ctx, cancel := commandContext(cmd) defer cancel() - item, err := client.Instances.ReportEntitlementUsageMetric(ctx, args[0], args[1], inputValue) + result, err := client.Instances.ReportEntitlementUsage(ctx, args[0], args[1], inputValue) if err != nil { return err } - return writeStructured(cmd, output.FormatYAML, item, output.Table{}) + // Notes go to stderr, so stdout stays the usage document scripts read. + if result.Replayed { + fmt.Fprintf(cmd.ErrOrStderr(), "note: transaction %s was already counted; this is its original result, not a new report\n", inputValue.TransactionID) + } + if result.MetadataDropped { + fmt.Fprintln(cmd.ErrOrStderr(), "note: the metadata was larger than 4 KiB and was not stored; the report was counted") + } + return writeStructured(cmd, output.FormatYAML, result.Usage, output.Table{}) }, } addInputSourceFlags(cmd, &file, &payload, "usage report") @@ -354,6 +383,7 @@ func newInstancesUsageReportCommand() *cobra.Command { cmd.Flags().StringVar(&behavior, "behavior", string(sdk.Append), "Usage behavior: append or set") cmd.Flags().StringVar(&metadataFile, "metadata-file", "", "Optional JSON or YAML metadata file") cmd.Flags().StringVar(&metadataPayload, "metadata-payload", "", "Optional inline JSON or YAML metadata object") + cmd.Flags().StringVar(&transactionID, "transaction-id", "", "Idempotency key: 1 to 128 characters of letters, digits, '.', '_', ':' and '-'") return cmd } diff --git a/internal/cmd/usage.go b/internal/cmd/usage.go new file mode 100644 index 0000000..5f5a4d4 --- /dev/null +++ b/internal/cmd/usage.go @@ -0,0 +1,230 @@ +package cmd + +import ( + "fmt" + "io" + "net/http" + "os" + + "github.com/kaitencloud/cli/internal/output" + "github.com/kaitencloud/sdk-go" + "github.com/spf13/cobra" +) + +func newInstancesUsageHistoryCommand() *cobra.Command { + var from, to, transactionID string + var limit int32 + + cmd := &cobra.Command{ + Use: "history ", + Short: "List the usage reports of an instance's entitlement", + Long: `List the usage reports Kaiten accepted for one instance and entitlement, in +the order it accepted them, with the counter before and after each one and the +limit it was gated on. + +The range defaults to the last 30 days. A --from earlier than the start of the +organization's usage history is refused (exit code 5).`, + Args: cobra.ExactArgs(2), + Example: ` kaiten instances usage history acme-prod tokens + kaiten instances usage history acme-prod tokens --from 2026-10-01T00:00:00Z --limit 100 + kaiten instances usage history acme-prod tokens --transaction-id llm-call:9f2c:tokens`, + RunE: func(cmd *cobra.Command, args []string) error { + fromTime, err := parseTimeFlag(from, "from") + if err != nil { + return err + } + toTime, err := parseTimeFlag(to, "to") + if err != nil { + return err + } + + client, _, err := newClient(cmd) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd) + defer cancel() + items, err := client.Instances.ListUsageReports(ctx, args[0], args[1], &sdk.UsageReportsOptions{ + From: fromTime, + To: toTime, + TransactionID: transactionID, + Limit: optionalInt32(limit), + }) + if err != nil { + return err + } + + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + fmt.Sprint(item.ReportSeq), + formatTimeValue(item.ReportedAt), + string(item.Behavior), + item.ReportedValue, + item.ValueBefore, + item.ValueAfter, + item.Delta, + stringOrEmpty(item.LimitValue), + stringOrEmpty(item.TransactionId), + }) + } + return writeStructured(cmd, output.FormatTable, items, output.Table{ + Columns: []string{"SEQ", "REPORTED AT", "BEHAVIOR", "VALUE", "BEFORE", "AFTER", "DELTA", "LIMIT", "TRANSACTION ID"}, + Rows: rows, + }) + }, + } + + cmd.Flags().StringVar(&from, "from", "", "Only reports accepted at or after this RFC3339 timestamp (default: 30 days before --to)") + cmd.Flags().StringVar(&to, "to", "", "Only reports accepted before this RFC3339 timestamp (default: now)") + cmd.Flags().StringVar(&transactionID, "transaction-id", "", "Only the report sent with this idempotency key") + cmd.Flags().Int32Var(&limit, "limit", 0, "Fetch at most this many reports") + + return cmd +} + +func newInstancesUsageExportCommand() *cobra.Command { + var from, to, format, outputFile string + var instance, instanceID, entitlement, entitlementID string + + cmd := &cobra.Command{ + Use: "export [ ]", + Short: "Export usage reports as CSV or NDJSON", + Long: `Stream usage reports as CSV (with a header row) or NDJSON (one report per +line), to stdout or to --output-file. + +With an instance and an entitlement, the export covers that pair, up to 366 days +per run. Without them, it covers the whole organization, up to 31 days per run, +and --instance, --instance-id, --entitlement and --entitlement-id narrow it; the +ID filters reach deleted instances and entitlements, whose reports are kept. + +The range defaults to the last 30 days. A --from earlier than the start of the +organization's usage history is refused (exit code 5). The --output flag does not +apply: the export is written as it arrives, in --format.`, + Args: func(_ *cobra.Command, args []string) error { + if len(args) != 0 && len(args) != 2 { + return fmt.Errorf("accepts either no arguments or , received %d", len(args)) + } + return nil + }, + Example: ` # One instance's tokens, last 30 days, as CSV on stdout + kaiten instances usage export acme-prod tokens > tokens.csv + + # The whole organization for September, as NDJSON in a file + kaiten instances usage export --from 2026-09-01T00:00:00Z --to 2026-10-01T00:00:00Z \ + --format json --output-file usage-september.ndjson + + # A deleted instance's reports, by ID + kaiten instances usage export --instance-id 3f1c... --output-file old-instance.csv`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 2 && anyFlagChanged(cmd, "instance", "instance-id", "entitlement", "entitlement-id") { + return errUsagef("--instance, --instance-id, --entitlement and --entitlement-id narrow an organization export; drop them, or drop the two arguments") + } + if format != string(sdk.UsageExportCSV) && format != string(sdk.UsageExportJSON) { + return errUsagef("--format must be csv or json") + } + fromTime, err := parseTimeFlag(from, "from") + if err != nil { + return err + } + toTime, err := parseTimeFlag(to, "to") + if err != nil { + return err + } + + client, err := newStreamingClient(cmd) + if err != nil { + return err + } + ctx := cmd.Context() + + var export *sdk.UsageExport + if len(args) == 2 { + export, err = client.Instances.ExportUsageReports(ctx, args[0], args[1], &sdk.UsageExportOptions{ + From: fromTime, To: toTime, Format: sdk.UsageExportFormat(format), + }) + } else { + export, err = client.Instances.ExportOrganizationUsageReports(ctx, &sdk.OrganizationUsageExportOptions{ + From: fromTime, To: toTime, Format: sdk.UsageExportFormat(format), + InstanceSlug: instance, InstanceID: instanceID, + EntitlementSlug: entitlement, EntitlementID: entitlementID, + }) + } + if err != nil { + return err + } + defer func() { _ = export.Body.Close() }() + + return writeExport(cmd, export, outputFile) + }, + } + + cmd.Flags().StringVar(&from, "from", "", "Only reports accepted at or after this RFC3339 timestamp (default: 30 days before --to)") + cmd.Flags().StringVar(&to, "to", "", "Only reports accepted before this RFC3339 timestamp (default: now)") + cmd.Flags().StringVar(&format, "format", string(sdk.UsageExportCSV), "Export format: csv or json (NDJSON)") + cmd.Flags().StringVar(&outputFile, "output-file", "", "Write the export to this file instead of stdout") + cmd.Flags().StringVar(&instance, "instance", "", "Organization export: only this instance (slug)") + cmd.Flags().StringVar(&instanceID, "instance-id", "", "Organization export: only this instance (ID; reaches a deleted one)") + cmd.Flags().StringVar(&entitlement, "entitlement", "", "Organization export: only this entitlement (slug)") + cmd.Flags().StringVar(&entitlementID, "entitlement-id", "", "Organization export: only this entitlement (ID; reaches a deleted one)") + + return cmd +} + +// writeExport copies an export to outputFile, or to stdout when it is empty, as +// it arrives. A file is written beside its final name and renamed into place +// once complete, so an interrupted export never leaves a truncated file that +// looks finished. +func writeExport(cmd *cobra.Command, export *sdk.UsageExport, outputFile string) error { + if outputFile == "" { + _, err := io.Copy(cmd.OutOrStdout(), export.Body) + return err + } + + partial := outputFile + ".partial" + file, err := os.Create(partial) //nolint:gosec // the path is the caller's own --output-file + if err != nil { + return fmt.Errorf("create %s: %w", partial, err) + } + written, copyErr := io.Copy(file, export.Body) + closeErr := file.Close() + if err := firstError(copyErr, closeErr); err != nil { + _ = os.Remove(partial) + return fmt.Errorf("write %s: %w", outputFile, err) + } + if err := os.Rename(partial, outputFile); err != nil { + return fmt.Errorf("write %s: %w", outputFile, err) + } + + fmt.Fprintf(cmd.ErrOrStderr(), "wrote %d bytes to %s\n", written, outputFile) + return nil +} + +// newStreamingClient is newClient for a command whose response body can take +// longer to read than any fixed deadline: an export. Its HTTP client has no +// overall timeout -- the server must still start answering within +// defaultTimeout -- and the command runs on its own context, which a signal +// still cancels, rather than commandContext's. +func newStreamingClient(cmd *cobra.Command) (*sdk.Client, error) { + transport := http.DefaultTransport.(*http.Transport).Clone() //nolint:forcetypeassert // the standard library's own type + transport.ResponseHeaderTimeout = defaultTimeout + + client, _, err := newClient(cmd, sdk.WithHTTPClient(&http.Client{Transport: transport})) + return client, err +} + +func stringOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} + +func firstError(errs ...error) error { + for _, err := range errs { + if err != nil { + return err + } + } + return nil +} diff --git a/internal/cmd/usage_test.go b/internal/cmd/usage_test.go new file mode 100644 index 0000000..6d73d2b --- /dev/null +++ b/internal/cmd/usage_test.go @@ -0,0 +1,257 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +const usageBody = `{"entitlementId":"e","entitlementSlug":"tokens","licenseId":"l","value":{"type":"number","value":1200,"event_count":1},"limit":{"type":"number","value":5000}}` + +// usageAPI is a fake Core API for the usage commands. It records each request +// and answers with what the test configured. +type usageAPI struct { + mu sync.Mutex + requests []recordedUsageRequest + answer func(w http.ResponseWriter, r *http.Request, body []byte) +} + +type recordedUsageRequest struct { + method, path string + query url.Values + body []byte +} + +func givenUsageAPI(t *testing.T, answer func(w http.ResponseWriter, r *http.Request, body []byte)) (*httptest.Server, *usageAPI) { + t.Helper() + givenIsolatedConfig(t) + api := &usageAPI{answer: answer} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + api.mu.Lock() + api.requests = append(api.requests, recordedUsageRequest{r.Method, r.URL.Path, r.URL.Query(), body}) + api.mu.Unlock() + api.answer(w, r, body) + })) + t.Cleanup(server.Close) + return server, api +} + +func (a *usageAPI) only(t *testing.T) recordedUsageRequest { + t.Helper() + a.mu.Lock() + defer a.mu.Unlock() + if len(a.requests) != 1 { + t.Fatalf("requests = %d, want 1", len(a.requests)) + } + return a.requests[0] +} + +func (a *usageAPI) count() int { + a.mu.Lock() + defer a.mu.Unlock() + return len(a.requests) +} + +// runCapturing runs the CLI and returns stdout, stderr and the exit code. +func runCapturing(t *testing.T, args ...string) (string, string, int) { + t.Helper() + var stdout, stderr bytes.Buffer + root := NewRootCommand() + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(args) + cmd, err := root.ExecuteC() + return stdout.String(), stderr.String(), exitCode(cmd, err) +} + +func TestUsageReportSendsTheTransactionID(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + w.Header().Set("Idempotent-Replayed", "true") + writeTestJSON(w, http.StatusOK, usageBody) + }) + + stdout, stderr, code := runCapturing(t, "--base-url", server.URL, "--output", "json", + "instances", "usage", "report", "acme-prod", "tokens", "--value", "1200", "--transaction-id", "llm-call:9f2c:tokens") + expectExitCode(t, code, exitOK) + + var sent map[string]any + if err := json.Unmarshal(api.only(t).body, &sent); err != nil { + t.Fatal(err) + } + if sent["transactionId"] != "llm-call:9f2c:tokens" { + t.Errorf("request body = %s, want the key", api.only(t).body) + } + expectStructuredDocument(t, stdout, json.Unmarshal) + if strings.Contains(stdout, "already counted") { + t.Error("the replay note belongs on stderr, not in the document scripts read") + } + if !strings.Contains(stderr, "transaction llm-call:9f2c:tokens was already counted") { + t.Errorf("stderr = %q, want the replay note", stderr) + } +} + +func TestUsageReportWithoutAKeySendsNone(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, usageBody) + }) + + _, stderr, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "report", "acme-prod", "tokens", "--value", "1") + expectExitCode(t, code, exitOK) + if strings.Contains(string(api.only(t).body), "transactionId") { + t.Errorf("request body = %s, want no key", api.only(t).body) + } + if stderr != "" { + t.Errorf("stderr = %q, want nothing for a plain report", stderr) + } +} + +func TestUsageReportKeyFailures(t *testing.T) { + t.Run("a malformed key is a usage error and sends nothing", func(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, usageBody) + }) + _, _, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "report", "acme-prod", "tokens", "--value", "1", "--transaction-id", "has space") + expectExitCode(t, code, exitUsage) + if api.count() != 0 { + t.Errorf("requests = %d, want none", api.count()) + } + }) + + t.Run("a reused key is rejected", func(t *testing.T) { + server, _ := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"title":"Conflict","status":409,"code":"ReportEntitlementUsageMetric.TransactionIdReused","detail":"transactionId evt-1 was already used for another report"}`)) + }) + _, _, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "report", "acme-prod", "tokens", "--value", "1", "--transaction-id", "evt-1") + expectExitCode(t, code, exitRejected) + }) +} + +func TestUsageHistoryListsTheReports(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, `{"items":[ + {"reportSeq":1,"reportedAt":"2026-10-05T08:00:00Z","behavior":"append","aggregationMethod":"SUM","reportedValue":"600","valueBefore":"0","valueAfter":"600","delta":"600","overageDelta":"0","eventCountAfter":1,"limitValue":"1000","overagePercent":50,"transactionId":"evt-1","instanceId":"11111111-1111-1111-1111-111111111111","entitlementId":"22222222-2222-2222-2222-222222222222","licenseId":"33333333-3333-3333-3333-333333333333"}, + {"reportSeq":2,"reportedAt":"2026-10-05T09:00:00Z","behavior":"set","aggregationMethod":"SUM","reportedValue":"450","valueBefore":"600","valueAfter":"450","delta":"-150","overageDelta":"0","eventCountAfter":2,"instanceId":"11111111-1111-1111-1111-111111111111","entitlementId":"22222222-2222-2222-2222-222222222222","licenseId":"33333333-3333-3333-3333-333333333333"}]}`) + }) + + stdout, _, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "history", "acme-prod", "tokens", "--from", "2026-10-01T00:00:00Z") + expectExitCode(t, code, exitOK) + + request := api.only(t) + if request.path != "/instances/acme-prod/entitlements/tokens/usage/reports" || request.query.Get("from") == "" { + t.Errorf("request = %s?%s", request.path, request.query.Encode()) + } + for _, want := range []string{"SEQ", "TRANSACTION ID", "evt-1", "1000", "-150", "set"} { + expectOutputContains(t, stdout, want) + } +} + +const exportCSV = "organization_id,instance_id,report_seq\no,i,1\no,i,2\n" + +func givenExportAPI(t *testing.T) (*httptest.Server, *usageAPI) { + t.Helper() + return givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="usage-20261001-20261005.csv"`) + _, _ = w.Write([]byte(exportCSV)) + }) +} + +func TestUsageExportStreamsAPairToStdout(t *testing.T) { + server, api := givenExportAPI(t) + + stdout, _, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "export", "acme-prod", "tokens") + expectExitCode(t, code, exitOK) + if stdout != exportCSV { + t.Errorf("stdout = %q, want the export byte for byte", stdout) + } + request := api.only(t) + if request.path != "/instances/acme-prod/entitlements/tokens/usage/reports/export" || request.query.Get("format") != "csv" { + t.Errorf("request = %s?%s", request.path, request.query.Encode()) + } +} + +func TestUsageExportWritesTheOrganizationToAFile(t *testing.T) { + server, api := givenExportAPI(t) + target := filepath.Join(t.TempDir(), "usage.ndjson") + + stdout, stderr, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "export", + "--format", "json", "--instance-id", "11111111-1111-1111-1111-111111111111", "--entitlement", "tokens", "--output-file", target) + expectExitCode(t, code, exitOK) + + written, err := os.ReadFile(target) //nolint:gosec // a path under t.TempDir() + if err != nil { + t.Fatal(err) + } + if string(written) != exportCSV { + t.Errorf("file = %q", written) + } + if _, err := os.Stat(target + ".partial"); !os.IsNotExist(err) { + t.Error("the partial file was left behind") + } + if stdout != "" || !strings.Contains(stderr, fmt.Sprintf("wrote %d bytes to %s", len(exportCSV), target)) { + t.Errorf("stdout = %q, stderr = %q", stdout, stderr) + } + + query := api.only(t).query + if api.only(t).path != "/usage/reports/export" || query.Get("format") != "json" || + query.Get("instanceId") != "11111111-1111-1111-1111-111111111111" || query.Get("entitlementSlug") != "tokens" { + t.Errorf("request = %s?%s", api.only(t).path, query.Encode()) + } +} + +func TestUsageExportInvocationMistakes(t *testing.T) { + server, api := givenExportAPI(t) + + for name, args := range map[string][]string{ + "one argument": {"acme-prod"}, + "organization filters on a pair": {"acme-prod", "tokens", "--instance", "acme-prod"}, + "an unknown format": {"--format", "xml"}, + "an unparsable from": {"--from", "yesterday"}, + "a malformed instance ID": {"--instance-id", "not-a-uuid"}, + "organization filter ID on a pair": {"acme-prod", "tokens", "--entitlement-id", "22222222-2222-2222-2222-222222222222"}, + "three arguments": {"a", "b", "c"}, + "an entitlement filter with one slug": {"acme-prod", "--entitlement", "tokens"}, + } { + t.Run(name, func(t *testing.T) { + _, _, code := runCapturing(t, append([]string{"--base-url", server.URL, "instances", "usage", "export"}, args...)...) + if name == "a malformed instance ID" { + // Caught by the SDK before any request, as an ordinary error. + if code == exitOK { + t.Fatalf("exit code = 0, want a failure") + } + return + } + expectExitCode(t, code, exitUsage) + }) + } + if api.count() != 0 { + t.Errorf("requests = %d, want none", api.count()) + } +} + +func TestUsageExportReportsTheAPIRefusal(t *testing.T) { + server, _ := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"title":"Unprocessable Entity","status":422,"code":"ExportUsageReports.OutsideRetention","detail":"the usage history is kept from 2026-07-05T12:00:00.000Z"}`)) + }) + target := filepath.Join(t.TempDir(), "usage.csv") + + _, _, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "export", "acme-prod", "tokens", "--from", "2025-01-01T00:00:00Z", "--output-file", target) + expectExitCode(t, code, exitRejected) + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Error("a refused export created the output file") + } +}