From 20babc2e08ccea7368a9d902b90b74e166c6a569 Mon Sep 17 00:00:00 2001 From: Jon Edvald Date: Fri, 3 Sep 2021 21:03:18 +0200 Subject: [PATCH] feat(logs): allow filtering log lines by tag The new `--tag` option for the `garden logs` command can be used to show only log lines that match a given tag, e.g. `--tag 'container=foo'`. If multiple filters are specified in a single tag option (e.g. `--tag 'container=foo,someOtherTag=bar'`), they must all be matched. If multiple `--tag` options are provided (e.g. `--tag 'container=api' --tag 'container=frontend'`), they will be OR-ed together (i.e. if any of them match, the log line will be included). Glob-style wildcards, can also be used (e.g. `--tag 'container=prefix-*'`). --- core/src/cli/params.ts | 17 ++ core/src/commands/logs.ts | 99 ++++++- core/src/plugins/kubernetes/logs.ts | 44 ++- .../types/plugin/service/getServiceLogs.ts | 7 +- core/test/unit/src/commands/logs.ts | 267 ++++++++++++++++-- docs/reference/commands.md | 21 +- 6 files changed, 396 insertions(+), 59 deletions(-) diff --git a/core/src/cli/params.ts b/core/src/cli/params.ts index f7b3203733..d9ce7075d3 100644 --- a/core/src/cli/params.ts +++ b/core/src/cli/params.ts @@ -290,6 +290,23 @@ export class BooleanParameter extends Parameter { } } +/** + * Similar to `StringsOption`, but doesn't split individual option values on `,` + */ +export class TagsOption extends Parameter { + type = "array:tag" + schema = joi.array().items(joi.string()) + + coerce(input?: string | string[]): string[] { + if (!input) { + return [] + } else if (!isArray(input)) { + input = [input] + } + return input + } +} + export class EnvironmentOption extends StringParameter { type = "string" schema = joi.environment() diff --git a/core/src/commands/logs.ts b/core/src/commands/logs.ts index 61546613b6..cfe76a5b8f 100644 --- a/core/src/commands/logs.ts +++ b/core/src/commands/logs.ts @@ -6,21 +6,24 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import dotenv = require("dotenv") import { Command, CommandResult, CommandParams, PrepareParams } from "./base" import chalk from "chalk" -import { sortBy } from "lodash" +import { every, some, sortBy } from "lodash" import { ServiceLogEntry } from "../types/plugin/service/getServiceLogs" import Bluebird = require("bluebird") import { GardenService } from "../types/service" import Stream from "ts-stream" import { LoggerType, logLevelMap, LogLevel, parseLogLevel } from "../logger/logger" -import { StringsParameter, BooleanParameter, IntegerParameter, DurationParameter } from "../cli/params" +import { StringsParameter, BooleanParameter, IntegerParameter, DurationParameter, TagsOption } from "../cli/params" import { printHeader, renderDivider } from "../logger/util" import stripAnsi = require("strip-ansi") import hasAnsi = require("has-ansi") -import { dedent } from "../util/string" +import { dedent, deline } from "../util/string" import { padSection } from "../logger/renderers" import { PluginEventBroker } from "../plugin-context" +import { ParameterError } from "../exceptions" +import { isMatch } from "micromatch" const logsArgs = { services: new StringsParameter({ @@ -31,26 +34,40 @@ const logsArgs = { } const logsOpts = { + "tag": new TagsOption({ + help: deline` + Only show log lines that match the given tag, e.g. \`--tag 'container=foo'\`. If you specify multiple filters + in a single tag option (e.g. \`--tag 'container=foo,someOtherTag=bar'\`), they must all be matched. If you + provide multiple \`--tag\` options (e.g. \`--tag 'container=api' --tag 'container=frontend'\`), they will be OR-ed + together (i.e. if any of them match, the log line will be included). You can specify glob-style wildcards, e.g. + \`--tag 'container=prefix-*'\`. + `, + }), "follow": new BooleanParameter({ help: "Continuously stream new logs from the service(s).", alias: "f", }), "tail": new IntegerParameter({ - help: dedent` + help: deline` Number of lines to show for each service. Defaults to showing all log lines (up to a certain limit). Takes precedence over the \`--since\` flag if both are set. Note that we don't recommend using a large value here when in follow mode. `, alias: "t", }), + // DEPRECATED, remove in 0.13 in favor of --show-tags "show-container": new BooleanParameter({ help: "Show the name of the container with log output. May not apply to all providers", defaultValue: false, }), + "show-tags": new BooleanParameter({ + help: "Show any tags attached to each log line. May not apply to all providers", + defaultValue: false, + }), "timestamps": new BooleanParameter({ help: "Show timestamps with log output.", }), "since": new DurationParameter({ - help: dedent` + help: deline` Only show logs newer than a relative duration like 5s, 2m, or 3h. Defaults to \`"1m"\` when \`--follow\` is true unless \`--tail\` is set. Note that we don't recommend using a large value here when in follow mode. `, @@ -70,6 +87,10 @@ type Opts = typeof logsOpts export const colors = ["green", "cyan", "magenta", "yellow", "blueBright", "red"] +type LogsTagFilter = [string, string] +type LogsTagAndFilter = LogsTagFilter[] +type LogsTagOrFilter = LogsTagAndFilter[] + /** * Skip empty entries. */ @@ -88,12 +109,13 @@ export class LogsCommand extends Command { Examples: - garden logs # interleaves color-coded logs from all services (up to a certain limit) - garden logs --since 2d # interleaves color-coded logs from all services from the last 2 days - garden logs --tail 100 # interleaves the last 100 log lines from all services - garden logs service-a,service-b # interleaves color-coded logs for service-a and service-b - garden logs --follow # keeps running and streams all incoming logs to the console - garden logs --original-color # interleaves logs from all services and prints the original output color + garden logs # interleaves color-coded logs from all services (up to a certain limit) + garden logs --since 2d # interleaves color-coded logs from all services from the last 2 days + garden logs --tail 100 # interleaves the last 100 log lines from all services + garden logs service-a,service-b # interleaves color-coded logs for service-a and service-b + garden logs --follow # keeps running and streams all incoming logs to the console + garden logs --tag container=service-a # only shows logs from containers with names matching the pattern + garden logs --original-color # interleaves logs from all services and prints the original output color ` arguments = logsArgs @@ -118,14 +140,17 @@ export class LogsCommand extends Command { } async action({ garden, log, args, opts }: CommandParams): Promise> { - const { follow, timestamps } = opts + const { follow, timestamps, tag } = opts let tail = opts.tail as number | undefined let since = opts.since as string | undefined const originalColor = opts["original-color"] const showContainer = opts["show-container"] + const showTags = opts["show-tags"] const hideService = opts["hide-service"] const logLevel = parseLogLevel(opts["log-level"]) + let tagFilters: LogsTagOrFilter | undefined = undefined + if (tail) { // Tail takes precedence over since... since = undefined @@ -134,6 +159,23 @@ export class LogsCommand extends Command { since = "1m" } + if (tag && tag.length > 0) { + const parameterErrorMsg = `Unable to parse the given --tag flags. Format should be key=value.` + try { + tagFilters = tag.map((tagGroup: string) => { + return tagGroup.split(",").map((t: string) => { + const parsed = Object.entries(dotenv.parse(t))[0] + if (!parsed) { + throw new ParameterError(parameterErrorMsg, { tags: tag }) + } + return parsed + }) + }) + } catch { + throw new ParameterError(parameterErrorMsg, { tags: tag }) + } + } + const graph = await garden.getConfigGraph({ log, emit: false }) const allServices = graph.getServices() const services = args.services ? allServices.filter((s) => args.services?.includes(s.name)) : allServices @@ -170,6 +212,19 @@ export class LogsCommand extends Command { return acc }, {}) + const matchTagFilters = (entry: ServiceLogEntry): boolean => { + if (!tagFilters) { + return true + } + // We OR together the filter results of each tag option instance. + return some(tagFilters, (andFilter: LogsTagAndFilter) => { + // We AND together the filter results within a given tag option instance. + return every(andFilter, ([key, value]: LogsTagFilter) => { + return isMatch(entry.tags?.[key] || "", value) + }) + }) + } + const formatEntry = (entry: ServiceLogEntry) => { const style = chalk[colorMap[entry.serviceName]] const sectionStyle = style.bold @@ -178,6 +233,7 @@ export class LogsCommand extends Command { let timestamp: string | undefined let container: string | undefined + let tags: string | undefined if (timestamps && entry.timestamp) { timestamp = " " @@ -186,8 +242,15 @@ export class LogsCommand extends Command { } catch {} } - if (showContainer && entry.containerName) { - container = entry.containerName + // DEPRECATED, remove in 0.13 in favor of --show-tags + if (showContainer && entry.tags?.container) { + container = entry.tags.container + } + + if (showTags && entry.tags) { + tags = Object.entries(entry.tags) + .map(([k, v]) => `${k}=${v}`) + .join(" ") } if (entryLevel <= logLevel) { @@ -205,6 +268,9 @@ export class LogsCommand extends Command { if (timestamp) { out += `${chalk.gray(timestamp)} → ` } + if (tags) { + out += chalk.gray("[" + tags + "] ") + } if (originalColor) { // If the line doesn't have ansi encoding, we color it white to prevent logger from applying styles. out += hasAnsi(serviceLog) ? serviceLog : chalk.white(serviceLog) @@ -221,6 +287,11 @@ export class LogsCommand extends Command { return } + // Match against all of the specified filters, if any + if (!matchTagFilters(entry)) { + return + } + if (follow) { const levelStr = logLevelMap[entry.level || LogLevel.info] || "info" const msg = formatEntry(entry) diff --git a/core/src/plugins/kubernetes/logs.ts b/core/src/plugins/kubernetes/logs.ts index 7d50119b49..7664bcb5e5 100644 --- a/core/src/plugins/kubernetes/logs.ts +++ b/core/src/plugins/kubernetes/logs.ts @@ -115,9 +115,9 @@ async function readLogs({ try { const [timestampStr, msg] = splitFirst(line, " ") const timestamp = moment(timestampStr).toDate() - return { ...res, timestamp, msg } + return makeServiceLogEntry({ ...res, timestamp, msg }) } catch { - return { ...res, msg: line } + return makeServiceLogEntry({ ...res, msg: line }) } }) }) @@ -430,13 +430,39 @@ export class K8sLogFollower { level?: LogLevel timestamp?: Date }) { - void this.stream.write({ - serviceName: this.service.name, - timestamp, - msg, - containerName, - level, - }) + void this.stream.write( + makeServiceLogEntry({ + serviceName: this.service.name, + timestamp, + msg, + level, + containerName, + }) + ) + } +} + +function makeServiceLogEntry({ + serviceName, + msg, + containerName, + level, + timestamp, +}: { + serviceName: string + msg: string + containerName?: string + level?: LogLevel + timestamp?: Date +}): ServiceLogEntry { + return { + serviceName, + timestamp, + msg, + level, + tags: { + container: containerName || "", + }, } } diff --git a/core/src/types/plugin/service/getServiceLogs.ts b/core/src/types/plugin/service/getServiceLogs.ts index 16930d2ed9..0279a058fc 100644 --- a/core/src/types/plugin/service/getServiceLogs.ts +++ b/core/src/types/plugin/service/getServiceLogs.ts @@ -11,9 +11,8 @@ import { PluginServiceActionParamsBase, serviceActionParamsSchema } from "../bas import { dedent } from "../../../util/string" import { GardenModule } from "../../module" import { runtimeContextSchema } from "../../../runtime-context" -import { joi } from "../../../config/common" +import { joi, joiStringMap } from "../../../config/common" import { LogLevel } from "../../../logger/logger" -import { string } from "@hapi/joi" export interface GetServiceLogsParams extends PluginServiceActionParamsBase { @@ -29,7 +28,7 @@ export interface ServiceLogEntry { timestamp?: Date msg: string level?: LogLevel - containerName?: string + tags?: { [key: string]: string } } export const serviceLogEntrySchema = () => @@ -39,13 +38,13 @@ export const serviceLogEntrySchema = () => serviceName: joi.string().required().description("The name of the service the log entry originated from."), timestamp: joi.date().required().description("The time when the log entry was generated by the service."), msg: joi.string().required().description("The content of the log entry."), - containerName: string().description("The name of the container the service runs, if appicable."), level: joi.string().description( dedent` The log level of the entry. The 'info' level should be reserved for logs from the service proper. Other levels can be used to print warnings or debug information from the plugin. ` ), + tags: joiStringMap(joi.string()).description("Tags used for later filtering in the logs command."), }) .description("A log entry returned by a getServiceLogs action handler.") diff --git a/core/test/unit/src/commands/logs.ts b/core/test/unit/src/commands/logs.ts index b33c7812f6..41c71eeb58 100644 --- a/core/test/unit/src/commands/logs.ts +++ b/core/test/unit/src/commands/logs.ts @@ -13,15 +13,16 @@ import { colors, LogsCommand } from "../../../../src/commands/logs" import { joi } from "../../../../src/config/common" import { ProjectConfig, defaultNamespace } from "../../../../src/config/project" import { createGardenPlugin, GardenPlugin } from "../../../../src/types/plugin/plugin" -import { GetServiceLogsParams } from "../../../../src/types/plugin/service/getServiceLogs" +import { GetServiceLogsParams, ServiceLogEntry } from "../../../../src/types/plugin/service/getServiceLogs" import { TestGarden } from "../../../../src/util/testing" -import { projectRootA, withDefaultGlobalOpts } from "../../../helpers" +import { expectError, projectRootA, withDefaultGlobalOpts } from "../../../helpers" import execa from "execa" import { DEFAULT_API_VERSION } from "../../../../src/constants" import { formatForTerminal } from "../../../../src/logger/renderers" import chalk from "chalk" import { LogEntry } from "../../../../src/logger/log-entry" import { LogLevel } from "../../../../src/logger/logger" +import { ModuleConfig } from "../../../../src/config/module" function makeCommandParams({ garden, @@ -104,7 +105,7 @@ describe("LogsCommand", () => { const color = chalk[colors[0]] const defaultGetServiceLogsHandler = async ({ stream }: GetServiceLogsParams) => { void stream.write({ - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-a", msg: logMsgWithColor, timestamp, @@ -146,7 +147,7 @@ describe("LogsCommand", () => { expect(res).to.eql({ result: [ { - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-a", msg: logMsgWithColor, timestamp, @@ -157,25 +158,25 @@ describe("LogsCommand", () => { it("should sort entries by timestamp", async () => { const getServiceLogsHandler = async ({ stream }: GetServiceLogsParams) => { void stream.write({ - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-a", msg: "3", timestamp: new Date("2021-05-13T20:03:00.000Z"), }) void stream.write({ - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-a", msg: "4", timestamp: new Date("2021-05-13T20:04:00.000Z"), }) void stream.write({ - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-a", msg: "2", timestamp: new Date("2021-05-13T20:02:00.000Z"), }) void stream.write({ - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-a", msg: "1", timestamp: new Date("2021-05-13T20:01:00.000Z"), @@ -190,25 +191,25 @@ describe("LogsCommand", () => { expect(res).to.eql({ result: [ { - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-a", msg: "1", timestamp: new Date("2021-05-13T20:01:00.000Z"), }, { - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-a", msg: "2", timestamp: new Date("2021-05-13T20:02:00.000Z"), }, { - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-a", msg: "3", timestamp: new Date("2021-05-13T20:03:00.000Z"), }, { - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-a", timestamp: new Date("2021-05-13T20:04:00.000Z"), msg: "4", @@ -220,14 +221,14 @@ describe("LogsCommand", () => { const getServiceLogsHandler = async ({ stream }: GetServiceLogsParams) => { // Empty message and invalid date void stream.write({ - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-a", msg: "", timestamp: new Date(""), }) // Empty message and empty date void stream.write({ - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-a", msg: "", timestamp: undefined, @@ -295,33 +296,33 @@ describe("LogsCommand", () => { const getServiceLogsHandler = async ({ stream, service }: GetServiceLogsParams) => { if (service.name === "a-short") { void stream.write({ - containerName: "short", + tags: { container: "short" }, serviceName: "a-short", msg: logMsgWithColor, timestamp: new Date("2021-05-13T20:01:00.000Z"), // <--- 1 }) void stream.write({ - containerName: "short", + tags: { container: "short" }, serviceName: "a-short", msg: logMsgWithColor, timestamp: new Date("2021-05-13T20:03:00.000Z"), // <--- 3 }) void stream.write({ - containerName: "short", + tags: { container: "short" }, serviceName: "a-short", msg: logMsgWithColor, timestamp: new Date("2021-05-13T20:06:00.000Z"), // <--- 6 }) } else if (service.name === "b-not-short") { void stream.write({ - containerName: "not-short", + tags: { container: "not-short" }, serviceName: "b-not-short", msg: logMsgWithColor, timestamp: new Date("2021-05-13T20:02:00.000Z"), // <--- 2 }) } else if (service.name === "c-by-far-the-longest-of-the-bunch") { void stream.write({ - containerName: "by-far-the-longest-of-the-bunch", + tags: { container: "by-far-the-longest-of-the-bunch" }, serviceName: "c-by-far-the-longest-of-the-bunch", msg: logMsgWithColor, timestamp: new Date("2021-05-13T20:04:00.000Z"), // <--- 4 @@ -329,7 +330,7 @@ describe("LogsCommand", () => { }) } else if (service.name === "d-very-very-long") { void stream.write({ - containerName: "very-very-long", + tags: { container: "very-very-long" }, serviceName: "d-very-very-long", msg: logMsgWithColor, timestamp: new Date("2021-05-13T20:05:00.000Z"), // <--- 5 @@ -403,14 +404,14 @@ describe("LogsCommand", () => { const getServiceLogsHandler = async ({ stream, service }: GetServiceLogsParams) => { if (service.name === "test-service-a") { void stream.write({ - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-a", msg: logMsg, timestamp: new Date("2021-05-13T20:00:00.000Z"), }) } else { void stream.write({ - containerName: "my-container", + tags: { container: "my-container" }, serviceName: "test-service-b", msg: logMsg, timestamp: new Date("2021-05-13T20:01:00.000Z"), @@ -459,5 +460,227 @@ describe("LogsCommand", () => { // Assert that the service gets the "second" color, even though its the only one we're fetching logs for. expect(out[0]).to.eql(`${color2.bold("test-service-b")} → ${color2("Yes, this is log")}`) }) + + const moduleConfigsForTags = (): ModuleConfig[] => [ + { + apiVersion: DEFAULT_API_VERSION, + name: "test", + type: "test", + allowPublish: false, + disabled: false, + build: { dependencies: [] }, + path: tmpDir.path, + serviceConfigs: [ + { + name: "api", + dependencies: [], + disabled: false, + hotReloadable: false, + spec: {}, + }, + { + name: "frontend", + dependencies: [], + disabled: false, + hotReloadable: false, + spec: {}, + }, + ], + taskConfigs: [], + testConfigs: [], + spec: { bla: "fla" }, + }, + ] + + it("should optionally print tags with --show-tags", async () => { + const getServiceLogsHandler = async ({ stream }: GetServiceLogsParams) => { + void stream.write({ + tags: { container: "api" }, + serviceName: "api", + msg: logMsg, + timestamp: new Date(), + }) + return {} + } + const garden = await makeGarden(tmpDir, makeTestPlugin(getServiceLogsHandler)) + garden.setModuleConfigs(moduleConfigsForTags()) + + const command = new LogsCommand() + await command.action(makeCommandParams({ garden, opts: { "show-tags": true } })) + const out = getLogOutput(garden, logMsg) + + expect(out[0]).to.eql(`${color.bold("api")} → ${chalk.gray("[container=api] ")}${color("Yes, this is log")}`) + }) + + // These tests use tags as emitted by `container`/`kubernetes`/`helm` services, which use the `container` tag. + const filterByTag = (entries: ServiceLogEntry[], tag: string): ServiceLogEntry[] => { + return entries.filter((e: ServiceLogEntry) => e.tags!["container"] === tag) + } + + it("should apply a basic --tag filter", async () => { + const getServiceLogsHandler = async ({ stream }: GetServiceLogsParams) => { + void stream.write({ + tags: { container: "api" }, + serviceName: "api", + msg: logMsg, + timestamp: new Date(), + }) + void stream.write({ + tags: { container: "frontend" }, + serviceName: "frontend", + msg: logMsg, + timestamp: new Date(), + }) + return {} + } + const garden = await makeGarden(tmpDir, makeTestPlugin(getServiceLogsHandler)) + garden.setModuleConfigs(moduleConfigsForTags()) + + const command = new LogsCommand() + const res = await command.action(makeCommandParams({ garden, opts: { tag: ["container=api"] } })) + + expect(filterByTag(res.result!, "api").length).to.eql(2) + expect(filterByTag(res.result!, "frontend").length).to.eql(0) + }) + + it("should throw when passed an invalid --tag filter", async () => { + const getServiceLogsHandler = async ({ stream }: GetServiceLogsParams) => { + void stream.write({ + tags: { container: "api-main" }, + serviceName: "api", + msg: logMsg, + timestamp: new Date(), + }) + return {} + } + const garden = await makeGarden(tmpDir, makeTestPlugin(getServiceLogsHandler)) + garden.setModuleConfigs(moduleConfigsForTags()) + + const command = new LogsCommand() + await expectError( + () => command.action(makeCommandParams({ garden, opts: { tag: ["*-main"] } })), + (err) => expect(err.message).to.eql("Unable to parse the given --tag flags. Format should be key=value.") + ) + }) + + it("should AND together tag filters in a given --tag option instance", async () => { + const getServiceLogsHandler = async ({ stream }: GetServiceLogsParams) => { + void stream.write({ + tags: { container: "api", myTag: "1" }, + serviceName: "api", + msg: logMsg, + timestamp: new Date(), + }) + void stream.write({ + tags: { container: "api", myTag: "2" }, + serviceName: "api", + msg: logMsg, + timestamp: new Date(), + }) + void stream.write({ + tags: { container: "frontend", myTag: "1" }, + serviceName: "frontend", + msg: logMsg, + timestamp: new Date(), + }) + return {} + } + const garden = await makeGarden(tmpDir, makeTestPlugin(getServiceLogsHandler)) + garden.setModuleConfigs(moduleConfigsForTags()) + + const command = new LogsCommand() + const res = await command.action(makeCommandParams({ garden, opts: { tag: ["container=api,myTag=1"] } })) + + const matching = filterByTag(res.result!, "api") + expect(matching.length).to.eql(2) // The same log line is emitted for each service in this test setup (here: 2) + expect(matching[0].tags).to.eql({ container: "api", myTag: "1" }) + expect(matching[1].tags).to.eql({ container: "api", myTag: "1" }) + }) + + it("should OR together tag filters from all provided --tag option instances", async () => { + const getServiceLogsHandler = async ({ stream }: GetServiceLogsParams) => { + void stream.write({ + tags: { container: "api", myTag: "1" }, + serviceName: "api", + msg: logMsg, + timestamp: new Date(), + }) + void stream.write({ + tags: { container: "api", myTag: "2" }, + serviceName: "api", + msg: logMsg, + timestamp: new Date(), + }) + void stream.write({ + tags: { container: "frontend", myTag: "1" }, + serviceName: "frontend", + msg: logMsg, + timestamp: new Date(), + }) + void stream.write({ + tags: { container: "frontend", myTag: "2" }, + serviceName: "frontend", + msg: logMsg, + timestamp: new Date(), + }) + return {} + } + const garden = await makeGarden(tmpDir, makeTestPlugin(getServiceLogsHandler)) + garden.setModuleConfigs(moduleConfigsForTags()) + + const command = new LogsCommand() + const res = await command.action( + makeCommandParams({ garden, opts: { tag: ["container=api,myTag=1", "container=frontend"] } }) + ) + + const apiMatching = filterByTag(res.result!, "api") + const frontendMatching = filterByTag(res.result!, "frontend") + expect(apiMatching.length).to.eql(2) // The same log line is emitted for each service in this test setup (here: 2) + expect(apiMatching[0].tags).to.eql({ container: "api", myTag: "1" }) + expect(apiMatching[1].tags).to.eql({ container: "api", myTag: "1" }) + expect(frontendMatching.length).to.eql(4) + expect(frontendMatching[0].tags).to.eql({ container: "frontend", myTag: "1" }) + expect(frontendMatching[1].tags).to.eql({ container: "frontend", myTag: "2" }) + }) + + it("should apply a wildcard --tag filter", async () => { + const getServiceLogsHandler = async ({ stream }: GetServiceLogsParams) => { + void stream.write({ + tags: { container: "api-main" }, + serviceName: "api", + msg: logMsg, + timestamp: new Date(), + }) + void stream.write({ + tags: { container: "api-sidecar" }, + serviceName: "api", + msg: logMsg, + timestamp: new Date(), + }) + void stream.write({ + tags: { container: "frontend-main" }, + serviceName: "frontend", + msg: logMsg, + timestamp: new Date(), + }) + void stream.write({ + tags: { container: "frontend-sidecar" }, + serviceName: "frontend", + msg: logMsg, + timestamp: new Date(), + }) + return {} + } + const garden = await makeGarden(tmpDir, makeTestPlugin(getServiceLogsHandler)) + garden.setModuleConfigs(moduleConfigsForTags()) + + const command = new LogsCommand() + const res = await command.action(makeCommandParams({ garden, opts: { tag: ["container=*-main"] } })) + + expect(filterByTag(res.result!, "api-main").length).to.eql(2) + expect(filterByTag(res.result!, "frontend-main").length).to.eql(2) + expect(filterByTag(res.result!, "api-sidecar").length).to.eql(0) + expect(filterByTag(res.result!, "frontend-sidecar").length).to.eql(0) + }) }) }) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 0931e0c683..dc0bdef24b 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -2768,12 +2768,13 @@ to getting logs from the last minute when in `--follow` mode. You can change thi Examples: - garden logs # interleaves color-coded logs from all services (up to a certain limit) - garden logs --since 2d # interleaves color-coded logs from all services from the last 2 days - garden logs --tail 100 # interleaves the last 100 log lines from all services - garden logs service-a,service-b # interleaves color-coded logs for service-a and service-b - garden logs --follow # keeps running and streams all incoming logs to the console - garden logs --original-color # interleaves logs from all services and prints the original output color + garden logs # interleaves color-coded logs from all services (up to a certain limit) + garden logs --since 2d # interleaves color-coded logs from all services from the last 2 days + garden logs --tail 100 # interleaves the last 100 log lines from all services + garden logs service-a,service-b # interleaves color-coded logs for service-a and service-b + garden logs --follow # keeps running and streams all incoming logs to the console + garden logs --tag container=service-a # only shows logs from containers with names matching the pattern + garden logs --original-color # interleaves logs from all services and prints the original output color | Supported in workflows | | | ---------------------- |---| @@ -2793,13 +2794,13 @@ Examples: | Argument | Alias | Type | Description | | -------- | ----- | ---- | ----------- | + | `--tag` | | array:tag | Only show log lines that match the given tag, e.g. `--tag 'container=foo'`. If you specify multiple filters in a single tag option (e.g. `--tag 'container=foo,someOtherTag=bar'`), they must all be matched. If you provide multiple `--tag` options (e.g. `--tag 'container=api' --tag 'container=frontend'`), they will be OR-ed together (i.e. if any of them match, the log line will be included). You can specify glob-style wildcards, e.g. `--tag 'container=prefix-*'`. | `--follow` | `-f` | boolean | Continuously stream new logs from the service(s). - | `--tail` | `-t` | number | Number of lines to show for each service. Defaults to showing all log lines (up to a certain limit). Takes precedence over -the `--since` flag if both are set. Note that we don't recommend using a large value here when in follow mode. + | `--tail` | `-t` | number | Number of lines to show for each service. Defaults to showing all log lines (up to a certain limit). Takes precedence over the `--since` flag if both are set. Note that we don't recommend using a large value here when in follow mode. | `--show-container` | | boolean | Show the name of the container with log output. May not apply to all providers + | `--show-tags` | | boolean | Show any tags attached to each log line. May not apply to all providers | `--timestamps` | | boolean | Show timestamps with log output. - | `--since` | | moment | Only show logs newer than a relative duration like 5s, 2m, or 3h. Defaults to `"1m"` when `--follow` is true -unless `--tail` is set. Note that we don't recommend using a large value here when in follow mode. + | `--since` | | moment | Only show logs newer than a relative duration like 5s, 2m, or 3h. Defaults to `"1m"` when `--follow` is true unless `--tail` is set. Note that we don't recommend using a large value here when in follow mode. | `--original-color` | | boolean | Show the original color output of the logs instead of color coding them. | `--hide-service` | | boolean | Hide the service name and render the logs directly.