UNPKG

@swell/cli

Version:

Swell's command line interface/utility

251 lines (250 loc) 9.16 kB
import { Flags } from '@oclif/core'; import { LineOutput, LoggedItem, TableOutput } from '../lib/logs/index.js'; import { resolveWorkflowScope } from '../lib/workflows/operations.js'; import { SwellCommand } from '../swell-command.js'; // the columns available to display in the table const OUTPUT_COLUMNS = [ 'app', 'data', 'date', 'env', 'ip', 'request', 'req', 'status', 'time', 'type', ]; // the default columns to display in the table const OUTPUT_COLUMNS_DEFAULTS = ['date', 'request', 'data', 'status', 'time']; // the interval to poll the API when following logs const FOLLOW_POLLING_INTERVAL = 1000 * 2; // 2 seconds /** * Converts $in value to array * @param value value to convert * @param op operator * @param convertToNumber true if string value should be converted to number * @returns converted value */ function convertOperatorValue(value, op, convertToNumber = false) { if (Array.isArray(value)) { return value; } const converted = convertToNumber ? Number.parseInt(value, 10) : value; if (op !== '$in') { return converted; } return [converted]; } /** * Builds the request options to send to the API based on the flags passed * to the command. * * @param flags the flags passed to the command * @returns the request options to send to the API */ function buildLogRequestBody(flags) { const reqOptions = { where: {} }; const andConditions = []; reqOptions.limit = flags.number; if (flags.search) { reqOptions.search = flags.search; } // some filters are simple and can be mapped directly to the API const logFilters = [ { filter: 'app_id', flag: 'app', operator: '$in' }, { filter: 'message.status', flag: 'status', operator: '$in', number: true }, { filter: 'message.type', flag: 'type', operator: '$in' }, ]; // map the flags to the API query for (const filter of logFilters) { if (flags[filter.flag]) { andConditions.push({ [filter.filter]: filter.operator ? { [filter.operator]: convertOperatorValue(flags[filter.flag], filter.operator, filter.number), } : flags[filter.flag], }); } } const { after, before, env, startPolling } = flags; if (env) { andConditions.push({ environment_id: env }); } else { // Default to test env andConditions.push({ environment_id: 'test' }); } // if we are polling, we want to get logs after the last date we received if (startPolling) { andConditions.push({ date: { $gt: startPolling } }); } else { if (after) { if (typeof after === 'object' && after !== null) { andConditions.push({ date: after }); } else { andConditions.push({ date: { $gte: after } }); } } if (before) { andConditions.push({ date: { $lte: before } }); } } // if there are any conditions, add them to the request options // otherwise, the server will complain for an empty $and array if (andConditions.length > 0) { reqOptions.where.$and = andConditions; } return reqOptions; } export default class Logs extends SwellCommand { static examples = [ 'swell logs', 'swell logs -f -p -n 10 -c date,req,data', 'swell logs -s accounts', 'swell logs --type workflow --app my-app -s run-import', 'swell logs --after 2023-08-25T16:01:02.697Z -c date', 'swell logs --after 2023-08-27', ]; static flags = { after: Flags.string({ description: 'expected is Javascript Date Time String Format: YYYY-MM-DDTHH:mm:ss.sssZ', parse: async (value) => new Date(value).toISOString(), summary: 'show logs created on or after this date/time (utc)', }), // filters app: Flags.string({ description: 'filter logs by app id, slug, or "." for current swell.json', }), before: Flags.string({ description: 'expected is Javascript Date Time String Format: YYYY-MM-DDTHH:mm:ss.sssZ', parse: async (value) => new Date(value).toISOString(), summary: 'show logs created on or before this date/time (utc)', }), columns: Flags.string({ char: 'c', default: OUTPUT_COLUMNS_DEFAULTS.join(','), description: `available columns: ${OUTPUT_COLUMNS.join(', ')}`, // validate the columns requested for display async parse(value) { const flagColumns = value.split(/\s*,\s*/g); for (const column of flagColumns) { if (!OUTPUT_COLUMNS.includes(column)) { throw new Error(`Invalid column: ${column}`); } } return flagColumns.join(','); }, summary: 'comma separated list of columns to display', }), env: Flags.string({ description: 'filter logs by environment id', }), // control / output follow: Flags.boolean({ char: 'f', default: false, description: 'stream logs in real time', exclusive: ['before'], }), number: Flags.integer({ char: 'n', default: 100, description: 'number of log lines to return', }), output: Flags.string({ char: 'o', default: 'line', description: 'how to display the logs', options: ['table', 'line'], }), pretty: Flags.boolean({ char: 'p', default: false, description: 'note that this flag will take more space in the terminal and require more time to load', summary: 'pretty print json data in table output', }), search: Flags.string({ char: 's', description: 'search logs by keyword', }), status: Flags.string({ description: 'filter logs by request status', }), type: Flags.string({ description: 'filter logs by type', multiple: true, options: ['api', 'function', 'webhook', 'transaction', 'workflow'], }), }; static summary = 'Output or stream store logs to the terminal.'; output; async run() { const { flags } = await this.parse(Logs); await this.resolveWorkflowLogFilters(flags); // indentify the columns to display const columns = flags.columns.split(','); // create the output stream this.output = flags.output === 'table' ? new TableOutput(columns, flags.pretty) : new LineOutput(columns, flags.pretty); // if following, poll the API every FOLLOW_POLLING_INTERVAL seconds and // write new logs to the stream if (flags.follow) { // first get the latest log to use its date // we keep track of the last date we received so we can get logs after that const previousNumber = flags.number; flags.number = 1; // get the latest log flags.startPolling = await this.getLogsAndWriteToStream(flags, false); flags.number = previousNumber; setInterval(async () => { // when polling, we want to get logs after the last date we received flags.startPolling = await this.getLogsAndWriteToStream(flags); }, FOLLOW_POLLING_INTERVAL); } else { await this.getLogsAndWriteToStream(flags); } } async getLogs(flags) { const body = buildLogRequestBody(flags); const response = await this.api.post({ adminPath: `/data/$get/:logs` }, { body }); return response?.results?.reverse() || []; } async getLogsAndWriteToStream(flags, show = true) { const logs = await this.getLogs(flags); let lastDate = ''; if (!this.output) { this.error('Output stream not available'); } if (logs.length > 0) { lastDate = logs.at(-1).date; if (show) { for (const log of logs) { this.output.write(this.output.prepareData(new LoggedItem(log))); } } } // if the user is following logs, we want to return the last date we received return lastDate || flags.startPolling || new Date().toISOString(); } async resolveWorkflowLogFilters(flags) { if (!flags.type?.includes('workflow') || !flags.app) { return; } const envId = flags.env || 'test'; if (envId !== 'live') { await this.api.setEnv(envId); } const scope = await resolveWorkflowScope(this.api, { app: flags.app, }); if (scope.appId) { flags.app = scope.appId; } } }