@swell/cli
Version:
Swell's command line interface/utility
251 lines (250 loc) • 9.16 kB
JavaScript
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.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;
}
}
}