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 +11 -10
- package/dist/cli.js +2 -2
- package/dist/commands/serve-command.d.ts +20 -1
- package/dist/commands/serve-command.js +82 -76
- package/dist/commands/usage-command.js +4 -3
- package/dist/server/routes.d.ts +10 -11
- package/dist/server/routes.js +45 -12
- package/dist/utils/calculate-usage-rate.d.ts +1 -1
- package/dist/utils/calculate-usage-rate.js +1 -2
- package/dist/utils/format-prometheus-metrics.d.ts +2 -2
- package/dist/utils/format-prometheus-metrics.js +16 -4
- package/dist/utils/format-service-usage.d.ts +1 -1
- package/dist/utils/format-service-usage.js +22 -16
- package/package.json +1 -1
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
|
|
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,
|
|
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` |
|
|
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
|
|
183
|
-
- `GET /
|
|
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>", "
|
|
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
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
//
|
|
107
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/dist/server/routes.d.ts
CHANGED
|
@@ -2,17 +2,16 @@
|
|
|
2
2
|
* Route handlers for axusage serve mode.
|
|
3
3
|
*/
|
|
4
4
|
import { Router } from "express";
|
|
5
|
-
type
|
|
6
|
-
|
|
7
|
-
|
|
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(
|
|
16
|
-
/** Create router for GET /metrics */
|
|
17
|
-
export declare function createMetricsRouter(
|
|
18
|
-
|
|
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;
|
package/dist/server/routes.js
CHANGED
|
@@ -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(
|
|
9
|
+
export function createHealthRouter(services, getState) {
|
|
8
10
|
const router = Router();
|
|
9
11
|
router.get("/health", (_request, response) => {
|
|
10
|
-
const
|
|
11
|
-
const healthy =
|
|
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:
|
|
16
|
-
services
|
|
17
|
-
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(
|
|
24
|
+
/** Create router for GET /metrics (Prometheus text exposition) */
|
|
25
|
+
export function createMetricsRouter(getState) {
|
|
24
26
|
const router = Router();
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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