axusage 3.4.1 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -153,12 +153,12 @@ Prometheus output emits text metrics suitable for scraping.
153
153
 
154
154
  ## Serve Mode
155
155
 
156
- `axusage serve` starts an HTTP server that exposes Prometheus metrics at `/metrics` for scraping, with automatic polling.
156
+ `axusage serve` starts an HTTP server exposing usage data at `/metrics` (Prometheus) and `/usage` (JSON). An initial fetch runs at startup to pre-populate the cache (enabling `/health` to return a meaningful status from the first connection). After that, no background polling runs: subsequent requests within the cache window are served instantly, and the first request after the cache expires triggers a refresh (blocking on `/usage`, non-blocking on `/metrics`).
157
157
 
158
158
  ### Usage
159
159
 
160
160
  ```bash
161
- # Start with defaults (port 3848, poll every 5 minutes)
161
+ # Start with defaults (port 3848, max cache age 5 minutes)
162
162
  axusage serve
163
163
 
164
164
  # Custom configuration
@@ -170,17 +170,18 @@ AXUSAGE_PORT=9090 AXUSAGE_INTERVAL=60 axusage serve
170
170
 
171
171
  ### Options
172
172
 
173
- | Flag | Env Var | Default | Description |
174
- | ---------------------- | ------------------ | ----------- | --------------------------- |
175
- | `--port <port>` | `AXUSAGE_PORT` | `3848` | Port to listen on |
176
- | `--host <host>` | `AXUSAGE_HOST` | `127.0.0.1` | Host to bind to |
177
- | `--interval <seconds>` | `AXUSAGE_INTERVAL` | `300` | Polling interval in seconds |
178
- | `--service <service>` | `AXUSAGE_SERVICE` | all | Service to monitor |
173
+ | Flag | Env Var | Default | Description |
174
+ | ---------------------- | ------------------ | ----------- | ------------------------ |
175
+ | `--port <port>` | `AXUSAGE_PORT` | `3848` | Port to listen on |
176
+ | `--host <host>` | `AXUSAGE_HOST` | `127.0.0.1` | Host to bind to |
177
+ | `--interval <seconds>` | `AXUSAGE_INTERVAL` | `300` | Max cache age in seconds |
178
+ | `--service <service>` | `AXUSAGE_SERVICE` | all | Service to monitor |
179
179
 
180
180
  ### Endpoints
181
181
 
182
- - `GET /metrics` — Prometheus text exposition (`text/plain; version=0.0.4`). Returns 503 if no data has been fetched yet.
183
- - `GET /health` — JSON health status with version, last refresh time, tracked services, and errors.
182
+ - `GET /metrics` — Prometheus text exposition (`text/plain; version=0.0.4`). Serves cached data immediately; triggers a background refresh when stale. Returns 503 when all services are currently failing.
183
+ - `GET /usage` — JSON array of usage objects (one per service). Waits for a fresh snapshot when stale. Returns 503 if no data is available. Date fields (e.g. `resetsAt`) are serialized as ISO 8601 strings.
184
+ - `GET /health` — JSON health status with version, last refresh time, tracked services, and errors. Always responds immediately from cached state without triggering a refresh.
184
185
 
185
186
  ### Container Deployment
186
187
 
package/dist/cli.js CHANGED
@@ -31,10 +31,10 @@ const program = new Command()
31
31
  .addHelpText("after", () => `\nExamples:\n # Fetch usage for all services\n ${packageJson.name}\n\n # JSON output for a single service\n ${packageJson.name} --service claude --format json\n\n # TSV output for piping to cut, awk, sort\n ${packageJson.name} --format tsv | tail -n +2 | awk -F'\\t' '{print $1, $4"%"}'\n\n # Filter Prometheus metrics with standard tools\n ${packageJson.name} --format prometheus | grep axusage_utilization_percent\n\n # Check authentication status for all services\n ${packageJson.name} --auth-status\n\nSources config file: ${getCredentialSourcesPath()}\n(or set AXUSAGE_SOURCES to JSON to bypass file)\n\n${formatRequiresHelpText()}\nOverride CLI paths: AXUSAGE_CLAUDE_PATH, AXUSAGE_CODEX_PATH, AXUSAGE_GEMINI_PATH, AXUSAGE_GH_PATH\n`);
32
32
  program
33
33
  .command("serve")
34
- .description("Start HTTP server exposing Prometheus metrics at /metrics")
34
+ .description("Start HTTP server exposing Prometheus metrics at /metrics and usage JSON at /usage")
35
35
  .option("-p, --port <port>", "Port to listen on (env: AXUSAGE_PORT)")
36
36
  .option("-H, --host <host>", "Host to bind to (env: AXUSAGE_HOST)")
37
- .option("--interval <seconds>", "Polling interval in seconds (env: AXUSAGE_INTERVAL)")
37
+ .option("--interval <seconds>", "Max cache age in seconds (env: AXUSAGE_INTERVAL)")
38
38
  .option("-s, --service <service>", "Service to monitor (env: AXUSAGE_SERVICE, default: all)")
39
39
  .action(async (options) => {
40
40
  await serveCommand(options);
@@ -1,11 +1,30 @@
1
1
  /**
2
- * Serve command handler — starts an HTTP server exposing Prometheus metrics.
2
+ * Serve command handler — starts an HTTP server exposing usage data.
3
3
  */
4
+ import { type ServerState } from "../server/routes.js";
5
+ import type { ServiceResult } from "../types/domain.js";
4
6
  type ServeCommandOptions = {
5
7
  readonly port?: string;
6
8
  readonly host?: string;
7
9
  readonly interval?: string;
8
10
  readonly service?: string;
9
11
  };
12
+ type UsageCache = {
13
+ readonly getState: () => ServerState | undefined;
14
+ /** Waits for a fresh snapshot before returning. Use for data endpoints where staleness is unacceptable. */
15
+ readonly getFreshState: () => Promise<ServerState | undefined>;
16
+ /** Serves the current snapshot immediately; triggers a background refresh when stale.
17
+ * Blocks only if no snapshot exists yet (first ever request). Use for Prometheus /metrics
18
+ * where scrape latency matters more than strict freshness. */
19
+ readonly getStateStaleWhileRevalidate: () => Promise<ServerState | undefined>;
20
+ };
21
+ /**
22
+ * Creates an on-demand usage cache. Data is fetched via `doFetch` when a
23
+ * caller requests fresh state and the cached snapshot is older than `intervalMs`.
24
+ * Concurrent callers during a refresh all receive the same in-flight promise.
25
+ * When all services fail, the cache retries after a short backoff (≤5s) rather
26
+ * than waiting the full interval.
27
+ */
28
+ export declare function createUsageCache(doFetch: () => Promise<ServiceResult[]>, intervalMs: number): UsageCache;
10
29
  export declare function serveCommand(options: ServeCommandOptions): Promise<void>;
11
30
  export {};
@@ -1,13 +1,76 @@
1
1
  /**
2
- * Serve command handler — starts an HTTP server exposing Prometheus metrics.
2
+ * Serve command handler — starts an HTTP server exposing usage data.
3
3
  */
4
4
  import { getServeConfig } from "../config/serve-config.js";
5
5
  import { selectServicesToQuery } from "./fetch-service-usage.js";
6
6
  import { fetchServicesInParallel } from "./usage-command.js";
7
- import { formatPrometheusMetrics } from "../utils/format-prometheus-metrics.js";
8
7
  import { createServer } from "../server/server.js";
9
- import { createHealthRouter, createMetricsRouter } from "../server/routes.js";
8
+ import { createHealthRouter, createMetricsRouter, createUsageRouter, } from "../server/routes.js";
10
9
  import { getAvailableServices } from "../services/service-adapter-registry.js";
10
+ /**
11
+ * Creates an on-demand usage cache. Data is fetched via `doFetch` when a
12
+ * caller requests fresh state and the cached snapshot is older than `intervalMs`.
13
+ * Concurrent callers during a refresh all receive the same in-flight promise.
14
+ * When all services fail, the cache retries after a short backoff (≤5s) rather
15
+ * than waiting the full interval.
16
+ */
17
+ export function createUsageCache(doFetch, intervalMs) {
18
+ let state;
19
+ let refreshPromise;
20
+ async function doRefresh() {
21
+ const results = await doFetch();
22
+ const usage = [];
23
+ const errors = [];
24
+ for (const { service, result } of results) {
25
+ if (result.ok) {
26
+ usage.push(result.value);
27
+ }
28
+ else {
29
+ const statusSuffix = result.error.status === undefined
30
+ ? ""
31
+ : ` (HTTP ${String(result.error.status)})`;
32
+ errors.push(`${service}: fetch failed${statusSuffix}`);
33
+ console.error(`Warning: Failed to fetch ${service}: ${result.error.message}`);
34
+ }
35
+ }
36
+ state = { usage, refreshedAt: new Date(), errors };
37
+ }
38
+ function ensureFresh() {
39
+ const age = state === undefined ? Infinity : Date.now() - state.refreshedAt.getTime();
40
+ // If the last refresh produced no data (all services failed), retry on a
41
+ // short backoff so the server recovers promptly after transient failures
42
+ // rather than waiting the full cache interval.
43
+ const hasData = state !== undefined && state.usage.length > 0;
44
+ const maxAge = hasData ? intervalMs : Math.min(intervalMs, 5000);
45
+ if (age < maxAge)
46
+ return Promise.resolve();
47
+ refreshPromise ??= doRefresh().finally(() => {
48
+ refreshPromise = undefined;
49
+ });
50
+ return refreshPromise;
51
+ }
52
+ return {
53
+ getState: () => state,
54
+ getFreshState: async () => {
55
+ await ensureFresh();
56
+ return state;
57
+ },
58
+ getStateStaleWhileRevalidate: async () => {
59
+ if (state === undefined) {
60
+ // No snapshot yet — block until we have something to serve.
61
+ await ensureFresh();
62
+ }
63
+ else {
64
+ // Serve the current snapshot immediately; kick off a background
65
+ // refresh if stale. Errors are logged; callers are not affected.
66
+ void ensureFresh().catch((error) => {
67
+ console.error("Background metrics refresh failed:", error);
68
+ });
69
+ }
70
+ return state;
71
+ },
72
+ };
73
+ }
11
74
  export async function serveCommand(options) {
12
75
  const config = getServeConfig(options);
13
76
  const availableServices = getAvailableServices();
@@ -20,82 +83,29 @@ export async function serveCommand(options) {
20
83
  return;
21
84
  }
22
85
  const servicesToQuery = selectServicesToQuery(config.service);
23
- // Cached state
24
- let cachedMetrics;
25
- let lastRefreshTime;
26
- let lastRefreshErrors = [];
27
- let refreshing = false;
28
- async function refreshMetrics() {
29
- if (refreshing)
30
- return;
31
- refreshing = true;
32
- try {
33
- const results = await fetchServicesInParallel(servicesToQuery);
34
- const successes = [];
35
- const errors = [];
36
- for (const { service, result } of results) {
37
- if (result.ok) {
38
- successes.push(result.value);
39
- }
40
- else {
41
- const statusSuffix = result.error.status === undefined
42
- ? ""
43
- : ` (HTTP ${String(result.error.status)})`;
44
- errors.push(`${service}: fetch failed${statusSuffix}`);
45
- console.error(`Warning: Failed to fetch ${service}: ${result.error.message}`);
46
- }
47
- }
48
- lastRefreshErrors = errors;
49
- lastRefreshTime = new Date();
50
- // All services failed → clear cache so /metrics returns 503 instead of
51
- // serving stale data that could mask outages in Prometheus alerting.
52
- cachedMetrics =
53
- successes.length > 0
54
- ? await formatPrometheusMetrics(successes)
55
- : undefined;
56
- }
57
- finally {
58
- refreshing = false; // eslint-disable-line require-atomic-updates -- single-threaded guard, no race
59
- }
60
- }
61
- // Initial fetch
62
- console.error(`Fetching initial metrics for: ${servicesToQuery.join(", ")}`);
63
- await refreshMetrics();
64
- // Create server
65
- const healthRouter = createHealthRouter(() => ({
66
- lastRefreshTime,
67
- services: servicesToQuery,
68
- errors: lastRefreshErrors,
69
- hasMetrics: cachedMetrics !== undefined,
70
- }));
71
- const metricsRouter = createMetricsRouter(() => ({
72
- metrics: cachedMetrics,
73
- }));
74
- const server = createServer(config, [healthRouter, metricsRouter]);
75
- // Graceful shutdown handler — registered before start so signals during
76
- // startup are handled. process.once ensures at-most-one invocation per signal.
77
- // Object wrapper lets the shutdown closure reference the interval assigned
78
- // after server.start(), without needing a reassignable `let`.
79
- const poll = {
80
- intervalId: undefined,
81
- };
86
+ const cache = createUsageCache(() => fetchServicesInParallel(servicesToQuery), config.intervalMs);
87
+ const server = createServer(config, [
88
+ createHealthRouter(servicesToQuery, cache.getState),
89
+ createMetricsRouter(cache.getStateStaleWhileRevalidate),
90
+ createUsageRouter(cache.getFreshState),
91
+ ]);
82
92
  const shutdown = () => {
83
93
  console.error("\nShutting down...");
84
- if (poll.intervalId !== undefined)
85
- clearInterval(poll.intervalId);
86
- // Force-exit if server.stop() hangs (e.g. keep-alive connections not closing)
87
94
  const forceExit = setTimeout(() => {
88
95
  console.error("Shutdown timed out, forcing exit");
89
96
  // eslint-disable-next-line unicorn/no-process-exit -- CLI graceful shutdown
90
97
  process.exit(1);
91
98
  }, 5000);
92
99
  forceExit.unref();
93
- server.stop().then(() => {
100
+ server
101
+ .stop()
102
+ .finally(() => {
94
103
  clearTimeout(forceExit);
104
+ })
105
+ .then(() => {
95
106
  // eslint-disable-next-line unicorn/no-process-exit -- CLI graceful shutdown
96
107
  process.exit(0);
97
108
  }, (error) => {
98
- clearTimeout(forceExit);
99
109
  console.error("Error during shutdown:", error);
100
110
  // eslint-disable-next-line unicorn/no-process-exit -- CLI graceful shutdown
101
111
  process.exit(1);
@@ -103,14 +113,10 @@ export async function serveCommand(options) {
103
113
  };
104
114
  process.once("SIGTERM", shutdown);
105
115
  process.once("SIGINT", shutdown);
106
- // Start server first if this throws (e.g. EADDRINUSE), no polling interval
107
- // is left dangling keeping the process alive.
116
+ // Pre-populate the cache before accepting connections so /health returns a
117
+ // meaningful status immediately (important for container readiness checks).
118
+ console.error(`Fetching initial data for: ${servicesToQuery.join(", ")}`);
119
+ await cache.getFreshState();
108
120
  await server.start();
109
- // Start polling only after a successful listen.
110
- poll.intervalId = setInterval(() => {
111
- void refreshMetrics().catch((error) => {
112
- console.error("Unexpected error during metrics refresh:", error);
113
- });
114
- }, config.intervalMs);
115
- console.error(`Polling every ${String(config.intervalMs / 1000)}s for: ${servicesToQuery.join(", ")}`);
121
+ console.error(`Serving usage for: ${servicesToQuery.join(", ")} (max age: ${String(config.intervalMs / 1000)}s)`);
116
122
  }
@@ -74,9 +74,10 @@ export async function usageCommand(options) {
74
74
  console.log(formatServiceUsageDataAsJson(singleSuccess));
75
75
  }
76
76
  else {
77
+ const now = Date.now();
77
78
  const payload = successes.length === 1 && singleSuccess
78
- ? toJsonObject(singleSuccess)
79
- : successes.map((data) => toJsonObject(data));
79
+ ? toJsonObject(singleSuccess, now)
80
+ : successes.map((data) => toJsonObject(data, now));
80
81
  const output = hasPartialFailures
81
82
  ? {
82
83
  results: payload,
@@ -94,7 +95,7 @@ export async function usageCommand(options) {
94
95
  }
95
96
  case "prometheus": {
96
97
  // Emit Prometheus text metrics using prom-client
97
- const output = await formatPrometheusMetrics(successes);
98
+ const output = await formatPrometheusMetrics(successes, Date.now());
98
99
  process.stdout.write(output);
99
100
  break;
100
101
  }
@@ -2,17 +2,16 @@
2
2
  * Route handlers for axusage serve mode.
3
3
  */
4
4
  import { Router } from "express";
5
- type HealthStatus = {
6
- readonly lastRefreshTime: Date | undefined;
7
- readonly services: readonly string[];
5
+ import type { ServiceUsageData } from "../types/domain.js";
6
+ /** Snapshot produced by each refresh cycle. */
7
+ export type ServerState = {
8
+ readonly usage: readonly ServiceUsageData[];
9
+ readonly refreshedAt: Date;
8
10
  readonly errors: readonly string[];
9
- readonly hasMetrics: boolean;
10
- };
11
- type MetricsStatus = {
12
- readonly metrics: string | undefined;
13
11
  };
14
12
  /** Create router for GET /health */
15
- export declare function createHealthRouter(getStatus: () => HealthStatus): Router;
16
- /** Create router for GET /metrics */
17
- export declare function createMetricsRouter(getMetrics: () => MetricsStatus): Router;
18
- export {};
13
+ export declare function createHealthRouter(services: readonly string[], getState: () => ServerState | undefined): Router;
14
+ /** Create router for GET /metrics (Prometheus text exposition) */
15
+ export declare function createMetricsRouter(getState: () => Promise<ServerState | undefined>): Router;
16
+ /** Create router for GET /usage (JSON) */
17
+ export declare function createUsageRouter(getFreshState: () => Promise<ServerState | undefined>): Router;
@@ -3,35 +3,68 @@
3
3
  */
4
4
  import { Router } from "express";
5
5
  import packageJson from "../../package.json" with { type: "json" };
6
+ import { formatPrometheusMetrics } from "../utils/format-prometheus-metrics.js";
7
+ import { toJsonObject } from "../utils/format-service-usage.js";
6
8
  /** Create router for GET /health */
7
- export function createHealthRouter(getStatus) {
9
+ export function createHealthRouter(services, getState) {
8
10
  const router = Router();
9
11
  router.get("/health", (_request, response) => {
10
- const status = getStatus();
11
- const healthy = status.hasMetrics;
12
+ const state = getState();
13
+ const healthy = state !== undefined && state.usage.length > 0;
12
14
  response.status(healthy ? 200 : 503).json({
13
15
  status: healthy ? "ok" : "degraded",
14
16
  version: packageJson.version,
15
- lastRefresh: status.lastRefreshTime?.toISOString(),
16
- services: status.services,
17
- errors: status.errors,
17
+ lastRefresh: state?.refreshedAt.toISOString(),
18
+ services,
19
+ errors: state?.errors ?? [],
18
20
  });
19
21
  });
20
22
  return router;
21
23
  }
22
- /** Create router for GET /metrics */
23
- export function createMetricsRouter(getMetrics) {
24
+ /** Create router for GET /metrics (Prometheus text exposition) */
25
+ export function createMetricsRouter(getState) {
24
26
  const router = Router();
25
- router.get("/metrics", (_request, response) => {
26
- const { metrics } = getMetrics();
27
- if (!metrics) {
27
+ // Memoize the rendered Prometheus text by the state snapshot's refreshedAt
28
+ // timestamp. Scrapes within the same cache window reuse the same Promise,
29
+ // avoiding recreating prom-client Registry/Gauge objects on each request.
30
+ // Rate is computed at refreshedAt so output is deterministic per snapshot,
31
+ // keeping the gauge coherent with the usage data it describes.
32
+ // Assignments happen synchronously (before any await) so require-atomic-updates
33
+ // is satisfied and concurrent scrapes naturally coalesce onto one render.
34
+ let memoFor;
35
+ let memoPromise = Promise.resolve("");
36
+ router.get("/metrics", async (_request, response) => {
37
+ const state = await getState();
38
+ const usage = state?.usage;
39
+ if (!usage || usage.length === 0) {
28
40
  response.status(503).type("text/plain").send("No data yet\n");
29
41
  return;
30
42
  }
43
+ if (memoFor !== state.refreshedAt) {
44
+ memoFor = state.refreshedAt;
45
+ memoPromise = formatPrometheusMetrics(usage, state.refreshedAt.getTime());
46
+ }
47
+ const text = await memoPromise;
31
48
  response
32
49
  .status(200)
33
50
  .type("text/plain; version=0.0.4; charset=utf-8")
34
- .send(metrics);
51
+ .send(text);
52
+ });
53
+ return router;
54
+ }
55
+ /** Create router for GET /usage (JSON) */
56
+ export function createUsageRouter(getFreshState) {
57
+ const router = Router();
58
+ router.get("/usage", async (_request, response) => {
59
+ const state = await getFreshState();
60
+ const usage = state?.usage;
61
+ if (!usage || usage.length === 0) {
62
+ response.status(503).json({ error: "No data yet" });
63
+ return;
64
+ }
65
+ response
66
+ .status(200)
67
+ .json(usage.map((entry) => toJsonObject(entry, state.refreshedAt.getTime())));
35
68
  });
36
69
  return router;
37
70
  }
@@ -6,4 +6,4 @@
6
6
  * Rate is shown after either {@link MIN_ELAPSED_FRACTION} of the period OR
7
7
  * {@link MIN_ELAPSED_TIME_MS} has passed (whichever comes first).
8
8
  */
9
- export declare function calculateUsageRate(utilization: number, resetsAt: Date | undefined, periodDurationMs: number): number | undefined;
9
+ export declare function calculateUsageRate(utilization: number, resetsAt: Date | undefined, periodDurationMs: number, now: number): number | undefined;
@@ -10,12 +10,11 @@ const MIN_ELAPSED_TIME_MS = 2 * 60 * 60 * 1000;
10
10
  * Rate is shown after either {@link MIN_ELAPSED_FRACTION} of the period OR
11
11
  * {@link MIN_ELAPSED_TIME_MS} has passed (whichever comes first).
12
12
  */
13
- export function calculateUsageRate(utilization, resetsAt, periodDurationMs) {
13
+ export function calculateUsageRate(utilization, resetsAt, periodDurationMs, now) {
14
14
  if (!resetsAt)
15
15
  return undefined;
16
16
  if (periodDurationMs <= 0)
17
17
  return 0;
18
- const now = Date.now();
19
18
  const resetTime = resetsAt.getTime();
20
19
  const periodStart = resetTime - periodDurationMs;
21
20
  const elapsedTime = now - periodStart;
@@ -1,6 +1,6 @@
1
1
  import type { ServiceUsageData } from "../types/domain.js";
2
2
  /**
3
3
  * Formats service usage data as Prometheus text exposition using prom-client.
4
- * Emits a gauge `axusage_utilization_percent{service,window}` per window.
4
+ * Emits gauges `axusage_utilization_percent` and `axusage_usage_rate` per window.
5
5
  */
6
- export declare function formatPrometheusMetrics(data: readonly ServiceUsageData[]): Promise<string>;
6
+ export declare function formatPrometheusMetrics(data: readonly ServiceUsageData[], now: number): Promise<string>;
@@ -1,19 +1,31 @@
1
1
  import { Gauge, Registry } from "prom-client";
2
+ import { calculateUsageRate } from "./calculate-usage-rate.js";
2
3
  /**
3
4
  * Formats service usage data as Prometheus text exposition using prom-client.
4
- * Emits a gauge `axusage_utilization_percent{service,window}` per window.
5
+ * Emits gauges `axusage_utilization_percent` and `axusage_usage_rate` per window.
5
6
  */
6
- export async function formatPrometheusMetrics(data) {
7
+ export async function formatPrometheusMetrics(data, now) {
7
8
  const registry = new Registry();
8
- const gauge = new Gauge({
9
+ const utilizationGauge = new Gauge({
9
10
  name: "axusage_utilization_percent",
10
11
  help: "Current utilization percentage by service/window",
11
12
  labelNames: ["service", "window"],
12
13
  registers: [registry],
13
14
  });
15
+ const rateGauge = new Gauge({
16
+ name: "axusage_usage_rate",
17
+ help: "Usage rate (utilization / elapsed fraction of period); >1 means over budget",
18
+ labelNames: ["service", "window"],
19
+ registers: [registry],
20
+ });
14
21
  for (const entry of data) {
15
22
  for (const w of entry.windows) {
16
- gauge.set({ service: entry.service, window: w.name }, w.utilization);
23
+ const labels = { service: entry.service, window: w.name };
24
+ utilizationGauge.set(labels, w.utilization);
25
+ const rate = calculateUsageRate(w.utilization, w.resetsAt, w.periodDurationMs, now);
26
+ if (rate !== undefined) {
27
+ rateGauge.set(labels, rate);
28
+ }
17
29
  }
18
30
  }
19
31
  return registry.metrics();
@@ -6,7 +6,7 @@ export declare function formatServiceUsageData(data: ServiceUsageData): string;
6
6
  /**
7
7
  * Converts service usage data to a plain JSON-serializable object
8
8
  */
9
- export declare function toJsonObject(data: ServiceUsageData): unknown;
9
+ export declare function toJsonObject(data: ServiceUsageData, now: number): unknown;
10
10
  /**
11
11
  * Formats service usage data as JSON string
12
12
  */
@@ -34,10 +34,9 @@ function getUtilizationColor(rate) {
34
34
  */
35
35
  function formatUsageWindow(window) {
36
36
  const utilizationString = formatUtilization(window.utilization);
37
- const rate = calculateUsageRate(window.utilization, window.resetsAt, window.periodDurationMs);
37
+ const rate = calculateUsageRate(window.utilization, window.resetsAt, window.periodDurationMs, Date.now());
38
38
  const coloredUtilization = getUtilizationColor(rate)(utilizationString);
39
39
  const resetTime = formatResetTime(window.resetsAt);
40
- // Build full display string for rate to keep formatting consistent
41
40
  const rateDisplay = rate === undefined ? "Not available" : `${rate.toFixed(2)}x rate`;
42
41
  return `${chalk.bold(window.name)}:
43
42
  Utilization: ${coloredUtilization} (${rateDisplay})
@@ -47,14 +46,17 @@ function formatUsageWindow(window) {
47
46
  * Formats complete service usage data for human-readable display
48
47
  */
49
48
  export function formatServiceUsageData(data) {
49
+ let statusWarning;
50
+ if (data.metadata?.limitReached === true) {
51
+ statusWarning = chalk.red("⚠ Rate limit reached");
52
+ }
53
+ else if (data.metadata?.allowed === false) {
54
+ statusWarning = chalk.red("⚠ Usage not allowed");
55
+ }
50
56
  const header = [
51
57
  chalk.cyan.bold(`=== ${data.service} Usage ===`),
52
58
  data.planType ? chalk.gray(`Plan: ${data.planType}`) : undefined,
53
- data.metadata?.limitReached === true
54
- ? chalk.red("⚠ Rate limit reached")
55
- : data.metadata?.allowed === false
56
- ? chalk.red("⚠ Usage not allowed")
57
- : undefined,
59
+ statusWarning,
58
60
  ]
59
61
  .filter(Boolean)
60
62
  .join("\n");
@@ -66,16 +68,20 @@ export function formatServiceUsageData(data) {
66
68
  /**
67
69
  * Converts service usage data to a plain JSON-serializable object
68
70
  */
69
- export function toJsonObject(data) {
71
+ export function toJsonObject(data, now) {
70
72
  return {
71
73
  service: data.service,
72
74
  planType: data.planType,
73
- windows: data.windows.map((w) => ({
74
- name: w.name,
75
- utilization: w.utilization,
76
- resetsAt: w.resetsAt?.toISOString(),
77
- periodDurationMs: w.periodDurationMs,
78
- })),
75
+ windows: data.windows.map((w) => {
76
+ const rate = calculateUsageRate(w.utilization, w.resetsAt, w.periodDurationMs, now);
77
+ return {
78
+ name: w.name,
79
+ utilization: w.utilization,
80
+ rate,
81
+ resetsAt: w.resetsAt?.toISOString(),
82
+ periodDurationMs: w.periodDurationMs,
83
+ };
84
+ }),
79
85
  metadata: data.metadata,
80
86
  };
81
87
  }
@@ -84,7 +90,7 @@ export function toJsonObject(data) {
84
90
  */
85
91
  export function formatServiceUsageDataAsJson(data) {
86
92
  // eslint-disable-next-line unicorn/no-null -- JSON.stringify requires null for no replacer
87
- return JSON.stringify(toJsonObject(data), null, 2);
93
+ return JSON.stringify(toJsonObject(data, Date.now()), null, 2);
88
94
  }
89
95
  const TSV_HEADER = "SERVICE\tPLAN\tWINDOW\tUTILIZATION\tRATE\tRESETS_AT";
90
96
  /**
@@ -99,7 +105,7 @@ function sanitizeForTsv(value) {
99
105
  */
100
106
  function formatServiceUsageRowsAsTsv(data) {
101
107
  return data.windows.map((w) => {
102
- const rate = calculateUsageRate(w.utilization, w.resetsAt, w.periodDurationMs);
108
+ const rate = calculateUsageRate(w.utilization, w.resetsAt, w.periodDurationMs, Date.now());
103
109
  return [
104
110
  sanitizeForTsv(data.service),
105
111
  sanitizeForTsv(data.planType ?? "-"),
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "axusage",
3
3
  "author": "Łukasz Jerciński",
4
4
  "license": "MIT",
5
- "version": "3.4.1",
5
+ "version": "3.6.0",
6
6
  "description": "Monitor API usage across Claude, ChatGPT, GitHub Copilot, and Gemini from a single CLI",
7
7
  "repository": {
8
8
  "type": "git",