diff --git a/tracer/labels.go b/tracer/labels.go new file mode 100644 index 000000000..40e03935a --- /dev/null +++ b/tracer/labels.go @@ -0,0 +1,88 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package tracer // import "go.opentelemetry.io/ebpf-profiler/tracer" + +import ( + "bytes" + "sync/atomic" + "unicode/utf8" + + "go.opentelemetry.io/ebpf-profiler/metrics" +) + +// customLabelValidator validates custom label keys and values extracted from +// eBPF and tracks how many are dropped due to invalid UTF-8. The zero value is +// ready to use. Methods take pointer receivers (atomic ops require an +// addressable counter), so embed as a value field on a struct held by pointer. +type customLabelValidator struct { + droppedInvalidName atomic.Int64 + droppedInvalidValue atomic.Int64 +} + +// cstring returns the prefix of buf up to (but not including) the first NUL +// byte, or all of buf if no NUL is present. Suitable for fixed-size buffers +// populated from eBPF. +func cstring(buf []byte) []byte { + if i := bytes.IndexByte(buf, 0); i >= 0 { + return buf[:i] + } + return buf +} + +// validateKey enforces strict UTF-8 validity on a custom label key. Any invalid +// byte (or an empty key) returns ok=false and bumps the drop counter, signaling +// the caller to drop the label. A corrupted key would silently group unrelated +// samples under a garbage name, so strictness is intentional here. The returned +// slice aliases buf; copy or intern before the buffer is reused. +func (v *customLabelValidator) validateKey(buf []byte) ([]byte, bool) { + b := cstring(buf) + if len(b) == 0 || !utf8.Valid(b) { + v.droppedInvalidName.Add(1) + return nil, false + } + return b, true +} + +// validateValue is lenient on a custom label value: fixed-width eBPF buffers +// can clip a multi-byte rune in half, so on invalid trailing bytes we salvage +// the longest valid UTF-8 prefix rather than discard the whole label. ok=false +// (and bumping the drop counter) fires only when the salvage is empty, i.e. +// the input was non-empty garbage rather than mid-rune truncation. The returned +// slice aliases buf; copy or intern before the buffer is reused. +func (v *customLabelValidator) validateValue(buf []byte) ([]byte, bool) { + b := cstring(buf) + pos := len(b) + if !utf8.Valid(b) { + // Walk forward; stop at the first invalid byte. This recovers the entire + // valid prefix of a mid-rune truncation in one pass. + pos = 0 + for pos < len(b) { + r, size := utf8.DecodeRune(b[pos:]) + if r == utf8.RuneError && size == 1 { + break + } + pos += size + } + if pos == 0 { + v.droppedInvalidValue.Add(1) + return nil, false + } + } + return b[:pos], true +} + +// getAndResetMetrics reports and resets the counters of custom labels dropped +// due to an invalid name or value since the previous call. +func (v *customLabelValidator) getAndResetMetrics() []metrics.Metric { + return []metrics.Metric{ + { + ID: metrics.IDGoLabelsDroppedInvalidName, + Value: metrics.MetricValue(v.droppedInvalidName.Swap(0)), + }, + { + ID: metrics.IDGoLabelsDroppedInvalidValue, + Value: metrics.MetricValue(v.droppedInvalidValue.Swap(0)), + }, + } +} diff --git a/tracer/labels_test.go b/tracer/labels_test.go new file mode 100644 index 000000000..c2a0c5bda --- /dev/null +++ b/tracer/labels_test.go @@ -0,0 +1,156 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package tracer + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/ebpf-profiler/metrics" +) + +func TestCustomLabelValidatorValidateKey(t *testing.T) { + tests := map[string]struct { + input []byte + wantValue string + wantOK bool + wantDropped int64 + }{ + "plain ascii": { + input: []byte("tenant\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), + wantValue: "tenant", + wantOK: true, + }, + "valid multi-byte utf8": { + input: append([]byte("héllo"), 0), + wantValue: "héllo", + wantOK: true, + }, + "empty buffer drops": { + // An empty key cannot be grouped against, so reject. + input: make([]byte, 16), + wantOK: false, + wantDropped: 1, + }, + "stale bytes after nul are discarded": { + input: []byte("tier\x00equest-trace"), + wantValue: "tier", + wantOK: true, + }, + "mid-rune truncation drops the whole key": { + // Keys are strict: a salvageable value-style prefix is not enough, + // since dropping the trailing byte would silently change which key + // samples are grouped under. + input: []byte{'o', 'k', 0xE2, 0x00}, + wantOK: false, + wantDropped: 1, + }, + "trailing lone continuation byte drops": { + input: []byte{'a', 'b', 'c', 0x80, 0x00}, + wantOK: false, + wantDropped: 1, + }, + "all-invalid bytes drop": { + input: []byte{0x80, 0x80, 0x00}, + wantOK: false, + wantDropped: 1, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var v customLabelValidator + got, ok := v.validateKey(tc.input) + require.Equal(t, tc.wantOK, ok) + require.Equal(t, tc.wantValue, string(got)) + require.Equal(t, tc.wantDropped, v.droppedInvalidName.Load()) + }) + } +} + +func TestCustomLabelValidatorValidateValue(t *testing.T) { + tests := map[string]struct { + input []byte + wantValue string + wantOK bool + wantDropped int64 + }{ + "plain ascii": { + input: []byte("tenant\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), + wantValue: "tenant", + wantOK: true, + }, + "valid multi-byte utf8": { + input: append([]byte("héllo"), 0), + wantValue: "héllo", + wantOK: true, + }, + "empty buffer is valid": { + input: make([]byte, 16), + wantValue: "", + wantOK: true, + }, + "stale bytes after nul are discarded": { + input: []byte("tier\x00equest-trace"), + wantValue: "tier", + wantOK: true, + }, + "mid-rune truncation salvages valid prefix": { + // 3-byte rune (0xE2 0x98 0x83 = U+2603) cut after the first byte. + // The valid "ok" prefix must be preserved. + input: []byte{'o', 'k', 0xE2, 0x00}, + wantValue: "ok", + wantOK: true, + }, + "trailing lone continuation byte salvages valid prefix": { + input: []byte{'a', 'b', 'c', 0x80, 0x00}, + wantValue: "abc", + wantOK: true, + }, + "all-invalid bytes drop": { + input: []byte{0x80, 0x80, 0x00}, + wantOK: false, + wantDropped: 1, + }, + "single invalid byte drops": { + input: []byte{0xC0, 0x00}, + wantOK: false, + wantDropped: 1, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var v customLabelValidator + got, ok := v.validateValue(tc.input) + require.Equal(t, tc.wantOK, ok) + require.Equal(t, tc.wantValue, string(got)) + require.Equal(t, tc.wantDropped, v.droppedInvalidValue.Load()) + }) + } +} + +func TestCustomLabelValidatorGetAndResetMetrics(t *testing.T) { + var v customLabelValidator + + // Trigger two name drops and one value drop. + v.validateKey([]byte{0xC0, 0}) + v.validateKey([]byte{0}) + v.validateValue([]byte{0xC0, 0}) + + m := v.getAndResetMetrics() + byID := map[metrics.MetricID]metrics.MetricValue{} + for _, x := range m { + byID[x.ID] = x.Value + } + require.Equal(t, metrics.MetricValue(2), byID[metrics.IDGoLabelsDroppedInvalidName]) + require.Equal(t, metrics.MetricValue(1), byID[metrics.IDGoLabelsDroppedInvalidValue]) + + // Second call returns zeros — counters reset. + m = v.getAndResetMetrics() + for _, x := range m { + require.Equal(t, metrics.MetricValue(0), x.Value, x.ID) + } +} diff --git a/tracer/string_test.go b/tracer/string_test.go index c36832e85..4f043663c 100644 --- a/tracer/string_test.go +++ b/tracer/string_test.go @@ -51,112 +51,3 @@ func TestGoString(t *testing.T) { }) } } - -func TestGoLabelKey(t *testing.T) { - tests := map[string]struct { - input []byte - wantValue string - wantOK bool - }{ - "plain ascii": { - input: []byte("tenant\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), - wantValue: "tenant", - wantOK: true, - }, - "valid multi-byte utf8": { - input: append([]byte("héllo"), 0), - wantValue: "héllo", - wantOK: true, - }, - "empty buffer drops": { - // An empty key cannot be grouped against, so reject. - input: make([]byte, 16), - wantOK: false, - }, - "stale bytes after nul are discarded": { - input: []byte("tier\x00equest-trace"), - wantValue: "tier", - wantOK: true, - }, - "mid-rune truncation drops the whole key": { - // Keys are strict: a salvageable value-style prefix is not enough, - // since dropping the trailing byte would silently change which key - // samples are grouped under. - input: []byte{'o', 'k', 0xE2, 0x00}, - wantOK: false, - }, - "trailing lone continuation byte drops": { - input: []byte{'a', 'b', 'c', 0x80, 0x00}, - wantOK: false, - }, - "all-invalid bytes drop": { - input: []byte{0x80, 0x80, 0x00}, - wantOK: false, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - got, ok := goLabelKey(tc.input) - require.Equal(t, tc.wantOK, ok) - require.Equal(t, tc.wantValue, got.String()) - }) - } -} - -func TestGoLabelValue(t *testing.T) { - tests := map[string]struct { - input []byte - wantValue string - wantOK bool - }{ - "plain ascii": { - input: []byte("tenant\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), - wantValue: "tenant", - wantOK: true, - }, - "valid multi-byte utf8": { - input: append([]byte("héllo"), 0), - wantValue: "héllo", - wantOK: true, - }, - "empty buffer is valid": { - input: make([]byte, 16), - wantValue: "", - wantOK: true, - }, - "stale bytes after nul are discarded": { - input: []byte("tier\x00equest-trace"), - wantValue: "tier", - wantOK: true, - }, - "mid-rune truncation salvages valid prefix": { - // 3-byte rune (0xE2 0x98 0x83 = U+2603) cut after the first byte. - // The valid "ok" prefix must be preserved. - input: []byte{'o', 'k', 0xE2, 0x00}, - wantValue: "ok", - wantOK: true, - }, - "trailing lone continuation byte salvages valid prefix": { - input: []byte{'a', 'b', 'c', 0x80, 0x00}, - wantValue: "abc", - wantOK: true, - }, - "all-invalid bytes drop": { - input: []byte{0x80, 0x80, 0x00}, - wantOK: false, - }, - "single invalid byte drops": { - input: []byte{0xC0, 0x00}, - wantOK: false, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - got, ok := goLabelValue(tc.input) - require.Equal(t, tc.wantOK, ok) - require.Equal(t, tc.wantValue, got.String()) - }) - } -} diff --git a/tracer/tracer.go b/tracer/tracer.go index 7d77bd6bb..ddf00a06d 100644 --- a/tracer/tracer.go +++ b/tracer/tracer.go @@ -6,7 +6,6 @@ package tracer // import "go.opentelemetry.io/ebpf-profiler/tracer" import ( "bufio" - "bytes" "context" "errors" "fmt" @@ -17,9 +16,7 @@ import ( "slices" "strings" "sync" - "sync/atomic" "time" - "unicode/utf8" "unsafe" cebpf "github.com/cilium/ebpf" @@ -134,13 +131,9 @@ type Tracer struct { // probabilisticThreshold holds the threshold for probabilistic profiling. probabilisticThreshold uint - // goLabelsDroppedInvalidName counts Go custom labels dropped since the last - // metrics report because their name was empty or not valid UTF-8. - goLabelsDroppedInvalidName atomic.Int64 - - // goLabelsDroppedInvalidValue counts Go custom labels dropped since the last - // metrics report because their value was not valid UTF-8. - goLabelsDroppedInvalidValue atomic.Int64 + // customLabels validates custom label keys/values pulled from eBPF and + // tracks how many were dropped due to invalid UTF-8. + customLabels customLabelValidator // done is closed when the tracer encounters an unrecoverable error. // Use Done() to obtain a read-only channel for use in select statements. @@ -228,58 +221,8 @@ type progLoaderHelper struct { // goString converts a fixed-size NUL-terminated buffer into an interned string, // ignoring everything from the first NUL byte onward. -func goString(cstr []byte) libpf.String { - index := bytes.IndexByte(cstr, byte(0)) - if index < 0 { - index = len(cstr) - } - return libpf.Intern(pfunsafe.ToString(cstr[:index])) -} - -// goLabelKey enforces strict UTF-8 validity on a Go custom label key. Any -// invalid byte drops the whole label, since a corrupted key would group -// unrelated samples under a garbage name. ok=false means the caller should -// drop the label (and also fires for an empty key). -func goLabelKey(cstr []byte) (libpf.String, bool) { - index := bytes.IndexByte(cstr, byte(0)) - if index < 0 { - index = len(cstr) - } - b := cstr[:index] - if len(b) == 0 || !utf8.Valid(b) { - return libpf.NullString, false - } - return libpf.Intern(pfunsafe.ToString(b)), true -} - -// goLabelValue is lenient on a Go custom label value: fixed-width eBPF buffers -// can clip a multi-byte rune in half, so on invalid trailing bytes we salvage -// the longest valid UTF-8 prefix rather than discard the whole label. ok=false -// only when the salvage is empty (the input was non-empty garbage). -func goLabelValue(cstr []byte) (libpf.String, bool) { - index := bytes.IndexByte(cstr, byte(0)) - if index < 0 { - index = len(cstr) - } - b := cstr[:index] - // Fast path: already valid. - if utf8.Valid(b) { - return libpf.Intern(pfunsafe.ToString(b)), true - } - // Walk forward; stop at the first invalid byte. This recovers the entire - // valid prefix of a mid-rune truncation in one pass. - var pos int - for pos < len(b) { - r, size := utf8.DecodeRune(b[pos:]) - if r == utf8.RuneError && size == 1 { - break - } - pos += size - } - if pos == 0 { - return libpf.NullString, false - } - return libpf.Intern(pfunsafe.ToString(b[:pos])), true +func goString(buf []byte) libpf.String { + return libpf.Intern(pfunsafe.ToString(cstring(buf))) } // schedProcessFreeHookName returns the name of the tracepoint hook to use. @@ -1123,19 +1066,18 @@ func (t *Tracer) loadBpfTrace(raw []byte, cpu int) (*libpf.EbpfTrace, error) { trace.CustomLabels = make(map[libpf.String]libpf.String, int(ptr.Custom_labels.Len)) for i := 0; i < int(ptr.Custom_labels.Len); i++ { lbl := ptr.Custom_labels.Labels[i] - key, ok := goLabelKey(lbl.Key[:]) + keyBytes, ok := t.customLabels.validateKey(lbl.Key[:]) if !ok { - t.goLabelsDroppedInvalidName.Add(1) log.Debugf("Dropping Go custom label with empty or invalid UTF-8 name") continue } - val, ok := goLabelValue(lbl.Val[:]) + key := libpf.Intern(pfunsafe.ToString(keyBytes)) + valBytes, ok := t.customLabels.validateValue(lbl.Val[:]) if !ok { - t.goLabelsDroppedInvalidValue.Add(1) log.Debugf("Dropping Go custom label %s with invalid UTF-8 value", key) continue } - trace.CustomLabels[key] = val + trace.CustomLabels[key] = libpf.Intern(pfunsafe.ToString(valBytes)) } } @@ -1155,21 +1097,6 @@ func (t *Tracer) loadBpfTrace(raw []byte, cpu int) (*libpf.EbpfTrace, error) { return trace, nil } -// goLabelsMetricCollector reports and resets the counters of Go custom labels -// dropped due to an invalid name or value since the previous call. -func (t *Tracer) goLabelsMetricCollector() []metrics.Metric { - return []metrics.Metric{ - { - ID: metrics.IDGoLabelsDroppedInvalidName, - Value: metrics.MetricValue(t.goLabelsDroppedInvalidName.Swap(0)), - }, - { - ID: metrics.IDGoLabelsDroppedInvalidValue, - Value: metrics.MetricValue(t.goLabelsDroppedInvalidValue.Swap(0)), - }, - } -} - // StartMapMonitors starts goroutines for collecting metrics and monitoring eBPF // maps for tracepoints, new traces, trace count updates and unknown PCs. func (t *Tracer) StartMapMonitors(ctx context.Context, traceOutChan chan<- *libpf.EbpfTrace) error { @@ -1218,7 +1145,7 @@ func (t *Tracer) StartMapMonitors(ctx context.Context, traceOutChan chan<- *libp metrics.AddSlice(eventMetricCollector()) metrics.AddSlice(traceEventMetricCollector()) metrics.AddSlice(t.eBPFMetricsCollector(translateIDs, previousMetricValue)) - metrics.AddSlice(t.goLabelsMetricCollector()) + metrics.AddSlice(t.customLabels.getAndResetMetrics()) }) return nil