diff --git a/src/index.ts b/src/index.ts index 89a451a..2da8ddc 100755 --- a/src/index.ts +++ b/src/index.ts @@ -4,60 +4,62 @@ import { GetQueryResultsCommandOutput, QueryStatus, StartQueryCommand, - StopQueryCommand, -} from '@aws-sdk/client-cloudwatch-logs'; -import { Parser as CsvParser } from 'json2csv'; -import json2md from 'json2md'; -import * as yargs from 'yargs'; + StopQueryCommand +} from "@aws-sdk/client-cloudwatch-logs"; +import { Parser as CsvParser } from "json2csv"; +import json2md from "json2md"; +import * as yargs from "yargs"; -import * as logs from './logs'; -import * as time from './time'; -import * as util from './util'; +import * as logs from "./logs"; +import * as time from "./time"; +import * as util from "./util"; const args = yargs .strictOptions() - .option('log-group', { - alias: 'l', - type: 'string', - description: 'The name of the log group', + .option("log-group", { + alias: "l", + type: "string", + description: "The name of the log group", }) - .option('format', { - alias: 'f', - type: 'string', - choices: ['csv', 'json', 'md', 'markdown'], - default: 'csv', - description: 'The format of the results', + .option("format", { + alias: "f", + type: "string", + choices: ["csv", "json", "md", "markdown"], + default: "csv", + description: "The format of the results", }) - .option('start', { - alias: 's', - type: 'string', - default: '1h', - description: 'When to start the search (duration or ISO 8601 format)', + .option("start", { + alias: "s", + type: "string", + default: "1h", + description: "When to start the search (duration or ISO 8601 format)", }) - .option('end', { - alias: 'e', - type: 'string', - default: 'now', - description: 'When to end the search (duration or ISO 8601 format)', + .option("end", { + alias: "e", + type: "string", + default: "now", + description: "When to end the search (duration or ISO 8601 format)", }) - .option('message-only', { - alias: 'm', - type: 'boolean', + .option("message-only", { + alias: "m", + type: "boolean", default: false, - description: 'Return just the message in plain-text format (no CSV)', + description: "Return just the message in plain-text format (no CSV)", }) - .demandOption('log-group', 'At least one log group is required.') + .demandOption("log-group", "At least one log group is required.") .help() - .alias('help', 'h').argv; + .alias("help", "h").argv; let runningQueryId: string | undefined; const logsClient = new CloudWatchLogsClient({}); -process.on('SIGINT', async () => { +process.on("SIGINT", async () => { if (runningQueryId) { console.error(`Cancelling running query with ID ${runningQueryId}`); - await logsClient.send(new StopQueryCommand({ - queryId: runningQueryId - })) + await logsClient.send( + new StopQueryCommand({ + queryId: runningQueryId, + }) + ); process.exit(0); } }); @@ -66,28 +68,37 @@ process.on('SIGINT', async () => { // Parse arguments const logGroups = args.logGroup; if (!logGroups) { - throw new Error('A log group is required (specify with -l or --log-group)'); + throw new Error("A log group is required (specify with -l or --log-group)"); } - const inputLogGroupNames: string[] = Array.isArray(logGroups) ? logGroups : [logGroups]; + const inputLogGroupNames: string[] = Array.isArray(logGroups) + ? logGroups + : [logGroups]; const startTime = time.parseTimeOrDuration(args.start); const endTime = time.parseTimeOrDuration(args.end); const query = args._[0]; if (!query) { - throw new Error('No query provided'); + throw new Error("No query provided"); } const queryString = query.toString(); // Expand log group names - const logGroupNames = await logs.expandLogGroups(inputLogGroupNames, logsClient) + const logGroupNames = await logs.expandLogGroups( + inputLogGroupNames, + logsClient + ); if (logGroupNames.size === 0) { - throw new Error('No explicit or matching log groups provided.'); + throw new Error("No explicit or matching log groups provided."); } - console.error(`Querying between ${new Date(startTime).toISOString()} and ${new Date(endTime).toISOString()} - for ${logGroupNames.size} log group(s): ${JSON.stringify([...logGroupNames])}\n`); + console.error(`Querying between ${new Date( + startTime + ).toISOString()} and ${new Date(endTime).toISOString()} + for ${logGroupNames.size} log group(s): ${JSON.stringify([ + ...logGroupNames, + ])}\n`); // Execute the query const startOutput = await logsClient.send( @@ -114,19 +125,25 @@ process.on('SIGINT', async () => { }) ); const stats = queryResults.statistics!; - const megabytesScanned = ((stats.bytesScanned || 0) / 1024 / 1024).toFixed(2); - process.stderr.write(`\r ${stats.recordsScanned} records (${megabytesScanned} MB) scanned, ${stats.recordsMatched} matched`); + const megabytesScanned = ((stats.bytesScanned || 0) / 1024 / 1024).toFixed( + 2 + ); + process.stderr.write( + `\r ${stats.recordsScanned} records (${megabytesScanned} MB) scanned, ${stats.recordsMatched} matched` + ); } while ( queryResults.status === QueryStatus.Scheduled || queryResults.status === QueryStatus.Running ); runningQueryId = undefined; - process.stderr.write('\n\n'); + process.stderr.write("\n\n"); // If the command did not complete successfully, error with output if (queryResults.status !== QueryStatus.Complete) { throw new Error( - `Query failed with status ${queryResults.status}: ${JSON.stringify(queryResults)}` + `Query failed with status ${queryResults.status}: ${JSON.stringify( + queryResults + )}` ); } @@ -135,46 +152,49 @@ process.on('SIGINT', async () => { const mappedResults = results.map((r) => { const result: { [key: string]: string } = {}; for (const item of r) { - const field = item.field || ''; - if (item.field === '@ptr') { + const field = item.field || ""; + if (item.field === "@ptr") { continue; } - result[field] = item.value || ''; + result[field] = item.value || ""; } return result; }); - if(mappedResults.length === 0) { - console.error('No results returned.'); + if (mappedResults.length === 0) { + console.error("No results returned."); return; } // Output the results in the desired format - if (args['message-only']) { - mappedResults.forEach((r) => console.log(util.unescapeValue(r['@message']))); + if (args["message-only"]) { + mappedResults.forEach((r) => + console.log(util.unescapeValue(r["@message"])) + ); } else { switch (args.format) { - case 'csv': + case "csv": console.log(new CsvParser().parse(mappedResults)); break; - case 'json': + case "json": console.log(JSON.stringify(mappedResults)); break; - case 'md': - case 'markdown': - console.log(json2md([ - { - table: { - headers: Object.keys(mappedResults[0]), - rows: mappedResults.map(r => Object.values(r)), - } - } - ])); + case "md": + case "markdown": + console.log( + json2md([ + { + table: { + headers: Object.keys(mappedResults[0]), + rows: mappedResults.map((r) => Object.values(r)), + }, + }, + ]) + ); break; } } - })().catch((e: Error) => { console.error(`Error: ${e.message}`); process.exit(1); diff --git a/src/logs.ts b/src/logs.ts index fe1b40b..215ed09 100644 --- a/src/logs.ts +++ b/src/logs.ts @@ -4,44 +4,59 @@ import { DescribeLogGroupsCommandOutput } from "@aws-sdk/client-cloudwatch-logs"; -const getLogGroupsByPrefix = async(logGroupPrefix: string, client: CloudWatchLogsClient): Promise => { - const logGroups: string[] = []; - - let response: DescribeLogGroupsCommandOutput = { - $metadata: {} - }; - do { - response = await client.send(new DescribeLogGroupsCommand({ - logGroupNamePrefix: logGroupPrefix, - nextToken: response.nextToken - })); - - const matchingLogGroups = response.logGroups; - if (!matchingLogGroups) { - throw new Error(`No log groups found for prefix ${logGroupPrefix}`); - } +const getLogGroupsByPrefix = async ( + logGroupPrefix: string, + client: CloudWatchLogsClient +): Promise => { + const logGroups: string[] = []; + + let response: DescribeLogGroupsCommandOutput = { + $metadata: {}, + }; + do { + response = await client.send( + new DescribeLogGroupsCommand({ + logGroupNamePrefix: logGroupPrefix, + nextToken: response.nextToken, + }) + ); + + const matchingLogGroups = response.logGroups; + if (!matchingLogGroups) { + throw new Error(`No log groups found for prefix ${logGroupPrefix}`); + } - logGroups.push(...matchingLogGroups.map(g => g.logGroupName!)); - } while (response.nextToken) + logGroups.push(...matchingLogGroups.map((g) => g.logGroupName!)); + } while (response.nextToken); - return logGroups; -} + return logGroups; +}; -export const expandLogGroups = async(inputLogGroupNames: string[], client: CloudWatchLogsClient): Promise> => { +export const expandLogGroups = async ( + inputLogGroupNames: string[], + client: CloudWatchLogsClient +): Promise> => { const logGroupNames = new Set(); for (const inputName of inputLogGroupNames) { - if (inputName.includes('*')) { + if (inputName.includes("*")) { // ensure only one asterisk is provided at the end - if (!inputName.endsWith('*') || (inputName.match(/\*/g) || []).length > 1) { - throw new Error(`Could not match log group by name ${inputName}: only prefix matching is supported by CloudWatch.`); - } - - const matchingLogGroups = await getLogGroupsByPrefix(inputName.replace(/\*/g, ''), client); - matchingLogGroups.forEach(g => logGroupNames.add(g)); + if ( + !inputName.endsWith("*") || + (inputName.match(/\*/g) || []).length > 1 + ) { + throw new Error( + `Could not match log group by name ${inputName}: only prefix matching is supported by CloudWatch.` + ); + } + + const matchingLogGroups = await getLogGroupsByPrefix( + inputName.replace(/\*/g, ""), + client + ); + matchingLogGroups.forEach((g) => logGroupNames.add(g)); } else { logGroupNames.add(inputName); } } return logGroupNames; -} - +}; diff --git a/src/time.ts b/src/time.ts index cf90357..9be6bbb 100644 --- a/src/time.ts +++ b/src/time.ts @@ -1,9 +1,9 @@ -import parse from 'parse-duration'; +import parse from "parse-duration"; // Attempts to parse a time as a duration or ISO 8601 format, returning a unix timestamp export const parseTimeOrDuration = (timeOrDuration: string): number => { // special case: `now` is now - if (timeOrDuration === 'now') { + if (timeOrDuration === "now") { return new Date().getTime(); } @@ -11,13 +11,15 @@ export const parseTimeOrDuration = (timeOrDuration: string): number => { if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(timeOrDuration)) { const time = Date.parse(timeOrDuration); if (!time) { - throw new Error(`Unable to parse time as ISO 8601 format: ${timeOrDuration}`); + throw new Error( + `Unable to parse time as ISO 8601 format: ${timeOrDuration}` + ); } return time; } // otherwise, try to parse as a duration - const duration = parse(timeOrDuration, 'ms'); + const duration = parse(timeOrDuration, "ms"); if (!duration) { throw new Error(`Unable to parse time as a duration: ${timeOrDuration}`); } diff --git a/src/util.ts b/src/util.ts index 5da0d7a..152f4db 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,6 @@ export const unescapeValue = (v: string): string => { - return v.replace(new RegExp('\\\\"', 'g'),'"') - .replace(new RegExp('\\\\n', 'g'), '\n') - .trim(); -} \ No newline at end of file + return v + .replace(new RegExp('\\\\"', "g"), '"') + .replace(new RegExp("\\\\n", "g"), "\n") + .trim(); +};