diff --git a/docs/commands/logs.md b/docs/commands/logs.md index c456ab699ab..64303d35909 100644 --- a/docs/commands/logs.md +++ b/docs/commands/logs.md @@ -66,13 +66,15 @@ netlify logs:function **Flags** - `filter` (*string*) - For monorepos, specify the name of the application to run the command in +- `level` (*string*) - Log levels to stream. Choices are: trace, debug, info, warn, error, fatal - `debug` (*boolean*) - Print debugging information **Examples** ```bash -netlify logs:function my-function netlify logs:function +netlify logs:function my-function +netlify logs:function my-function -l info warn ``` --- diff --git a/src/commands/logs/functions.mts b/src/commands/logs/functions.mts index d8080d8b5fc..d20eb9cbcd2 100644 --- a/src/commands/logs/functions.mts +++ b/src/commands/logs/functions.mts @@ -1,20 +1,32 @@ -import { Argument, OptionValues } from 'commander' +import { Argument, Option, OptionValues } from 'commander' import inquirer from 'inquirer' import { chalk, log } from '../../utils/command-helpers.mjs' import { getWebSocket } from '../../utils/websockets/index.mjs' import type BaseCommand from '../base-command.mjs' +// Source: Source: https://docs.aws.amazon.com/lambda/latest/dg/monitoring-cloudwatchlogs.html#monitoring-cloudwatchlogs-advanced +export const LOG_LEVELS = { + TRACE: 'TRACE', + DEBUG: 'DEBUG', + INFO: 'INFO', + WARN: 'WARN', + ERROR: 'ERROR', + FATAL: 'FATAL', +} +const LOG_LEVELS_LIST = Object.values(LOG_LEVELS).map((level) => level.toLowerCase()) +const CLI_LOG_LEVEL_CHOICES_STRING = LOG_LEVELS_LIST.map((level) => ` ${level}`) + function getLog(logData: { level: string; message: string }) { let logString = '' switch (logData.level) { - case 'INFO': + case LOG_LEVELS.INFO: logString += chalk.blueBright(logData.level) break - case 'WARN': + case LOG_LEVELS.WARN: logString += chalk.yellowBright(logData.level) break - case 'ERROR': + case LOG_LEVELS.ERROR: logString += chalk.redBright(logData.level) break default: @@ -30,6 +42,12 @@ const logsFunction = async (functionName: string | undefined, options: OptionVal const { site } = command.netlify const { id: siteId } = site + if (options.level && !options.level.every((level: string) => LOG_LEVELS_LIST.includes(level))) { + log(`Invalid log level. Choices are:${CLI_LOG_LEVEL_CHOICES_STRING}`) + } + + const levelsToPrint = options.level || LOG_LEVELS_LIST + const { functions = [] } = await client.searchSiteFunctions({ siteId }) if (functions.length === 0) { @@ -73,6 +91,9 @@ const logsFunction = async (functionName: string | undefined, options: OptionVal ws.on('message', (data: string) => { const logData = JSON.parse(data) + if (!levelsToPrint.includes(logData.level.toLowerCase())) { + return + } log(getLog(logData)) }) @@ -90,7 +111,14 @@ export const createLogsFunctionCommand = (program: BaseCommand) => program .command('logs:function') .alias('logs:functions') + .addOption( + new Option('-l, --level ', `Log levels to stream. Choices are:${CLI_LOG_LEVEL_CHOICES_STRING}`), + ) .addArgument(new Argument('[functionName]', 'Name of the function to stream logs for')) - .addExamples(['netlify logs:function my-function', 'netlify logs:function']) + .addExamples([ + 'netlify logs:function', + 'netlify logs:function my-function', + 'netlify logs:function my-function -l info warn', + ]) .description('(Beta) Stream netlify function logs to the console') .action(logsFunction) diff --git a/tests/integration/commands/logs/functions.test.ts b/tests/integration/commands/logs/functions.test.ts index 16318747b42..8f678201a7f 100644 --- a/tests/integration/commands/logs/functions.test.ts +++ b/tests/integration/commands/logs/functions.test.ts @@ -1,8 +1,9 @@ import { Mock, afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import BaseCommand from '../../../../src/commands/base-command.mjs' -import { createLogsFunctionCommand } from '../../../../src/commands/logs/functions.mjs' +import { LOG_LEVELS, createLogsFunctionCommand } from '../../../../src/commands/logs/functions.mjs' import { getWebSocket } from '../../../../src/utils/websockets/index.mjs' +import { log } from '../../../../src/utils/command-helpers.mjs' import { startMockApi } from '../../utils/mock-api-vitest.ts' import { getEnvironmentVariables } from '../../utils/mock-api.mjs' @@ -10,6 +11,14 @@ vi.mock('../../../../src/utils/websockets/index.mjs', () => ({ getWebSocket: vi.fn(), })) +vi.mock('../../../../src/utils/command-helpers.mjs', async () => { + const actual = await vi.importActual("../../../../src/utils/command-helpers.mjs") + return { + ...actual, + log: vi.fn(), + } +}) + vi.mock('inquirer', () => ({ default: { prompt: vi.fn().mockResolvedValue({ result: 'cool-function' }), @@ -67,7 +76,7 @@ describe('logs:function command', () => { createLogsFunctionCommand(program) }) - test('should setup the functions stream correctly', async ({}) => { + test('should setup the functions stream correctly', async ({ }) => { const { apiUrl } = await startMockApi({ routes }) const spyWebsocket = getWebSocket as unknown as Mock const spyOn = vi.fn() @@ -86,7 +95,7 @@ describe('logs:function command', () => { expect(spyOn).toHaveBeenCalledTimes(4) }) - test('should send the correct payload to the websocket', async ({}) => { + test('should send the correct payload to the websocket', async ({ }) => { const { apiUrl } = await startMockApi({ routes }) const spyWebsocket = getWebSocket as unknown as Mock const spyOn = vi.fn() @@ -117,4 +126,68 @@ describe('logs:function command', () => { expect(body.account_id).toEqual('account') expect(body.access_token).toEqual(env.NETLIFY_AUTH_TOKEN) }) + + test('should print only specified log levels', async ({ }) => { + const { apiUrl } = await startMockApi({ routes }) + const spyWebsocket = getWebSocket as unknown as Mock + const spyOn = vi.fn() + const spySend = vi.fn() + spyWebsocket.mockReturnValue({ + on: spyOn, + send: spySend, + }) + const spyLog = log as unknown as Mock + + const env = getEnvironmentVariables({ apiUrl }) + Object.assign(process.env, env) + + await program.parseAsync(['', '', 'logs:function', '--level', 'info']) + const messageCallback = spyOn.mock.calls.find((args) => args[0] === 'message') + const messageCallbackFunc = messageCallback[1] + const mockInfoData = { + "level": LOG_LEVELS.INFO, + "message": "Hello World", + } + const mockWarnData = { + "level": LOG_LEVELS.WARN, + "message": "There was a warning", + } + + messageCallbackFunc(JSON.stringify(mockInfoData)) + messageCallbackFunc(JSON.stringify(mockWarnData)) + + expect(spyLog).toHaveBeenCalledTimes(1) + }) + + test('should print all the log levels', async ({ }) => { + const { apiUrl } = await startMockApi({ routes }) + const spyWebsocket = getWebSocket as unknown as Mock + const spyOn = vi.fn() + const spySend = vi.fn() + spyWebsocket.mockReturnValue({ + on: spyOn, + send: spySend, + }) + const spyLog = log as unknown as Mock + + const env = getEnvironmentVariables({ apiUrl }) + Object.assign(process.env, env) + + await program.parseAsync(['', '', 'logs:function']) + const messageCallback = spyOn.mock.calls.find((args) => args[0] === 'message') + const messageCallbackFunc = messageCallback[1] + const mockInfoData = { + "level": LOG_LEVELS.INFO, + "message": "Hello World", + } + const mockWarnData = { + "level": LOG_LEVELS.WARN, + "message": "There was a warning", + } + + messageCallbackFunc(JSON.stringify(mockInfoData)) + messageCallbackFunc(JSON.stringify(mockWarnData)) + + expect(spyLog).toHaveBeenCalledTimes(2) + }) })