axusage 3.4.1 → 3.5.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,70 +83,14 @@ 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
@@ -103,14 +110,10 @@ export async function serveCommand(options) {
103
110
  };
104
111
  process.once("SIGTERM", shutdown);
105
112
  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.
113
+ // Pre-populate the cache before accepting connections so /health returns a
114
+ // meaningful status immediately (important for container readiness checks).
115
+ console.error(`Fetching initial data for: ${servicesToQuery.join(", ")}`);
116
+ await cache.getFreshState();
108
117
  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(", ")}`);
118
+ console.error(`Serving usage for: ${servicesToQuery.join(", ")} (max age: ${String(config.intervalMs / 1000)}s)`);
116
119
  }
@@ -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,64 @@
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
+ // Assignments happen synchronously (before any await) so require-atomic-updates
31
+ // is satisfied and concurrent scrapes naturally coalesce onto one render.
32
+ let memoFor;
33
+ let memoPromise = Promise.resolve("");
34
+ router.get("/metrics", async (_request, response) => {
35
+ const state = await getState();
36
+ const usage = state?.usage;
37
+ if (!usage || usage.length === 0) {
28
38
  response.status(503).type("text/plain").send("No data yet\n");
29
39
  return;
30
40
  }
41
+ if (memoFor !== state.refreshedAt) {
42
+ memoFor = state.refreshedAt;
43
+ memoPromise = formatPrometheusMetrics(usage);
44
+ }
45
+ const text = await memoPromise;
31
46
  response
32
47
  .status(200)
33
48
  .type("text/plain; version=0.0.4; charset=utf-8")
34
- .send(metrics);
49
+ .send(text);
50
+ });
51
+ return router;
52
+ }
53
+ /** Create router for GET /usage (JSON) */
54
+ export function createUsageRouter(getFreshState) {
55
+ const router = Router();
56
+ router.get("/usage", async (_request, response) => {
57
+ const state = await getFreshState();
58
+ const usage = state?.usage;
59
+ if (!usage || usage.length === 0) {
60
+ response.status(503).json({ error: "No data yet" });
61
+ return;
62
+ }
63
+ response.status(200).json(usage.map((entry) => toJsonObject(entry)));
35
64
  });
36
65
  return router;
37
66
  }
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.5.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",