@controlplane/cli
Version:
Control Plane Corporation CLI
284 lines • 12.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.MetricsQuery = exports.MetricsCmd = void 0;
exports.resolveExpr = resolveExpr;
exports.resolveQueryPlan = resolveQueryPlan;
exports.flattenPrometheusResult = flattenPrometheusResult;
const command_1 = require("../cli/command");
const functions_1 = require("../util/functions");
const time_1 = require("../util/time");
const parse_duration_1 = require("../util/parse-duration");
const resultFetcher_1 = require("../rest/resultFetcher");
const api_1 = require("../rest/api");
const client_1 = require("../session/client");
const options_1 = require("./options");
// ANCHOR - Constants
const DEFAULT_SINCE = '1h';
const DEFAULT_STEP = '60s';
const MAX_RANGE_SAMPLES = 11000;
// ANCHOR - Options
/**
* Adds the `cpln metrics query` positional argument and time-range options.
*
* @param {Argv} yargs - The yargs instance to configure.
* @returns {Argv} The configured yargs instance.
*/
function withMetricsQueryOptions(yargs) {
const group = 'Query options:';
return yargs.options({
expr: {
group,
requiresArg: true,
describe: 'PromQL expression (alternative to the positional argument)',
},
from: {
group,
requiresArg: true,
describe: 'Range start (ISO 8601, Unix timestamp, or relative duration, e.g., 2025-10-23T07:00:00Z, 7d, now-1h)',
},
to: {
group,
requiresArg: true,
describe: 'Range end (ISO 8601, Unix timestamp, or relative duration; defaults to now)',
},
since: {
group,
requiresArg: true,
describe: `Lookback window before --to (or now); mutually exclusive with --from (default ${DEFAULT_SINCE} when a range is requested)`,
},
step: {
group,
requiresArg: true,
describe: `Range query resolution step, e.g., 15s, 1m, 5m (default ${DEFAULT_STEP})`,
},
time: {
group,
requiresArg: true,
describe: 'Evaluation time for an instant query (ISO 8601, Unix timestamp, or relative like now-5m); mutually exclusive with --from/--to/--since',
},
});
}
// ANCHOR - Command
class MetricsCmd extends command_1.Command {
constructor() {
super(...arguments);
this.command = 'metrics';
this.describe = 'Query metrics';
}
builder(yargs) {
return yargs.demandCommand().version(false).help().command(new MetricsQuery().toYargs());
}
handle() { }
}
exports.MetricsCmd = MetricsCmd;
class MetricsQuery extends command_1.Command {
constructor() {
super(...arguments);
this.command = 'query [expr]';
this.describe = 'Execute a PromQL query';
}
builder(yargs) {
return (0, functions_1.pipe)(
//
withMetricsQueryOptions, options_1.withProfileOptions, options_1.withOrgOptions, options_1.withFormatOptions, options_1.withRequestOptions, options_1.withDebugOptions)(yargs);
}
async handle(args) {
var _a, _b, _c, _d, _e, _f;
// Metrics are org-scoped
this.requireOrg();
// Resolve the PromQL expression from the positional argument or --expr
const expr = resolveExpr(args);
if (!expr) {
this.session.abort({ message: 'ERROR: A PromQL expression is required. Provide it as an argument or with --expr.' });
}
// Resolve instant vs. range and validate the time flags
const result = resolveQueryPlan(expr, args, Date.now());
if (!result.ok) {
this.session.abort({ message: `ERROR: ${result.error}` });
}
const plan = result.plan;
// Resolve the metrics host from service discovery
const metricsEndpoint = await (0, api_1.getDiscoveryEndpoint)(this.session.request.endpoint, 'metrics');
if (!metricsEndpoint) {
this.session.abort({ message: 'ERROR: The metrics endpoint is not available for this environment.' });
}
// Build a client bound to the metrics host, reusing the session credentials
const metricsClient = (0, client_1.makeSessionClient)(this.session, metricsEndpoint);
// The Prometheus API is org-scoped in the path
const verb = plan.instant ? 'query' : 'query_range';
const path = `/metrics/org/${this.session.context.org}/api/v1/${verb}`;
// Query the metrics API (Prometheus returns the full result set in one response — no pagination)
let body;
try {
body = await metricsClient.get(path, { params: plan.params });
}
catch (e) {
// Surface the Prometheus error text when present
const message = (_d = (_c = (_b = (_a = e.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.error) !== null && _c !== void 0 ? _c : e.message) !== null && _d !== void 0 ? _d : 'Unknown error';
this.session.abort({ message: `ERROR: Metrics query failed: ${message}`, error: e });
}
// A 2xx response may still carry an error status
if (body.status === 'error') {
this.session.abort({ message: `ERROR: Metrics query failed: ${(_f = (_e = body.error) !== null && _e !== void 0 ? _e : body.errorType) !== null && _f !== void 0 ? _f : 'Unknown error'}` });
}
// Flatten the Prometheus result into rows and cap them by --max
const items = (0, resultFetcher_1.applyMaxAndWarn)(this.session, flattenPrometheusResult(body.data));
// Wrap as a list; the metricSample itemKind selects the registered table model for text output
const list = { kind: 'list', itemKind: 'metricSample', items };
await this.session.outFormat(list);
}
}
exports.MetricsQuery = MetricsQuery;
// ANCHOR - Functions
/**
* Resolves the PromQL expression from the positional argument and/or --expr.
*
* @param {Arguments<MetricsQueryOptions>} args - The parsed command-line arguments.
* @returns {string | null} The trimmed expression, or null when none was provided.
*/
function resolveExpr(args) {
const raw = args.expr;
// The positional and --expr share one key; the parser collapses them (and repeated --expr)
// to the last scalar value, so an array only appears defensively — take the last entry
const value = Array.isArray(raw) ? raw[raw.length - 1] : raw;
if (typeof value !== 'string' || value.trim() === '') {
return null;
}
return value.trim();
}
/**
* Resolves the query into an instant or range plan and validates the time flags.
* Instant is the default; the presence of --from/--to/--since/--step selects range;
* --time forces an instant query at a specific moment.
*
* @param {string} query - The PromQL expression to run.
* @param {Arguments<MetricsQueryOptions>} args - The parsed command-line arguments.
* @param {number} nowMillis - The current time in milliseconds.
* @returns {QueryPlanResult} A valid plan, or an error describing the invalid flag combination.
*/
function resolveQueryPlan(query, args, nowMillis) {
var _a, _b;
const nowSeconds = Math.round(nowMillis / 1000);
const hasRangeFlag = args.from !== undefined || args.to !== undefined || args.since !== undefined || args.step !== undefined;
// --time is only meaningful for an instant query
if (args.time !== undefined && hasRangeFlag) {
return { ok: false, error: '--time cannot be combined with --from, --to, --since, or --step.' };
}
// --since and --from both set the start boundary
if (args.since !== undefined && args.from !== undefined) {
return { ok: false, error: '--since and --from are mutually exclusive.' };
}
// Instant query: at --time when given, otherwise at now
if (!hasRangeFlag) {
let time = nowSeconds;
if (args.time !== undefined) {
const parsed = (0, time_1.parseTimeToUnixSeconds)(args.time, nowMillis);
if (parsed === null) {
return { ok: false, error: invalidTimeMessage('--time', args.time) };
}
time = parsed;
}
return { ok: true, plan: { instant: true, params: { query, time } } };
}
// Range query: resolve the end boundary (--to or now)
let end = nowSeconds;
if (args.to !== undefined) {
const parsed = (0, time_1.parseTimeToUnixSeconds)(args.to, nowMillis);
if (parsed === null) {
return { ok: false, error: invalidTimeMessage('--to', args.to) };
}
end = parsed;
}
// Resolve the start boundary from --from, or from the --since lookback before end
let start;
if (args.from !== undefined) {
const parsed = (0, time_1.parseTimeToUnixSeconds)(args.from, nowMillis);
if (parsed === null) {
return { ok: false, error: invalidTimeMessage('--from', args.from) };
}
start = parsed;
}
else {
const sinceValue = (_a = args.since) !== null && _a !== void 0 ? _a : DEFAULT_SINCE;
const sinceMs = (0, parse_duration_1.parseDuration)(sinceValue);
if (sinceMs === null || sinceMs <= 0) {
return { ok: false, error: `Invalid --since duration: ${sinceValue}. Use a relative duration, e.g., 30m, 6h, 7d.` };
}
start = end - Math.round(sinceMs / 1000);
}
if (end <= start) {
return { ok: false, error: 'The range end must be after the range start.' };
}
// Resolve and validate the step
const stepValue = (_b = args.step) !== null && _b !== void 0 ? _b : DEFAULT_STEP;
const stepMs = (0, parse_duration_1.parseDuration)(stepValue);
if (stepMs === null || stepMs <= 0) {
return { ok: false, error: `Invalid --step: ${stepValue}. Use a duration, e.g., 15s, 1m, 5m.` };
}
const stepSeconds = stepMs / 1000;
// Guard against exceeding Prometheus's per-series sample cap
const sampleCount = (end - start) / stepSeconds;
if (sampleCount > MAX_RANGE_SAMPLES) {
return {
ok: false,
error: `The requested range needs ${Math.ceil(sampleCount)} samples per series, exceeding the ${MAX_RANGE_SAMPLES} limit. Use a larger --step or a narrower range.`,
};
}
return { ok: true, plan: { instant: false, params: { query, start, end, step: stepValue } } };
}
/**
* Builds the standard "invalid time" error message for a given flag.
*
* @param {string} flag - The originating flag name.
* @param {string} value - The rejected value.
* @returns {string} A human-readable error message.
*/
function invalidTimeMessage(flag, value) {
return `Invalid ${flag} format: ${value}. Use ISO 8601 (e.g., 2025-10-23T07:00:00Z), a Unix timestamp, or a relative duration (e.g., 7d, 1h, now-30m).`;
}
/**
* Flattens a Prometheus response `data` object into a flat list of samples.
* Vector series yield one row each; matrix series yield one row per data point.
*
* @param {PrometheusData | undefined} data - The `data` field of a Prometheus response.
* @returns {MetricSample[]} The flattened samples, empty when there is no data.
*/
function flattenPrometheusResult(data) {
var _a;
if (!data || !data.result) {
return [];
}
const samples = [];
// scalar / string results are a single [ts, value] pair, not an array of series
if (data.resultType === 'scalar' || data.resultType === 'string') {
const point = data.result;
samples.push({ metric: {}, timestamp: toIso(point[0]), value: point[1] });
return samples;
}
const series = data.result;
for (const one of series) {
const metric = (_a = one.metric) !== null && _a !== void 0 ? _a : {};
// matrix: many points per series
if (one.values) {
for (const [ts, value] of one.values) {
samples.push({ metric, timestamp: toIso(ts), value });
}
continue;
}
// vector: a single point per series
if (one.value) {
samples.push({ metric, timestamp: toIso(one.value[0]), value: one.value[1] });
}
}
return samples;
}
/**
* Converts Unix seconds (possibly fractional) into an ISO 8601 timestamp string.
*
* @param {number} seconds - The time in Unix seconds.
* @returns {string} The ISO 8601 representation.
*/
function toIso(seconds) {
return new Date(seconds * 1000).toISOString();
}
//# sourceMappingURL=metrics.js.map