Add ability to enable/disable logs by topic (#40126)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #40124 

# Details

Implements the proposal in
https://docs.google.com/document/d/16qe6oVLKK25nA9GEIPR9Gw_IJ342_wlJRdnWEMmWdas/edit?tab=t.0#heading=h.nlw4agv1xs3g

Allows doing e.g.
```go
logger.WarnContext(logCtx, "The `team_id` param is deprecated, use `fleet_id` instead", "log_topic", "deprecated-field-names")
```
or
```go
if logging.TopicEnabled("deprecated-api-params") {
  logging.WithLevel(ctx, slog.LevelWarn)
  logging.WithExtras(
    ctx, 
    "deprecated_param", 
    queryTagValue,
    "deprecation_warning", 
    fmt.Sprintf("'%s' is deprecated, use '%s'", queryTagValue, renameTo),
  )
}
```

Topics can be disabled at the app level, and enabled/disabled at the
command-line level.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [X] Added/updated automated tests
- [X] QA'd all new/changed functionality manually
No logs have this in prod yet, but I added some manually in a branch and
verified that I could enable/disable them via CLI options and env vars,
including enabling topics that were disabled on the server. Tested for
both server and `fleetctl gitops`.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added per-topic logging control to enable or disable logging for
specific topics via configuration and CLI flags.
* Added context-aware logging methods (ErrorContext, WarnContext,
InfoContext, DebugContext) to support contextual logging.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Scott Gress 2026-02-20 17:22:50 -06:00 committed by GitHub
parent f147a1a607
commit 421dc67e0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 479 additions and 16 deletions

View file

@ -0,0 +1 @@
- Added ability to enable/disable logs by topic

View file

@ -32,6 +32,7 @@ import (
"github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/pkg/scripts"
"github.com/fleetdm/fleet/v4/pkg/str"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/acl/activityacl"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
@ -67,6 +68,7 @@ import (
nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
platform_logging "github.com/fleetdm/fleet/v4/server/platform/logging"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/fleetdm/fleet/v4/server/pubsub"
"github.com/fleetdm/fleet/v4/server/service"
@ -234,6 +236,22 @@ the way that the Fleet server works.
logger := initLogger(config, loggerProvider)
// If you want to disable any logs by default, this is where to do it.
//
// For example:
// platform_logging.DisableTopic("deprecated-api-keys")
// Apply log topic overrides from config. Enables run first, then
// disables, so disable wins on conflict.
// Note that any topic not included in these lists will be considered
// enabled if it's encountered in a log.
for _, topic := range str.SplitAndTrim(config.Logging.EnableLogTopics, ",", true) {
platform_logging.EnableTopic(topic)
}
for _, topic := range str.SplitAndTrim(config.Logging.DisableLogTopics, ",", true) {
platform_logging.DisableTopic(topic)
}
if dev_mode.IsEnabled {
createTestBuckets(&config, logger)
}

View file

@ -1,12 +1,18 @@
package fleetctl
import "github.com/urfave/cli/v2"
import (
"github.com/fleetdm/fleet/v4/pkg/str"
"github.com/fleetdm/fleet/v4/server/platform/logging"
"github.com/urfave/cli/v2"
)
const (
outfileFlagName = "outfile"
debugFlagName = "debug"
fleetCertificateFlagName = "fleet-certificate"
stdoutFlagName = "stdout"
enableLogTopicsFlagName = "enable-log-topics"
disableLogTopicsFlagName = "disable-log-topics"
)
func outfileFlag() cli.Flag {
@ -58,6 +64,41 @@ func getStdout(c *cli.Context) bool {
return c.Bool(stdoutFlagName)
}
func enableLogTopicsFlag() cli.Flag {
return &cli.StringFlag{
Name: enableLogTopicsFlagName,
EnvVars: []string{"FLEET_ENABLE_LOG_TOPICS"},
Usage: "Comma-separated log topics to enable",
}
}
func getEnabledLogTopics(c *cli.Context) string {
return c.String(enableLogTopicsFlagName)
}
func disableLogTopicsFlag() cli.Flag {
return &cli.StringFlag{
Name: disableLogTopicsFlagName,
EnvVars: []string{"FLEET_DISABLE_LOG_TOPICS"},
Usage: "Comma-separated log topics to disable",
}
}
func getDisabledLogTopics(c *cli.Context) string {
return c.String(disableLogTopicsFlagName)
}
// applyLogTopicFlags parses the enable/disable log topic flags and applies them.
// Enables run first, then disables, so disable wins on conflict.
func applyLogTopicFlags(c *cli.Context) {
for _, topic := range str.SplitAndTrim(getEnabledLogTopics(c), ",", true) {
logging.EnableTopic(topic)
}
for _, topic := range str.SplitAndTrim(getDisabledLogTopics(c), ",", true) {
logging.DisableTopic(topic)
}
}
func byHostIdentifier() cli.Flag {
return &cli.StringFlag{
Name: "host",

View file

@ -76,12 +76,17 @@ func gitopsCommand() *cli.Command {
configFlag(),
contextFlag(),
debugFlag(),
enableLogTopicsFlag(),
disableLogTopicsFlag(),
},
Action: func(c *cli.Context) error {
logf := func(format string, a ...interface{}) {
_, _ = fmt.Fprintf(c.App.Writer, format, a...)
}
// Apply log topic overrides from CLI flags.
applyLogTopicFlags(c)
if len(c.Args().Slice()) != 0 {
return errors.New("No positional arguments are allowed. To load multiple config files, use one -f flag per file.")
}

15
pkg/str/str.go Normal file
View file

@ -0,0 +1,15 @@
package str
import "strings"
func SplitAndTrim(s string, delimiter string, removeEmpty bool) []string {
parts := strings.Split(s, delimiter)
cleaned := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if !removeEmpty || part != "" {
cleaned = append(cleaned, part)
}
}
return cleaned
}

88
pkg/str/str_test.go Normal file
View file

@ -0,0 +1,88 @@
package str
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSplitAndTrim(t *testing.T) {
tests := []struct {
name string
testString string
delimiter string
removeEmpty bool
expected []string
}{
{
name: "basic comma split",
testString: "a,b,c",
delimiter: ",",
removeEmpty: false,
expected: []string{"a", "b", "c"},
},
{
name: "trims whitespace",
testString: " a , b , c ",
delimiter: ",",
removeEmpty: false,
expected: []string{"a", "b", "c"},
},
{
name: "keeps empty parts when removeEmpty is false",
testString: "a,,b,,c",
delimiter: ",",
removeEmpty: false,
expected: []string{"a", "", "b", "", "c"},
},
{
name: "removes empty parts when removeEmpty is true",
testString: "a,,b,,c",
delimiter: ",",
removeEmpty: true,
expected: []string{"a", "b", "c"},
},
{
name: "removes whitespace-only parts when removeEmpty is true",
testString: "a, ,b, ,c",
delimiter: ",",
removeEmpty: true,
expected: []string{"a", "b", "c"},
},
{
name: "empty string",
testString: "",
delimiter: ",",
removeEmpty: true,
expected: []string{},
},
{
name: "empty string without removeEmpty",
testString: "",
delimiter: ",",
removeEmpty: false,
expected: []string{""},
},
{
name: "no delimiter found",
testString: "abc",
delimiter: ",",
removeEmpty: false,
expected: []string{"abc"},
},
{
name: "multi-char delimiter",
testString: "a::b::c",
delimiter: "::",
removeEmpty: false,
expected: []string{"a", "b", "c"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SplitAndTrim(tt.testString, tt.delimiter, tt.removeEmpty)
assert.Equal(t, tt.expected, result)
})
}
}

View file

@ -276,7 +276,9 @@ type LoggingConfig struct {
TracingType string `yaml:"tracing_type"`
// OtelLogsEnabled enables exporting logs to an OpenTelemetry collector.
// When enabled, logs are sent to both stderr and the OTLP endpoint.
OtelLogsEnabled bool `yaml:"otel_logs_enabled"`
OtelLogsEnabled bool `yaml:"otel_logs_enabled"`
EnableLogTopics string `yaml:"enable_topics"`
DisableLogTopics string `yaml:"disable_topics"`
}
// ActivityConfig defines configs related to activities.
@ -1322,6 +1324,10 @@ func (man Manager) addConfigs() {
"Select the kind of tracing, defaults to OpenTelemetry, can also be elasticapm")
man.addConfigBool("logging.otel_logs_enabled", false,
"Enable exporting logs to an OpenTelemetry collector (requires tracing_enabled)")
man.addConfigString("logging.enable_topics", "",
"Comma-separated log topics to enable (overrides code defaults)")
man.addConfigString("logging.disable_topics", "",
"Comma-separated log topics to disable (overrides code defaults)")
// Email
man.addConfigString("email.backend", "", "Provide the email backend type, acceptable values are currently \"ses\" and \"default\" or empty string which will default to SMTP")
@ -1752,6 +1758,8 @@ func (man Manager) LoadConfig() FleetConfig {
TracingEnabled: man.getConfigBool("logging.tracing_enabled"),
TracingType: man.getConfigString("logging.tracing_type"),
OtelLogsEnabled: man.getConfigBool("logging.otel_logs_enabled"),
EnableLogTopics: man.getConfigString("logging.enable_topics"),
DisableLogTopics: man.getConfigString("logging.disable_topics"),
},
Firehose: FirehoseConfig{
Region: man.getConfigString("firehose.region"),

View file

@ -106,5 +106,25 @@ func (a *Logger) SlogLogger() *slog.Logger {
return a.logger
}
// Wrap slog's ErrorContext method.
func (a *Logger) ErrorContext(ctx context.Context, msg string, keyvals ...any) {
a.logger.ErrorContext(ctx, msg, keyvals...)
}
// Wrap slog's WarnContext method.
func (a *Logger) WarnContext(ctx context.Context, msg string, keyvals ...any) {
a.logger.WarnContext(ctx, msg, keyvals...)
}
// Wrap slog's InfoContext method.
func (a *Logger) InfoContext(ctx context.Context, msg string, keyvals ...any) {
a.logger.InfoContext(ctx, msg, keyvals...)
}
// Wrap slog's DebugContext method.
func (a *Logger) DebugContext(ctx context.Context, msg string, keyvals ...any) {
a.logger.DebugContext(ctx, msg, keyvals...)
}
// Ensure Logger implements kitlog.Logger at compile time.
var _ kitlog.Logger = (*Logger)(nil)

View file

@ -1,6 +1,7 @@
package logging
import (
"context"
"log/slog"
"testing"
@ -56,7 +57,6 @@ func TestKitlogAdapter(t *testing.T) {
attrs := testutils.RecordAttrs(record)
assert.Equal(t, "test-component", attrs["component"])
})
}
func TestKitlogAdapterLevels(t *testing.T) {
@ -105,3 +105,51 @@ func TestKitlogAdapterLevels(t *testing.T) {
})
}
}
func TestKitlogSlogWrappers(t *testing.T) {
handler := testutils.NewTestHandler()
adapter := NewLogger(slog.New(handler))
tests := []struct {
name string
logFunc func(ctx context.Context, msg string, keyvals ...any)
expectedLevel slog.Level
}{
{
name: "error",
logFunc: adapter.ErrorContext,
expectedLevel: slog.LevelError,
},
{
name: "warn",
logFunc: adapter.WarnContext,
expectedLevel: slog.LevelWarn,
},
{
name: "info",
logFunc: adapter.InfoContext,
expectedLevel: slog.LevelInfo,
},
{
name: "debug",
logFunc: adapter.DebugContext,
expectedLevel: slog.LevelDebug,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.logFunc(context.Background(), tc.name+" message", "key", "value")
record := handler.LastRecord()
require.NotNil(t, record)
assert.Equal(t, tc.name+" message", record.Message)
assert.Equal(t, tc.expectedLevel, record.Level)
attrs := testutils.RecordAttrs(record)
assert.Equal(t, "value", attrs["key"])
})
}
}

View file

@ -74,6 +74,10 @@ func NewSlogLogger(opts Options) *slog.Logger {
handler = NewMultiHandler(handler, otelHandler)
}
// Wrap with topic filter as outermost handler so topic-disabled
// messages are dropped before any other handler processes them.
handler = NewTopicFilterHandler(handler)
return slog.New(handler)
}

View file

@ -0,0 +1,64 @@
package logging
import (
"context"
"log/slog"
)
const logTopicAttrKey = "log_topic"
// TopicFilterHandler is a slog.Handler that filters log records based on
// the log_topic found in the log record's attributes, or on the handler
// itself if set using `WithAttrs`.
type TopicFilterHandler struct {
base slog.Handler
topic string
}
// NewTopicFilterHandler wraps base with topic-aware filtering.
func NewTopicFilterHandler(base slog.Handler) *TopicFilterHandler {
return &TopicFilterHandler{base: base}
}
// Enabled reports whether the handler handles records at the given level.
func (h *TopicFilterHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.base.Enabled(ctx, level)
}
// Handle processes the log record. It performs a defensive re-check of the
// topic before delegating to the base handler.
func (h *TopicFilterHandler) Handle(ctx context.Context, r slog.Record) error {
topic := h.topic
// Override any log_topic attribute on the handler with one from the record's attributes, if present.
r.Attrs(func(a slog.Attr) bool {
if a.Key == logTopicAttrKey {
topic = a.Value.String()
return false
}
return true
})
// Don't log if there's a disabled topic either on the handler or the record.
if topic != "" && !TopicEnabled(topic) {
return nil
}
return h.base.Handle(ctx, r)
}
// WithAttrs returns a new TopicFilterHandler wrapping the base with added attributes.
func (h *TopicFilterHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
// Check if the new attributes include a log_topic that should override the handler's current topic.
for _, a := range attrs {
if a.Key == logTopicAttrKey {
return &TopicFilterHandler{base: h.base.WithAttrs(attrs), topic: a.Value.String()}
}
}
return &TopicFilterHandler{base: h.base.WithAttrs(attrs), topic: h.topic}
}
// WithGroup returns a new TopicFilterHandler wrapping the base with the given group.
func (h *TopicFilterHandler) WithGroup(name string) slog.Handler {
return &TopicFilterHandler{base: h.base.WithGroup(name), topic: h.topic}
}
// Ensure TopicFilterHandler implements slog.Handler at compile time.
var _ slog.Handler = (*TopicFilterHandler)(nil)

View file

@ -0,0 +1,85 @@
package logging
import (
"bytes"
"context"
"log/slog"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTopicTestLogger(buf *bytes.Buffer) *slog.Logger {
handler := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelInfo})
handler2 := NewTopicFilterHandler(handler)
return slog.New(handler2)
}
func TestTopicHandler_NoTopic(t *testing.T) {
t.Cleanup(ResetTopics)
var buf bytes.Buffer
logger := newTopicTestLogger(&buf)
logger.InfoContext(context.Background(), "hello")
assert.Contains(t, buf.String(), "hello")
}
func TestTopicHandler_DisabledTopicByAttr(t *testing.T) {
t.Cleanup(ResetTopics)
var buf bytes.Buffer
logger := newTopicTestLogger(&buf)
DisableTopic("my-topic")
logger.InfoContext(context.Background(), "should not appear", "log_topic", "my-topic")
assert.Empty(t, buf.String())
}
func TestTopicHandler_DisabledTopicByWithAttr(t *testing.T) {
t.Cleanup(ResetTopics)
var buf bytes.Buffer
logger := newTopicTestLogger(&buf)
DisableTopic("my-topic")
logger = logger.With("log_topic", "my-topic")
logger.InfoContext(context.Background(), "should not appear")
assert.Empty(t, buf.String())
// Test overriding the handler topic with a different per-log topic.
logger.InfoContext(context.Background(), "should appear", "log_topic", "other-topic")
assert.Contains(t, buf.String(), "should appear")
}
func TestTopicHandler_RespectsBaseLevel(t *testing.T) {
t.Cleanup(ResetTopics)
var buf bytes.Buffer
// Base handler at Info level — Debug messages should be dropped regardless of topic.
handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})
logger := slog.New(NewTopicFilterHandler(handler))
logger.DebugContext(context.Background(), "debug message")
assert.Empty(t, buf.String())
}
func TestTopicHandler_WithAttrsPassesThrough(t *testing.T) {
t.Cleanup(ResetTopics)
var buf bytes.Buffer
logger := newTopicTestLogger(&buf)
logger = logger.With("key", "value")
logger.InfoContext(context.Background(), "with attrs")
output := buf.String()
assert.Contains(t, output, "with attrs")
assert.Contains(t, output, "key=value")
}
func TestTopicHandler_WithGroupPassesThrough(t *testing.T) {
t.Cleanup(ResetTopics)
var buf bytes.Buffer
logger := newTopicTestLogger(&buf)
logger = logger.WithGroup("grp")
logger.InfoContext(context.Background(), "with group", "k", "v")
output := buf.String()
require.Contains(t, output, "grp.k=v")
}

View file

@ -0,0 +1,42 @@
package logging
import (
"sync"
)
// disabledTopics tracks which topics have been explicitly disabled.
// Topics are enabled by default — only topics in this map are disabled.
var (
disabledTopics = make(map[string]bool)
disabledTopicsMu sync.RWMutex
)
// EnableTopic marks a topic as enabled (removes it from the disabled set).
func EnableTopic(name string) {
disabledTopicsMu.Lock()
delete(disabledTopics, name)
disabledTopicsMu.Unlock()
}
// DisableTopic marks a topic as disabled.
func DisableTopic(name string) {
disabledTopicsMu.Lock()
disabledTopics[name] = true
disabledTopicsMu.Unlock()
}
// TopicEnabled returns true unless the topic has been explicitly disabled.
func TopicEnabled(name string) bool {
disabledTopicsMu.RLock()
disabled := disabledTopics[name]
disabledTopicsMu.RUnlock()
return !disabled
}
// ResetTopics clears all disabled topics, re-enabling everything.
// This is intended for use in tests to ensure isolation.
func ResetTopics() {
disabledTopicsMu.Lock()
disabledTopics = make(map[string]bool)
disabledTopicsMu.Unlock()
}

View file

@ -0,0 +1,35 @@
package logging
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTopicEnabledByDefault(t *testing.T) {
t.Cleanup(ResetTopics)
assert.True(t, TopicEnabled("unknown-topic"))
}
func TestDisableTopic(t *testing.T) {
t.Cleanup(ResetTopics)
DisableTopic("my-topic")
assert.False(t, TopicEnabled("my-topic"))
}
func TestEnableTopicReenables(t *testing.T) {
t.Cleanup(ResetTopics)
DisableTopic("my-topic")
assert.False(t, TopicEnabled("my-topic"))
EnableTopic("my-topic")
assert.True(t, TopicEnabled("my-topic"))
}
func TestResetTopics(t *testing.T) {
t.Cleanup(ResetTopics)
DisableTopic("a")
DisableTopic("b")
ResetTopics()
assert.True(t, TopicEnabled("a"))
assert.True(t, TopicEnabled("b"))
}

View file

@ -15,6 +15,7 @@ import (
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/str"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
@ -2552,7 +2553,7 @@ func directIngestMunkiInfo(ctx context.Context, logger *slog.Logger, host *fleet
}
errors, warnings := rows[0]["errors"], rows[0]["warnings"]
errList, warnList := splitCleanSemicolonSeparated(errors), splitCleanSemicolonSeparated(warnings)
errList, warnList := str.SplitAndTrim(errors, ";", true), str.SplitAndTrim(warnings, ";", true)
return ds.SetOrUpdateMunkiInfo(ctx, host.ID, rows[0]["version"], errList, warnList)
}
@ -3199,18 +3200,6 @@ func GetDetailQueries(
return generatedMap
}
func splitCleanSemicolonSeparated(s string) []string {
parts := strings.Split(s, ";")
cleaned := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
cleaned = append(cleaned, part)
}
}
return cleaned
}
func buildConfigProfilesWindowsQuery(
ctx context.Context,
logger *slog.Logger,