@controlplane/cli
Version:
Control Plane Corporation CLI
329 lines • 12.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Logs = void 0;
const Websocket = require("ws");
const _ = require("lodash");
const querystring = require("querystring");
const client_1 = require("../session/client");
const time_1 = require("../util/time");
const options_1 = require("./options");
const command_1 = require("../cli/command");
const functions_1 = require("../util/functions");
// ANCHOR - Constants
const PING_INTERVAL_MS = 30 * 1000;
const PONG_DEADLINE_MS = 10 * 1000;
const DEFAULT_LIMIT = 30;
const BATCH_LIMIT = 999;
const CHUNK_DELAY_MS = 100;
// ANCHOR - Command
class Logs extends command_1.Command {
constructor() {
super();
this.command = 'logs <query>';
this.describe = 'Show logs';
}
withLogsOptions(yargs) {
return yargs
.options({
tail: {
alias: ['t', 'f'],
describe: 'Tail the logs (follow)',
boolean: true,
},
limit: {
requiresArg: true,
describe: 'Maximum number of log entries to return. Use 0 for unlimited (auto-paginates through the full time range)',
number: true,
default: DEFAULT_LIMIT,
},
'delay-for': {
describe: 'Delay in tailing by number of seconds to accumulate logs for re-ordering',
number: true,
default: 0,
},
since: {
describe: 'Lookback window',
default: '1h',
},
from: {
describe: 'Start looking for logs at this time, inclusive (ISO 8601 or relative duration, e.g., 2025-10-23T07:00:00Z, 7d, now-1M)',
requiresArg: true,
},
to: {
describe: 'Stop looking for logs at this time, exclusive (ISO 8601 or relative duration, e.g., 2025-10-23T07:00:00Z, 1d, now-30m)',
requiresArg: true,
},
output: {
alias: 'o',
describe: 'Specify output mode. raw suppresses log labels and timestamp',
requiresArg: true,
choices: ['default', 'raw', 'jsonl'],
default: 'default',
},
direction: {
describe: 'Sort order of logs',
requiresArg: true,
default: 'forward',
choices: ['forward', 'backward'],
},
})
.positional('query', {
description: 'LogQL query',
});
}
builder(yargs) {
return (0, functions_1.pipe)(
//
this.withLogsOptions, options_1.withProfileOptions, options_1.withOrgOptions, options_1.withRequestOptions, options_1.withDebugOptions)(yargs);
}
async handle(args) {
const now = Date.now();
// Resolve the start boundary from --from (absolute or relative), falling back to the --since lookback window
const start = args.from ? this.nanosFromTimeValue('--from', args.from) : (0, time_1.toNanos)(now, args.since, undefined);
const query = {
delay_for: args.delayFor,
limit: args.limit,
query: args.query,
direction: args.direction,
start,
};
// Resolve the end boundary from --to (absolute or relative) when provided
if (args.to) {
query.end = this.nanosFromTimeValue('--to', args.to);
}
if (args.tail) {
await this.tail(args, query);
}
else {
await this.query_range(args, query);
}
}
/**
* Converts a --from/--to time value into a nanosecond timestamp string.
* Accepts ISO 8601 dates and relative durations (e.g., 7d, 3M, now-30d).
*
* @param {string} flag - The originating flag name, used in the error message.
* @param {string} value - The user-supplied time value.
* @returns {string} The time expressed as a nanosecond timestamp string.
*/
nanosFromTimeValue(flag, value) {
// Parse the value as either an absolute date or a relative duration
const parsed = this.parseTimeArg(flag, value);
// Express the resolved date as a nanosecond timestamp string
return '' + parsed.date.getTime() + '000000';
}
/**
* Streams logs in real-time via WebSocket connection to the Loki tail endpoint.
* Automatically reconnects on connection close with a 3-second delay.
*
* @param {Arguments<LogsOptions>} args - The parsed command-line arguments.
* @param {LogsQuery} query - The Loki query parameters.
* @returns {Promise<void>}
*/
async tail(args, query) {
const discovery = await this.session.discovery;
// Refresh token
await this.client.get(`/org/${this.session.context.org}`).catch();
let url = `${discovery.endpoints.logs}/logs/org/${this.session.context.org}/loki/api/v1/tail?${querystring.stringify(query)}`;
if (url.startsWith('http')) {
url = 'ws' + url.substring(4);
}
client_1.wire.debug('>>>>>> WS Try Open with Token: ' + (0, client_1.convertAuthTokenForDebug)(this.session.request.token));
const socket = new Websocket(url, {
headers: {
authorization: this.session.request.token,
},
});
let terminateTimeout = null;
const pingInterval = setInterval(() => {
socket.ping();
client_1.wire.debug('>>>>>>> WS Ping Sent');
terminateTimeout = setTimeout(() => {
socket.terminate();
}, PONG_DEADLINE_MS);
}, PING_INTERVAL_MS);
socket.on('pong', () => {
client_1.wire.debug('<<<<<<< WS Pong Received');
if (terminateTimeout) {
clearTimeout(terminateTimeout);
terminateTimeout = null;
}
});
socket.on('message', (msg) => {
const obj = JSON.parse(msg.toString());
const events = makeEvents(obj.streams);
if (events.length > 0) {
// Get the timestamp of the last event received
const ts = events[events.length - 1].timestamp;
query.start = (0, time_1.incrementNanos)(ts);
}
for (const e of events) {
this.session.out(formatEvent(e, args.output));
}
});
socket.on('error', (err) => {
if (pingInterval) {
clearInterval(pingInterval);
}
// If token expired or invalid, exit immediately
if (err.message.includes('Unexpected server response: 401')) {
this.session.abort({
message: 'Stream error: ' + err.message,
error: err,
exitCode: 1,
});
}
// Continuing to close()
});
socket.on('close', async (_code, reason) => {
client_1.wire.debug('<<<<<<< WS Close Event info');
if (pingInterval) {
clearInterval(pingInterval);
}
this.session.err(`Reconnecting: ${reason}`);
await (0, time_1.sleep)(3000);
this.tail(args, query);
});
}
/**
* Fetches logs via the Loki query_range HTTP endpoint.
* For small limits (<= BATCH_LIMIT), performs a single request.
* For larger limits or unlimited (--limit 0), uses cursor-based pagination
* to fetch beyond the server's per-request cap.
*
* @param {Arguments<LogsOptions>} args - The parsed command-line arguments.
* @param {LogsQuery} query - The Loki query parameters.
* @returns {Promise<void>}
*/
async query_range(args, query) {
var _a;
const discovery = await this.session.discovery;
const url = `/logs/org/${this.session.context.org}/loki/api/v1/query_range`;
const totalLimit = (_a = args.limit) !== null && _a !== void 0 ? _a : DEFAULT_LIMIT;
const needsPagination = totalLimit === 0 || totalLimit > BATCH_LIMIT;
if (!needsPagination) {
// Single request: limit fits within one API call
const res = await this.client.get(url, {
params: query,
baseURL: discovery.endpoints.logs,
maxContentLength: -1,
});
const events = makeEvents(res.data.result);
// Reverse for backward direction
if (args.direction === 'backward') {
events.reverse();
}
for (const event of events) {
this.session.out(formatEvent(event, args.output));
}
return;
}
// Paginated mode: fetch in batches of BATCH_LIMIT
const batchQuery = { ...query, limit: BATCH_LIMIT, direction: 'forward' };
// Ensure we have an end boundary
if (!batchQuery.end) {
batchQuery.end = '' + Date.now() + '000000';
}
const allEvents = [];
while (true) {
// Fetch a batch
const res = await this.client.get(url, {
params: batchQuery,
baseURL: discovery.endpoints.logs,
maxContentLength: -1,
});
const events = makeEvents(res.data.result);
// Accumulate events
allEvents.push(...events);
// Stop: reached the requested limit
if (totalLimit > 0 && allEvents.length >= totalLimit) {
break;
}
// Stop: fewer entries than batch size means all data in range was captured
if (events.length < BATCH_LIMIT) {
break;
}
// Advance cursor past the last timestamp
const lastTimestamp = events[events.length - 1].timestamp;
batchQuery.start = (0, time_1.incrementNanos)(lastTimestamp);
// Boundary guard: start must be before end
if (batchQuery.end && BigInt(batchQuery.start) >= BigInt(batchQuery.end)) {
break;
}
// Brief delay between requests
await (0, time_1.sleep)(CHUNK_DELAY_MS);
}
// Apply limit cap for non-unlimited requests
const outputEvents = totalLimit > 0 ? allEvents.slice(0, totalLimit) : allEvents;
// Reverse for backward direction
if (args.direction === 'backward') {
outputEvents.reverse();
}
// Output events
for (const event of outputEvents) {
this.session.out(formatEvent(event, args.output));
}
}
}
exports.Logs = Logs;
// ANCHOR - Functions
/**
* Parses Loki stream data into a sorted array of log events.
*
* @param {LokiStream[]} streams - The Loki streams from the API response.
* @returns {LogEvent[]} A sorted array of log events, ordered by timestamp ascending.
*/
function makeEvents(streams) {
const events = [];
for (const stream of streams) {
for (const [timestamp, line] of stream.values) {
events.push({ timestamp, labels: stream.stream, line });
}
}
return _.sortBy(events, 'timestamp');
}
/**
* Formats a log event into the requested output format.
*
* @param {LogEvent} e - The log event to format.
* @param {string} format - The output format ('default', 'raw', or 'jsonl').
* @returns {string} The formatted log line.
*/
function formatEvent(e, format) {
const timestamp = new Date(Number(e.timestamp) / 1000000);
if (format == 'jsonl') {
return JSON.stringify({
timestamp: timestamp,
labels: e.labels,
line: e.line,
});
}
if (format == 'default') {
return `${timestamp.toISOString()} ${formatLabels(e.labels)} ${e.line}`;
}
if (format == 'raw') {
return e.line;
}
// Unknown format type
return e.line;
}
/**
* Formats label key-value pairs into a string representation.
*
* @param {Record<string, string> | undefined} labels - The labels to format.
* @returns {string} The formatted labels string (e.g., '{gvc="demo", workload="app"}').
*/
function formatLabels(labels) {
if (!labels) {
return '{}';
}
let res = '{';
for (const prop in labels) {
res += `${prop}=${JSON.stringify(labels[prop])}, `;
}
if (res.length >= 2) {
res = res.slice(0, -2);
}
return res + '}';
}
//# sourceMappingURL=logs.js.map