From 5a8ca639e95b96d4256e967deb8864e0898b9a1f Mon Sep 17 00:00:00 2001 From: sachinsharma Date: Fri, 19 Sep 2025 00:36:02 -0700 Subject: [PATCH] feat(logfmt): strip ANSI escapes by default (#14199) --- pkg/logql/log/parser.go | 10 +++ pkg/logql/log/parser_decolorize_test.go | 96 +++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 pkg/logql/log/parser_decolorize_test.go diff --git a/pkg/logql/log/parser.go b/pkg/logql/log/parser.go index 035241182b234..53f562ffec9e8 100644 --- a/pkg/logql/log/parser.go +++ b/pkg/logql/log/parser.go @@ -374,6 +374,7 @@ func NewLogfmtParser(strict, keepEmpty bool) *LogfmtParser { } func (l *LogfmtParser) Process(_ int64, line []byte, lbs *LabelsBuilder) ([]byte, bool) { + line = StripANSI(line) parserHints := lbs.ParserLabelHints() if parserHints.NoLabels() { return line, true @@ -842,3 +843,12 @@ func (u *UnpackParser) unpack(entry []byte, lbs *LabelsBuilder) ([]byte, error) } return entry, nil } + +var ansiRE = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) + +func StripANSI(b []byte) []byte { + if len(b) == 0 || !bytes.Contains(b, []byte{0x1b, '['}) { + return b + } + return ansiRE.ReplaceAll(b, nil) +} diff --git a/pkg/logql/log/parser_decolorize_test.go b/pkg/logql/log/parser_decolorize_test.go new file mode 100644 index 0000000000000..bbf41274da9f1 --- /dev/null +++ b/pkg/logql/log/parser_decolorize_test.go @@ -0,0 +1,96 @@ +package log + +import ( + "bytes" + "testing" + + "github.com/prometheus/prometheus/model/labels" +) + +func TestStripANSI(t *testing.T) { + cases := []struct { + in []byte + want []byte + }{ + {[]byte("\x1b[32mhello\x1b[0m"), []byte("hello")}, + {[]byte("\x1b[1;31merr\x1b[0m=bad"), []byte("err=bad")}, + {[]byte("clean"), []byte("clean")}, + {[]byte{}, []byte{}}, + } + + for i, tc := range cases { + got := StripANSI(tc.in) + if bytes.Contains(got, []byte{0x1b}) { + t.Fatalf("case %d: expected no ESC in %q", i, got) + } + if !bytes.Equal(got, tc.want) { + t.Fatalf("case %d: got %q, want %q", i, got, tc.want) + } + } +} + +func makeLBS() *LabelsBuilder { + b := NewBaseLabelsBuilder() + baseLabels := labels.Labels{} // or use the actual type for base labels; empty set rather than nil + return b.ForLabels(baseLabels, 0) +} + +func TestLogfmtProcess_StripsANSIFromReturnedLine(t *testing.T) { + p := NewLogfmtParser(false, true) + lbs := makeLBS() + + colored := []byte("\x1b[32mlevel\x1b[0m=info msg=\"ok\"") + gotLine, ok := p.Process(0, colored, lbs) + if !ok { + t.Fatalf("Process returned ok=false for colored logfmt line") + } + if bytes.Contains(gotLine, []byte{0x1b}) { + t.Fatalf("returned line still contained ANSI escapes: %q", gotLine) + } + if !bytes.Contains(gotLine, []byte("level=info")) { + t.Fatalf("returned line lost expected content, got: %q", gotLine) + } +} + +func TestLogfmtProcess_ParsesColoredKeyAndValue(t *testing.T) { + p := NewLogfmtParser(false, true) + lbs := makeLBS() + + line := []byte("\x1b[32mlevel\x1b[0m=info \x1b[36mstatus\x1b[0m=200 msg=\"\x1b[31mhot\x1b[0m\"") + + _, ok := p.Process(0, line, lbs) + if !ok { + t.Fatalf("Process returned ok=false") + } + + m, _ := lbs.Map() + + if got := m["level"]; string(got) != "info" { + t.Fatalf("expected level=info, got %q", got) + } + if got := m["status"]; string(got) != "200" { + t.Fatalf("expected status=200, got %q", got) + } + if got := m["msg"]; string(got) != "hot" { + t.Fatalf("expected msg=hot, got %q", got) + } +} + +func TestLogfmtProcess_NoANSI_Idempotent(t *testing.T) { + p := NewLogfmtParser(true, true) + lbs := makeLBS() + + in := []byte("level=debug a=1 b=\"two words\"") + out, ok := p.Process(0, in, lbs) + if !ok { + t.Fatalf("Process returned ok=false for clean line") + } + if !bytes.Equal(in, out) { + t.Fatalf("expected returned line to equal input for clean lines.\n in=%q\nout=%q", in, out) + } + + m, _ := lbs.Map() + if string(m["level"]) != "debug" || string(m["a"]) != "1" || string(m["b"]) != "two words" { + t.Fatalf("unexpected parsed labels: %#v", m) + } +}