UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

329 lines 12.6 kB
"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