mirror of
https://github.com/open-telemetry/opentelemetry-go.git
synced 2025-03-29 21:47:00 +02:00
Fix sdk/log record attr value limit (#6032)
Fix #6004
Copy of #5997
### Correctness
From the [OTel
specification](88bffeac48/specification/common/README.md (attribute-limits)
):
> - set an attribute value length limit such that for each attribute
value:
> - if it is a string, if it exceeds that limit (counting any character
in it as 1), SDKs MUST truncate that value, so that its length is at
most equal to the limit...
Our current implementation truncates on number of bytes not characters.
Unit tests are added/updated to validate this fix and prevent
regressions.
### Performance
```
goos: linux
goarch: amd64
pkg: go.opentelemetry.io/otel/sdk/log
cpu: Intel(R) Core(TM) i7-8550U CPU @ 1.80GHz
│ commit-e9c7aac2(old).txt │ commit-878043b9(new).txt │
│ sec/op │ sec/op vs base │
Truncate/Unlimited-8 0.8323n ± 3% 0.7367n ± 3% -11.49% (p=0.000 n=10)
Truncate/Zero-8 1.923n ± 32% 1.359n ± 2% -29.34% (p=0.000 n=10)
Truncate/Short-8 14.6050n ± 4% 0.8785n ± 1% -93.98% (p=0.000 n=10)
Truncate/ASCII-8 8.205n ± 2% 3.601n ± 7% -56.12% (p=0.000 n=10)
Truncate/ValidUTF-8-8 11.335n ± 1% 7.206n ± 1% -36.43% (p=0.000 n=10)
Truncate/InvalidUTF-8-8 58.26n ± 1% 36.61n ± 1% -37.17% (p=0.000 n=10)
Truncate/MixedUTF-8-8 81.16n ± 1% 52.30n ± 1% -35.56% (p=0.000 n=10)
geomean 10.04n 4.601n -54.16%
│ commit-e9c7aac2(old).txt │ commit-878043b9(new).txt │
│ B/op │ B/op vs base │
Truncate/Unlimited-8 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
Truncate/Zero-8 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
Truncate/Short-8 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
Truncate/ASCII-8 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
Truncate/ValidUTF-8-8 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
Truncate/InvalidUTF-8-8 16.00 ± 0% 16.00 ± 0% ~ (p=1.000 n=10) ¹
Truncate/MixedUTF-8-8 32.00 ± 0% 32.00 ± 0% ~ (p=1.000 n=10) ¹
geomean ² +0.00% ²
¹ all samples are equal
² summaries must be >0 to compute geomean
│ commit-e9c7aac2(old).txt │ commit-878043b9(new).txt │
│ allocs/op │ allocs/op vs base │
Truncate/Unlimited-8 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
Truncate/Zero-8 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
Truncate/Short-8 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
Truncate/ASCII-8 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
Truncate/ValidUTF-8-8 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹
Truncate/InvalidUTF-8-8 1.000 ± 0% 1.000 ± 0% ~ (p=1.000 n=10) ¹
Truncate/MixedUTF-8-8 1.000 ± 0% 1.000 ± 0% ~ (p=1.000 n=10) ¹
geomean ² +0.00% ²
¹ all samples are equal
² summaries must be >0 to compute geomean
```
This commit is contained in:
parent
58fdf2a692
commit
aa95895dfa
@ -33,6 +33,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|||||||
- Fix inconsistent request body closing in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp`. (#5954)
|
- Fix inconsistent request body closing in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp`. (#5954)
|
||||||
- Fix invalid exemplar keys in `go.opentelemetry.io/otel/exporters/prometheus`. (#5995)
|
- Fix invalid exemplar keys in `go.opentelemetry.io/otel/exporters/prometheus`. (#5995)
|
||||||
- Fix attribute value truncation in `go.opentelemetry.io/otel/sdk/trace`. (#5997)
|
- Fix attribute value truncation in `go.opentelemetry.io/otel/sdk/trace`. (#5997)
|
||||||
|
- Fix attribute value truncation in `go.opentelemetry.io/otel/sdk/log`. (#6032)
|
||||||
|
|
||||||
<!-- Released section -->
|
<!-- Released section -->
|
||||||
<!-- Don't change this section unless doing release -->
|
<!-- Don't change this section unless doing release -->
|
||||||
|
@ -406,7 +406,7 @@ func (r *Record) applyValueLimits(val log.Value) log.Value {
|
|||||||
case log.KindString:
|
case log.KindString:
|
||||||
s := val.AsString()
|
s := val.AsString()
|
||||||
if len(s) > r.attributeValueLengthLimit {
|
if len(s) > r.attributeValueLengthLimit {
|
||||||
val = log.StringValue(truncate(s, r.attributeValueLengthLimit))
|
val = log.StringValue(truncate(r.attributeValueLengthLimit, s))
|
||||||
}
|
}
|
||||||
case log.KindSlice:
|
case log.KindSlice:
|
||||||
sl := val.AsSlice()
|
sl := val.AsSlice()
|
||||||
@ -427,40 +427,78 @@ func (r *Record) applyValueLimits(val log.Value) log.Value {
|
|||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncate returns a copy of str truncated to have a length of at most n
|
// truncate returns a truncated version of s such that it contains less than
|
||||||
// characters. If the length of str is less than n, str itself is returned.
|
// the limit number of characters. Truncation is applied by returning the limit
|
||||||
|
// number of valid characters contained in s.
|
||||||
//
|
//
|
||||||
// The truncate of str ensures that no valid UTF-8 code point is split. The
|
// If limit is negative, it returns the original string.
|
||||||
// copy returned will be less than n if a characters straddles the length
|
|
||||||
// limit.
|
|
||||||
//
|
//
|
||||||
// No truncation is performed if n is less than zero.
|
// UTF-8 is supported. When truncating, all invalid characters are dropped
|
||||||
func truncate(str string, n int) string {
|
// before applying truncation.
|
||||||
if n < 0 {
|
//
|
||||||
return str
|
// If s already contains less than the limit number of bytes, it is returned
|
||||||
|
// unchanged. No invalid characters are removed.
|
||||||
|
func truncate(limit int, s string) string {
|
||||||
|
// This prioritize performance in the following order based on the most
|
||||||
|
// common expected use-cases.
|
||||||
|
//
|
||||||
|
// - Short values less than the default limit (128).
|
||||||
|
// - Strings with valid encodings that exceed the limit.
|
||||||
|
// - No limit.
|
||||||
|
// - Strings with invalid encodings that exceed the limit.
|
||||||
|
if limit < 0 || len(s) <= limit {
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// cut returns a copy of the s truncated to not exceed a length of n. If
|
// Optimistically, assume all valid UTF-8.
|
||||||
// invalid UTF-8 is encountered, s is returned with false. Otherwise, the
|
var b strings.Builder
|
||||||
// truncated copy will be returned with true.
|
count := 0
|
||||||
cut := func(s string) (string, bool) {
|
for i, c := range s {
|
||||||
var i int
|
if c != utf8.RuneError {
|
||||||
for i = 0; i < n; {
|
count++
|
||||||
r, size := utf8.DecodeRuneInString(s[i:])
|
if count > limit {
|
||||||
if r == utf8.RuneError {
|
return s[:i]
|
||||||
return s, false
|
|
||||||
}
|
}
|
||||||
if i+size > n {
|
continue
|
||||||
break
|
}
|
||||||
}
|
|
||||||
i += size
|
_, size := utf8.DecodeRuneInString(s[i:])
|
||||||
|
if size == 1 {
|
||||||
|
// Invalid encoding.
|
||||||
|
b.Grow(len(s) - 1)
|
||||||
|
_, _ = b.WriteString(s[:i])
|
||||||
|
s = s[i:]
|
||||||
|
break
|
||||||
}
|
}
|
||||||
return s[:i], true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cp, ok := cut(str)
|
// Fast-path, no invalid input.
|
||||||
if !ok {
|
if b.Cap() == 0 {
|
||||||
cp, _ = cut(strings.ToValidUTF8(str, ""))
|
return s
|
||||||
}
|
}
|
||||||
return cp
|
|
||||||
|
// Truncate while validating UTF-8.
|
||||||
|
for i := 0; i < len(s) && count < limit; {
|
||||||
|
c := s[i]
|
||||||
|
if c < utf8.RuneSelf {
|
||||||
|
// Optimization for single byte runes (common case).
|
||||||
|
_ = b.WriteByte(c)
|
||||||
|
i++
|
||||||
|
count++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, size := utf8.DecodeRuneInString(s[i:])
|
||||||
|
if size == 1 {
|
||||||
|
// We checked for all 1-byte runes above, this is a RuneError.
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = b.WriteString(s[i : i+size])
|
||||||
|
i += size
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
@ -570,76 +570,149 @@ func assertKV(t *testing.T, r Record, kv log.KeyValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTruncate(t *testing.T) {
|
func TestTruncate(t *testing.T) {
|
||||||
testcases := []struct {
|
type group struct {
|
||||||
input, want string
|
limit int
|
||||||
limit int
|
input string
|
||||||
|
expected string
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
groups []group
|
||||||
}{
|
}{
|
||||||
|
// Edge case: limit is negative, no truncation should occur
|
||||||
{
|
{
|
||||||
input: "value",
|
name: "NoTruncation",
|
||||||
want: "value",
|
groups: []group{
|
||||||
limit: -1,
|
{-1, "No truncation!", "No truncation!"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Edge case: string is already shorter than the limit, no truncation
|
||||||
|
// should occur
|
||||||
{
|
{
|
||||||
input: "value",
|
name: "ShortText",
|
||||||
want: "",
|
groups: []group{
|
||||||
limit: 0,
|
{10, "Short text", "Short text"},
|
||||||
|
{15, "Short text", "Short text"},
|
||||||
|
{100, "Short text", "Short text"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Edge case: truncation happens with ASCII characters only
|
||||||
{
|
{
|
||||||
input: "value",
|
name: "ASCIIOnly",
|
||||||
want: "v",
|
groups: []group{
|
||||||
limit: 1,
|
{1, "Hello World!", "H"},
|
||||||
|
{5, "Hello World!", "Hello"},
|
||||||
|
{12, "Hello World!", "Hello World!"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Truncation including multi-byte characters (UTF-8)
|
||||||
{
|
{
|
||||||
input: "value",
|
name: "ValidUTF-8",
|
||||||
want: "va",
|
groups: []group{
|
||||||
limit: 2,
|
{7, "Hello, 世界", "Hello, "},
|
||||||
|
{8, "Hello, 世界", "Hello, 世"},
|
||||||
|
{2, "こんにちは", "こん"},
|
||||||
|
{3, "こんにちは", "こんに"},
|
||||||
|
{5, "こんにちは", "こんにちは"},
|
||||||
|
{12, "こんにちは", "こんにちは"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Truncation with invalid UTF-8 characters
|
||||||
{
|
{
|
||||||
input: "value",
|
name: "InvalidUTF-8",
|
||||||
want: "val",
|
groups: []group{
|
||||||
limit: 3,
|
{11, "Invalid\x80text", "Invalidtext"},
|
||||||
|
// Do not modify invalid text if equal to limit.
|
||||||
|
{11, "Valid text\x80", "Valid text\x80"},
|
||||||
|
// Do not modify invalid text if under limit.
|
||||||
|
{15, "Valid text\x80", "Valid text\x80"},
|
||||||
|
{5, "Hello\x80World", "Hello"},
|
||||||
|
{11, "Hello\x80World\x80!", "HelloWorld!"},
|
||||||
|
{15, "Hello\x80World\x80Test", "HelloWorldTest"},
|
||||||
|
{15, "Hello\x80\x80\x80World\x80Test", "HelloWorldTest"},
|
||||||
|
{15, "\x80\x80\x80Hello\x80\x80\x80World\x80Test\x80\x80", "HelloWorldTest"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Truncation with mixed validn and invalid UTF-8 characters
|
||||||
{
|
{
|
||||||
input: "value",
|
name: "MixedUTF-8",
|
||||||
want: "valu",
|
groups: []group{
|
||||||
limit: 4,
|
{6, "€"[0:2] + "hello€€", "hello€"},
|
||||||
|
{6, "€" + "€"[0:2] + "hello", "€hello"},
|
||||||
|
{11, "Valid text\x80📜", "Valid text📜"},
|
||||||
|
{11, "Valid text📜\x80", "Valid text📜"},
|
||||||
|
{14, "😊 Hello\x80World🌍🚀", "😊 HelloWorld🌍🚀"},
|
||||||
|
{14, "😊\x80 Hello\x80World🌍🚀", "😊 HelloWorld🌍🚀"},
|
||||||
|
{14, "😊\x80 Hello\x80World🌍\x80🚀", "😊 HelloWorld🌍🚀"},
|
||||||
|
{14, "😊\x80 Hello\x80World🌍\x80🚀\x80", "😊 HelloWorld🌍🚀"},
|
||||||
|
{14, "\x80😊\x80 Hello\x80World🌍\x80🚀\x80", "😊 HelloWorld🌍🚀"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Edge case: empty string, should return empty string
|
||||||
{
|
{
|
||||||
input: "value",
|
name: "Empty",
|
||||||
want: "value",
|
groups: []group{
|
||||||
limit: 5,
|
{5, "", ""},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Edge case: limit is 0, should return an empty string
|
||||||
{
|
{
|
||||||
input: "value",
|
name: "Zero",
|
||||||
want: "value",
|
groups: []group{
|
||||||
limit: 6,
|
{0, "Some text", ""},
|
||||||
},
|
{0, "", ""},
|
||||||
{
|
},
|
||||||
input: "€€€€", // 3 bytes each
|
|
||||||
want: "€€€",
|
|
||||||
limit: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "€"[0:2] + "hello€€", // corrupted first rune, then over limit
|
|
||||||
want: "hello€",
|
|
||||||
limit: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
input: "€"[0:2] + "hello", // corrupted first rune, then not over limit
|
|
||||||
want: "hello",
|
|
||||||
limit: 10,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testcases {
|
for _, tt := range tests {
|
||||||
name := fmt.Sprintf("%s/%d", tc.input, tc.limit)
|
for _, g := range tt.groups {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Log(tc.input, len(tc.input), tc.limit)
|
t.Parallel()
|
||||||
assert.Equal(t, tc.want, truncate(tc.input, tc.limit))
|
|
||||||
})
|
got := truncate(g.limit, g.input)
|
||||||
|
assert.Equalf(
|
||||||
|
t, g.expected, got,
|
||||||
|
"input: %q([]rune%v))\ngot: %q([]rune%v)\nwant %q([]rune%v)",
|
||||||
|
g.input, []rune(g.input),
|
||||||
|
got, []rune(got),
|
||||||
|
g.expected, []rune(g.expected),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BenchmarkTruncate(b *testing.B) {
|
||||||
|
run := func(limit int, input string) func(b *testing.B) {
|
||||||
|
return func(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.RunParallel(func(pb *testing.PB) {
|
||||||
|
var out string
|
||||||
|
for pb.Next() {
|
||||||
|
out = truncate(limit, input)
|
||||||
|
}
|
||||||
|
_ = out
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.Run("Unlimited", run(-1, "hello 😊 world 🌍🚀"))
|
||||||
|
b.Run("Zero", run(0, "Some text"))
|
||||||
|
b.Run("Short", run(10, "Short Text"))
|
||||||
|
b.Run("ASCII", run(5, "Hello, World!"))
|
||||||
|
b.Run("ValidUTF-8", run(10, "hello 😊 world 🌍🚀"))
|
||||||
|
b.Run("InvalidUTF-8", run(6, "€"[0:2]+"hello€€"))
|
||||||
|
b.Run("MixedUTF-8", run(14, "\x80😊\x80 Hello\x80World🌍\x80🚀\x80"))
|
||||||
|
}
|
||||||
|
|
||||||
func BenchmarkWalkAttributes(b *testing.B) {
|
func BenchmarkWalkAttributes(b *testing.B) {
|
||||||
for _, tt := range []struct {
|
for _, tt := range []struct {
|
||||||
attrCount int
|
attrCount int
|
||||||
|
Loading…
x
Reference in New Issue
Block a user