1
0
mirror of https://github.com/open-telemetry/opentelemetry-go.git synced 2026-06-19 21:45:50 +02:00

sdk: deduplicate MAP attribute keys (#8471)

Fixes https://github.com/open-telemetry/opentelemetry-go/issues/7957.

- Remove duplicate keys from `attribute.MAP` values by default across
SDK resource, log, trace, and metric export paths, including top-level
MAP-valued attributes and MAP values nested inside MAP or SLICE values.
- Update `log.WithAllowKeyDuplication()` behavior and docs.
- Always deduplicate MAP values in resources;
`go.opentelemetry.io/otel/sdk/resource` does not provide an opt-out.
Resources are typically evaluated once at startup or occasionally
updated. The ~12.90% overhead shown in benchmarks will have low impact
on an application's runtime latency, and having a strictly normalized
Resource map prevents downstream exporter issues.
- Preserve top-level `attribute.Set` behavior for resources, scopes, and
metric datapoint attributes; this change normalizes MAP-valued attribute
values, not the outer attribute collection semantics.
- Use last-value-wins semantics and do not increment dropped-attribute
counts for MAP duplicate removal.
- Keep internal helper code scoped per module: shared source lives in
`internal/shared/attrdedup`, then `gotmpl` renders module-local copies
under `sdk/internal`, `sdk/log/internal`, and `sdk/metric/internal`.

## Benchmarks

Baseline benchmarks were run from a local `main` worktree at
`/tmp/opentelemetry-go-main`; branch benchmarks were run from this
branch. All comparisons use `-benchmem -count=10`.

New helper benchmarks:

```console
goos: linux
goarch: amd64
pkg: go.opentelemetry.io/otel/sdk/internal/attrdedup
cpu: 13th Gen Intel(R) Core(TM) i7-13800H
                          │   this.txt   │
                          │    sec/op    │
Value/FastPath-20           186.4n ± 19%
Value/DuplicateMap-20       363.4n ± 82%
Value/NestedMapInSlice-20   607.4n ± 20%
geomean                     345.2n

                          │   this.txt   │
                          │     B/op     │
Value/FastPath-20           0.000 ± 0%
Value/DuplicateMap-20       320.0 ± 0%
Value/NestedMapInSlice-20   416.0 ± 0%
geomean                                ¹
¹ summaries must be >0 to compute geomean

                          │   this.txt   │
                          │  allocs/op   │
Value/FastPath-20           0.000 ± 0%
Value/DuplicateMap-20       2.000 ± 0%
Value/NestedMapInSlice-20   4.000 ± 0%
geomean                                ¹
¹ summaries must be >0 to compute geomean
```

```console
pkg: go.opentelemetry.io/otel/sdk/resource
                    │ main │ branch │
                    │ sec/op │ sec/op vs base │
MergeResource_1-20    587.9n ± 28%   677.7n ± 17%  +15.28% (p=0.043 n=10)
MergeResource_2-20    938.5n ± 11%  1011.8n ± 18%        ~ (p=0.393 n=10)
MergeResource_3-20    1.301µ ±  8%   1.522µ ± 21%  +16.95% (p=0.023 n=10)
MergeResource_4-20    1.487µ ± 31%   2.006µ ± 12%  +34.90% (p=0.000 n=10)
MergeResource_6-20    2.409µ ± 12%   2.962µ ± 15%  +22.96% (p=0.001 n=10)
MergeResource_8-20    3.336µ ± 10%   3.104µ ± 16%        ~ (p=0.247 n=10)
MergeResource_16-20   7.750µ ±  6%   8.076µ ±  9%        ~ (p=0.190 n=10)
geomean               1.821µ         2.056µ        +12.90%

pkg: go.opentelemetry.io/otel/sdk/trace
                                              │ main │ branch │
                                              │ sec/op │ sec/op vs base │
RecordingSpanSetAttributes/WithLimit/false-20  7.907µ ±  6%  11.311µ ±  6%  +43.06% (p=0.000 n=10)
RecordingSpanSetAttributes/WithLimit/true-20   13.29µ ± 15%   17.13µ ±  6%  +28.97% (p=0.002 n=10)
TraceStart/with_a_simple_span-20               402.1n ±  2%   406.4n ±  2%        ~ (p=0.218 n=10)
TraceStart/with_several_links-20               521.8n ±  1%   532.4n ±  2%  +2.03% (p=0.000 n=10)
TraceStart/with_attributes-20                  570.5n ±  2%   594.1n ±  2%  +4.12% (p=0.000 n=10)
TraceStart/ObservabilityEnabled-20             463.5n ±  1%   469.9n ±  1%  +1.38% (p=0.003 n=10)
SpanSetAttributesOverCapacity-20               2.539µ ±  8%   2.501µ ±  8%       ~ (p=0.684 n=10)
SpanWithAttributes_4/AlwaysSample-20           1.650µ ± 23%   1.867µ ± 12%       ~ (p=0.063 n=10)
SpanWithAttributes_4/NeverSample-20            634.5n ± 11%   683.4n ± 19%       ~ (p=0.315 n=10)
geomean                                        1.356µ         1.495µ       +10.27%
```

Resource and trace allocation counts were unchanged in the same
benchstat run.

```console
pkg: go.opentelemetry.io/otel/sdk/log
                                              │ main │ branch │
                                              │ sec/op │ sec/op vs base │
LoggerProviderLogger-20                        505.1n ± 8%  473.9n ± 15%       ~ (p=0.123 n=10)
SetAttributes/Unique/NoLimits-20               230.9n ± 9%  238.1n ±  7%       ~ (p=0.343 n=10)
SetAttributes/NestedDeduplication/Enabled-20   836.0n ± 6%  758.1n ±  8%  -9.32% (p=0.011 n=10)
SetAttributes/NestedDeduplication/Disabled-20  119.7n ± 1%  127.7n ±  8%       ~ (p=0.157 n=10)
geomean                                        328.7n       323.3n        -1.65%
```

Log allocation counts were unchanged in the same benchstat run.

```console
pkg: go.opentelemetry.io/otel/sdk/metric
                                                         │ main │ branch │
                                                         │ sec/op │ sec/op vs base │
MeasureNewAttributeSet/AlwaysOn/Int64Counter-20           725.7n ± 114%  1504.5n ± 15%  +107.32% (p=0.043 n=10)
MeasureNewAttributeSet/AlwaysOn/Float64Counter-20         1.106µ ±  14%   1.480µ ± 15%   +33.82% (p=0.000 n=10)
MeasureNewAttributeSet/AlwaysOn/Int64UpDownCounter-20     1.354µ ±   9%   1.334µ ± 13%         ~ (p=0.529 n=10)
MeasureNewAttributeSet/AlwaysOn/Float64UpDownCounter-20   1.445µ ±  29%   1.439µ ± 14%         ~ (p=0.542 n=10)
MeasureNewAttributeSet/AlwaysOn/Int64Histogram-20         1.423µ ±  31%   1.453µ ± 29%         ~ (p=0.631 n=10)
MeasureNewAttributeSet/AlwaysOn/Float64Histogram-20       1.383µ ±  41%   1.416µ ± 20%         ~ (p=0.796 n=10)
MeasureNewAttributeSet/AlwaysOn/Int64Gauge-20             1.105µ ±  42%   1.527µ ±  8%   +38.21% (p=0.023 n=10)
MeasureNewAttributeSet/AlwaysOn/Float64Gauge-20           1.598µ ±  10%   1.355µ ± 37%         ~ (p=0.247 n=10)
MeasureNewAttributeSet/AlwaysOff/Int64Counter-20          739.1n ±  16%   830.0n ± 17%         ~ (p=0.280 n=10)
MeasureNewAttributeSet/AlwaysOff/Float64Counter-20        299.2n ±  24%   766.7n ± 29%  +156.19% (p=0.000 n=10)
MeasureNewAttributeSet/AlwaysOff/Int64UpDownCounter-20    322.2n ± 104%   873.2n ±  7%  +171.01% (p=0.000 n=10)
MeasureNewAttributeSet/AlwaysOff/Float64UpDownCounter-20  740.5n ±  49%   713.8n ± 21%         ~ (p=0.481 n=10)
MeasureNewAttributeSet/AlwaysOff/Int64Histogram-20        901.1n ±  33%   988.8n ± 12%         ~ (p=0.481 n=10)
MeasureNewAttributeSet/AlwaysOff/Float64Histogram-20      1.059µ ±  23%   1.009µ ± 21%         ~ (p=0.393 n=10)
MeasureNewAttributeSet/AlwaysOff/Int64Gauge-20            408.9n ±  50%   788.0n ± 23%   +92.71% (p=0.000 n=10)
MeasureNewAttributeSet/AlwaysOff/Float64Gauge-20          544.5n ±  37%   662.1n ± 39%         ~ (p=0.725 n=10)
AsyncMeasureNewAttributeSet/AlwaysOn-20                   6.199µ ±  20%   5.766µ ± 26%         ~ (p=0.247 n=10)
AsyncMeasureNewAttributeSet/TraceBased-20                 1.811µ ±  10%   1.806µ ± 11%         ~ (p=0.912 n=10)
Instrument/instrumentImpl/aggregate-20                    2.577µ ±  10%   2.378µ ± 14%         ~ (p=0.247 n=10)
Instrument/observable/observe-20                          1.863µ ±  17%   1.762µ ±  8%         ~ (p=0.255 n=10)
InstrumentCreation-20                                     2.346µ ±  17%   2.215µ ±  8%         ~ (p=0.315 n=10)
geomean                                                   1.102µ          1.324µ         +20.12%
```

Metric B/op geomean was +0.10% and allocs/op geomean was unchanged in
the same benchstat run.

## Rejected implementations

### Seen-Pool

I tested an alternative `deduplicateMapValue` implementation using a
pooled `map[string]struct{}` for duplicate detection, modeled after
`sdk/log.Record`'s `seenPool`. The candidate was not kept because it
regressed all non-opt-out helper benchmark paths.

```console
pkg: go.opentelemetry.io/otel/sdk/internal/attrdedup
                             │ baseline │ seenpool │
                             │ sec/op │ sec/op vs base │
Value/FastPath-20              143.8n ± 0%   201.6n ±  0%  +40.15% (p=0.000 n=20)
Value/DuplicateMap-20          669.8n ± 9%   862.9n ± 12%  +28.83% (p=0.000 n=20)
Value/NestedMapInSlice-20      918.2n ± 9%  1115.0n ±  5%  +21.43% (p=0.000 n=20)
Value/AllowKeyDuplication-20   1.324n ± 0%   1.324n ±  0%        ~ (p=0.389 n=20)
geomean                        104.0n        126.6n        +21.68%
```

The candidate did not change allocation counts, but the `sync.Pool` and
map operations added measurable CPU overhead.

### Needs-Style Preflight

I tested an alternative implementation modeled after
`sdk/log.Record.needsValueLimitsOrDedup`: first scan attribute values to
determine whether normalization is needed, then normalize only when the
scan says a value would change.

The refined candidate preserved the opt-out fast path and avoided an
extra slice preflight pass, but was not kept because package-level
benchmark results were mixed: resource improved, but trace and metric
regressed.

```console
pkg: go.opentelemetry.io/otel/sdk/internal/attrdedup
                             │ baseline │ candidate │
                             │ sec/op │ sec/op vs base │
Value/FastPath-20              144.10n ± 0%   92.52n ± 0%  -35.79% (p=0.000 n=10)
Value/DuplicateMap-20           682.8n ± 19%  677.1n ± 34%       ~ (p=0.796 n=10)
Value/NestedMapInSlice-20       900.5n ±  7%  975.5n ±  8%  +8.33% (p=0.019 n=10)
Value/AllowKeyDuplication-20    1.325n ±  0%  1.325n ±  1%       ~ (p=0.860 n=10)
geomean                         104.1n        94.85n       -8.88%
```

```console
pkg: go.opentelemetry.io/otel/sdk/resource
MergeResource geomean: 2.209µ -> 1.896µ (-14.17%)

pkg: go.opentelemetry.io/otel/sdk/trace
Trace benchmark geomean: 1.474µ -> 1.576µ (+6.90%)
```

```console
pkg: go.opentelemetry.io/otel/sdk/log
Log benchmark geomean: 330.0n -> 330.6n (+0.18%)
```

```console
pkg: go.opentelemetry.io/otel/sdk/metric
Metric benchmark geomean: 1.355µ -> 1.455µ (+7.38%)
```

All candidate allocation geomeans were unchanged. Because the candidate
did not improve performance consistently across affected export paths,
this PR keeps the original lazy allocation-free no-op implementation.
This commit is contained in:
Robert Pająk
2026-06-18 06:25:50 +02:00
committed by GitHub
parent e8e809868f
commit 4ef740f17f
24 changed files with 2762 additions and 18 deletions
+5
View File
@@ -16,6 +16,11 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- Support `MAP` attributes in `go.opentelemetry.io/otel/exporters/otlp/otlpmetric`. (#8453)
- Support `MAP` attributes in `go.opentelemetry.io/otel/exporters/zipkin`. (#8453)
- Apply `AttributeValueLengthLimit` to `attribute.MAP` type attribute values in `go.opentelemetry.io/otel/sdk/trace`, recursively truncating contained values. (#8454)
- Add duplicate-key removal for `attribute.MAP` values in `go.opentelemetry.io/otel/sdk/resource` using last-value-wins semantics. (#8471)
- Add default duplicate-key removal for `attribute.MAP` values in instrumentation scope attributes in `go.opentelemetry.io/otel/sdk/log` using last-value-wins semantics. (#8471)
- Add default duplicate-key removal for `attribute.MAP` values in span, event, link, and instrumentation scope attributes in `go.opentelemetry.io/otel/sdk/trace` using last-value-wins semantics. (#8471)
- Add default duplicate-key removal for `attribute.MAP` values in measurement and instrumentation scope attributes in `go.opentelemetry.io/otel/sdk/metric` using last-value-wins semantics. (#8471)
- Extend `WithAllowKeyDuplication` in `go.opentelemetry.io/otel/sdk/log` to disable duplicate-key removal in `attribute.MAP` values for instrumentation scope attributes. (#8471)
- Add `WithUnsafeAttributes` to `go.opentelemetry.io/otel/metric/x` as an experimental no-copy attribute option intended for future performance work. This is a work in progress. (#8251)
### Fixed
+313
View File
@@ -0,0 +1,313 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/attrdedup/dedup.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
// Package attrdedup deduplicates attribute map values.
package attrdedup
import (
"reflect"
"unsafe"
"go.opentelemetry.io/otel/attribute"
)
var (
keyValueType = reflect.TypeFor[attribute.KeyValue]()
valueType = reflect.TypeFor[attribute.Value]()
)
// rawValue mirrors attribute.Value. It is used only to read immutable slice
// storage without calling AsMap or AsSlice on no-op paths.
type rawValue struct {
vtype attribute.Type
numeric uint64
stringly string
slice any
}
// Value returns value with all map values deduplicated.
//
// Duplicate map keys are resolved using last-value-wins semantics.
func Value(value attribute.Value) attribute.Value {
value, _ = deduplicateValue(value)
return value
}
// KeyValue returns kv with all map values deduplicated.
func KeyValue(kv attribute.KeyValue) attribute.KeyValue {
kv, _ = deduplicateKeyValue(kv)
return kv
}
// KeyValues returns kvs with all map values deduplicated.
//
// The returned slice is the original kvs slice if no value needs
// deduplication. Top-level keys in kvs are not deduplicated.
func KeyValues(kvs []attribute.KeyValue) []attribute.KeyValue {
kvs, _ = deduplicateKeyValues(kvs)
return kvs
}
// Set returns set with all map values deduplicated.
//
// The returned Set is the original set if no value needs deduplication.
// Top-level key uniqueness remains attribute.Set's responsibility; this only
// normalizes map attribute values.
func Set(set attribute.Set) attribute.Set {
if set.Len() == 0 {
return set
}
// Most attribute sets contain no duplicate map keys. Delay allocation until
// the first changed value so the no-op path returns the original Set.
var normalized []attribute.KeyValue
for i := 0; i < set.Len(); i++ {
kv, _ := set.Get(i)
kv, changed := deduplicateKeyValue(kv)
if normalized != nil {
normalized = append(normalized, kv)
continue
}
if !changed {
continue
}
normalized = make([]attribute.KeyValue, 0, set.Len())
for j := 0; j < i; j++ {
prior, _ := set.Get(j)
normalized = append(normalized, prior)
}
normalized = append(normalized, kv)
}
if normalized == nil {
return set
}
return attribute.NewSet(normalized...)
}
func deduplicateKeyValues(kvs []attribute.KeyValue) ([]attribute.KeyValue, bool) {
// Preserve the caller's slice on the common no-op path. Once a changed
// value is found, copy the prior values exactly once and fill the rest in
// place as the scan continues.
var normalized []attribute.KeyValue
for i, kv := range kvs {
kv, changed := deduplicateKeyValue(kv)
if normalized != nil {
normalized[i] = kv
continue
}
if !changed {
continue
}
normalized = make([]attribute.KeyValue, len(kvs))
copy(normalized, kvs[:i])
normalized[i] = kv
}
if normalized == nil {
return kvs, false
}
return normalized, true
}
func deduplicateKeyValue(kv attribute.KeyValue) (attribute.KeyValue, bool) {
value, changed := deduplicateValue(kv.Value)
if changed {
kv.Value = value
}
return kv, changed
}
func deduplicateValue(value attribute.Value) (attribute.Value, bool) {
switch value.Type() {
case attribute.SLICE:
return deduplicateSliceValue(value)
case attribute.MAP:
return deduplicateMapValue(value)
default:
return value, false
}
}
func deduplicateSliceValue(value attribute.Value) (attribute.Value, bool) {
storage := valueStorage(value)
length := valueLen(storage)
// Slice values can contain map values, so recurse into each element while
// keeping the original attribute.Value when no element changes.
var normalized []attribute.Value
for i := 0; i < length; i++ {
elem := valueAt(storage, i)
elem, changed := deduplicateValue(elem)
if normalized != nil {
normalized[i] = elem
continue
}
if !changed {
continue
}
normalized = make([]attribute.Value, length)
for j := 0; j < i; j++ {
normalized[j] = valueAt(storage, j)
}
normalized[i] = elem
}
if normalized == nil {
return value, false
}
return attribute.SliceValue(normalized...), true
}
func deduplicateMapValue(value attribute.Value) (attribute.Value, bool) {
storage := valueStorage(value)
length := keyValueLen(storage)
if length <= 1 {
// A single map entry cannot duplicate its own key, but its value might
// contain a map or slice that needs recursive normalization.
if length == 1 {
kv, changed := deduplicateKeyValue(keyValueAt(storage, 0))
if changed {
return attribute.MapValue(kv), true
}
}
return value, false
}
var normalized []attribute.KeyValue
for i := 0; i < length; {
// attribute.MapValue stores key-values sorted by key using a stable
// sort. Equal keys therefore form a contiguous run, and the last
// element in that run is the last value provided by the caller.
first := keyValueAt(storage, i)
j := i + 1
for j < length && keyValueAt(storage, j).Key == first.Key {
j++
}
kv, nestedChanged := deduplicateKeyValue(keyValueAt(storage, j-1))
// j-i > 1 means the current key run contained duplicates.
changed := nestedChanged || j-i > 1
if normalized != nil {
normalized = append(normalized, kv)
} else if changed {
normalized = make([]attribute.KeyValue, 0, length)
for k := 0; k < i; k++ {
normalized = append(normalized, keyValueAt(storage, k))
}
normalized = append(normalized, kv)
}
i = j
}
if normalized == nil {
return value, false
}
return attribute.MapValue(normalized...), true
}
func valueStorage(value attribute.Value) any {
// attribute.Value does not expose allocation-free map/slice iteration.
// The raw mirror lets us read the immutable backing array directly and
// reserve AsMap/AsSlice-style allocation for paths that actually change.
return (*rawValue)(unsafe.Pointer(&value)).slice //nolint:gosec // Read-only mirror of attribute.Value for allocation-free iteration.
}
func valueLen(storage any) int {
// attribute.Value stores small slices in fixed-size array values. Handle
// the common sizes directly and fall back to reflection for larger arrays.
switch storage.(type) {
case [0]attribute.Value:
return 0
case [1]attribute.Value:
return 1
case [2]attribute.Value:
return 2
case [3]attribute.Value:
return 3
case [4]attribute.Value:
return 4
case [5]attribute.Value:
return 5
default:
return arrayLen(storage, valueType)
}
}
func valueAt(storage any, i int) attribute.Value {
switch values := storage.(type) {
case [1]attribute.Value:
return values[i]
case [2]attribute.Value:
return values[i]
case [3]attribute.Value:
return values[i]
case [4]attribute.Value:
return values[i]
case [5]attribute.Value:
return values[i]
default:
return arrayAt[attribute.Value](storage, valueType, i)
}
}
func keyValueLen(storage any) int {
// attribute.Value stores small maps in fixed-size key-value arrays. Handle
// the common sizes directly and fall back to reflection for larger arrays.
switch storage.(type) {
case [0]attribute.KeyValue:
return 0
case [1]attribute.KeyValue:
return 1
case [2]attribute.KeyValue:
return 2
case [3]attribute.KeyValue:
return 3
case [4]attribute.KeyValue:
return 4
case [5]attribute.KeyValue:
return 5
default:
return arrayLen(storage, keyValueType)
}
}
func keyValueAt(storage any, i int) attribute.KeyValue {
switch kvs := storage.(type) {
case [1]attribute.KeyValue:
return kvs[i]
case [2]attribute.KeyValue:
return kvs[i]
case [3]attribute.KeyValue:
return kvs[i]
case [4]attribute.KeyValue:
return kvs[i]
case [5]attribute.KeyValue:
return kvs[i]
default:
return arrayAt[attribute.KeyValue](storage, keyValueType, i)
}
}
func arrayLen(storage any, elem reflect.Type) int {
// Be defensive around invalid or unexpected Value storage. Returning zero
// makes malformed storage a no-op instead of panicking in telemetry paths.
array := reflect.ValueOf(storage)
if array.Kind() != reflect.Array || array.Type().Elem() != elem {
return 0
}
return array.Len()
}
func arrayAt[T any](storage any, elem reflect.Type, i int) T {
// Match arrayLen's fail-closed behavior for unexpected storage.
array := reflect.ValueOf(storage)
if array.Kind() != reflect.Array || array.Type().Elem() != elem || i < 0 || i >= array.Len() {
var zero T
return zero
}
return array.Index(i).Interface().(T)
}
@@ -0,0 +1,303 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/attrdedup/dedup_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package attrdedup
import (
"testing"
"github.com/google/go-cmp/cmp"
"go.opentelemetry.io/otel/attribute"
)
var cmpValue = cmp.AllowUnexported(attribute.Value{})
func TestValue(t *testing.T) {
tests := []struct {
name string
value attribute.Value
want attribute.Value
}{
{
name: "unique map",
value: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("two", "2"),
),
want: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("two", "2"),
),
},
{
name: "duplicate map",
value: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("one", "2"),
attribute.String("two", "3"),
),
want: attribute.MapValue(
attribute.String("one", "2"),
attribute.String("two", "3"),
),
},
{
name: "duplicate map after prior key",
value: attribute.MapValue(
attribute.String("a", "1"),
attribute.String("b", "2"),
attribute.String("b", "3"),
attribute.String("c", "4"),
),
want: attribute.MapValue(
attribute.String("a", "1"),
attribute.String("b", "3"),
attribute.String("c", "4"),
),
},
{
name: "nested map",
value: attribute.MapValue(
attribute.Map(
"outer",
attribute.String("inner", "1"),
attribute.String("inner", "2"),
),
),
want: attribute.MapValue(
attribute.Map(
"outer",
attribute.String("inner", "2"),
),
),
},
{
name: "map inside slice",
value: attribute.SliceValue(
attribute.StringValue("prior"),
attribute.MapValue(
attribute.String("inner", "1"),
attribute.String("inner", "2"),
),
attribute.StringValue("tail"),
),
want: attribute.SliceValue(
attribute.StringValue("prior"),
attribute.MapValue(
attribute.String("inner", "2"),
),
attribute.StringValue("tail"),
),
},
{
name: "unique slice",
value: attribute.SliceValue(
attribute.StringValue("one"),
attribute.IntValue(2),
),
want: attribute.SliceValue(
attribute.StringValue("one"),
attribute.IntValue(2),
),
},
{
name: "empty and invalid keys",
value: attribute.MapValue(
attribute.KeyValue{},
attribute.String("", "empty"),
attribute.String("valid", "value"),
),
want: attribute.MapValue(
attribute.String("", "empty"),
attribute.String("valid", "value"),
),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := Value(test.value)
if diff := cmp.Diff(test.want, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestValueNoopAllocationFree(t *testing.T) {
value := attribute.MapValue(
attribute.String("one", "1"),
attribute.String("two", "2"),
)
var got attribute.Value
allocs := testing.AllocsPerRun(1000, func() {
got = Value(value)
})
if allocs != 0 {
t.Fatalf("Value() allocations = %v, want 0", allocs)
}
if diff := cmp.Diff(value, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
}
func TestValueStorageShapes(t *testing.T) {
for n := 0; n <= 6; n++ {
t.Run("map", func(t *testing.T) {
kvs := make([]attribute.KeyValue, n)
for i := range kvs {
kvs[i] = attribute.Int(string(rune('a'+i)), i)
}
value := attribute.MapValue(kvs...)
got := Value(value)
if diff := cmp.Diff(value, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
})
t.Run("slice", func(t *testing.T) {
values := make([]attribute.Value, n)
for i := range values {
values[i] = attribute.IntValue(i)
}
value := attribute.SliceValue(values...)
got := Value(value)
if diff := cmp.Diff(value, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestKeyValue(t *testing.T) {
kv := attribute.Map(
"map",
attribute.String("nested", "first"),
attribute.String("nested", "second"),
)
want := attribute.Map(
"map",
attribute.String("nested", "second"),
)
got := KeyValue(kv)
if diff := cmp.Diff(want, got, cmpValue); diff != "" {
t.Fatalf("KeyValue() mismatch (-want +got):\n%s", diff)
}
}
func TestKeyValuesNoopReturnsInput(t *testing.T) {
kvs := []attribute.KeyValue{
attribute.String("one", "1"),
attribute.Map("two", attribute.String("nested", "value")),
}
got := KeyValues(kvs)
if len(got) != len(kvs) {
t.Fatalf("KeyValues() length = %d, want %d", len(got), len(kvs))
}
if &got[0] != &kvs[0] {
t.Fatal("KeyValues() copied a no-op input")
}
}
func TestKeyValues(t *testing.T) {
kvs := []attribute.KeyValue{
attribute.String("top", "value"),
attribute.Map(
"map",
attribute.String("nested", "first"),
attribute.String("nested", "second"),
),
attribute.String("tail", "value"),
}
want := []attribute.KeyValue{
attribute.String("top", "value"),
attribute.Map(
"map",
attribute.String("nested", "second"),
),
attribute.String("tail", "value"),
}
got := KeyValues(kvs)
if diff := cmp.Diff(want, got, cmpValue); diff != "" {
t.Fatalf("KeyValues() mismatch (-want +got):\n%s", diff)
}
}
func TestSet(t *testing.T) {
set := attribute.NewSet(
attribute.String("a-top", "value"),
attribute.Map(
"m-map",
attribute.String("nested", "first"),
attribute.String("nested", "second"),
),
attribute.String("z-tail", "value"),
)
want := attribute.NewSet(
attribute.String("a-top", "value"),
attribute.Map(
"m-map",
attribute.String("nested", "second"),
),
attribute.String("z-tail", "value"),
)
got := Set(set)
if diff := cmp.Diff(want.ToSlice(), got.ToSlice(), cmpValue); diff != "" {
t.Fatalf("Set() mismatch (-want +got):\n%s", diff)
}
}
func TestSetNoop(t *testing.T) {
set := attribute.NewSet(
attribute.String("top", "value"),
attribute.Map("map", attribute.String("nested", "value")),
)
got := Set(set)
if !got.Equals(&set) {
t.Fatal("Set() changed a no-op input")
}
}
func TestSetEmpty(t *testing.T) {
set := attribute.Set{}
got := Set(set)
if !got.Equals(&set) {
t.Fatal("Set() changed an empty input")
}
}
func TestInvalidArrayStorage(t *testing.T) {
if got := arrayLen("invalid", valueType); got != 0 {
t.Fatalf("arrayLen() = %d, want 0", got)
}
if got := arrayAt[attribute.Value]("invalid", valueType, 0); got.Type() != attribute.EMPTY {
t.Fatalf("arrayAt() = %v, want empty value", got)
}
if got := arrayAt[attribute.Value](
[1]attribute.Value{attribute.StringValue("value")},
keyValueType,
0,
); got.Type() != attribute.EMPTY {
t.Fatalf("arrayAt() = %v, want empty value", got)
}
if got := arrayAt[attribute.Value](
[1]attribute.Value{attribute.StringValue("value")},
valueType,
-1,
); got.Type() != attribute.EMPTY {
t.Fatalf("arrayAt() = %v, want empty value", got)
}
}
+313
View File
@@ -0,0 +1,313 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/attrdedup/dedup.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
// Package attrdedup deduplicates attribute map values.
package attrdedup // import "go.opentelemetry.io/otel/sdk/internal/attrdedup"
import (
"reflect"
"unsafe"
"go.opentelemetry.io/otel/attribute"
)
var (
keyValueType = reflect.TypeFor[attribute.KeyValue]()
valueType = reflect.TypeFor[attribute.Value]()
)
// rawValue mirrors attribute.Value. It is used only to read immutable slice
// storage without calling AsMap or AsSlice on no-op paths.
type rawValue struct {
vtype attribute.Type
numeric uint64
stringly string
slice any
}
// Value returns value with all map values deduplicated.
//
// Duplicate map keys are resolved using last-value-wins semantics.
func Value(value attribute.Value) attribute.Value {
value, _ = deduplicateValue(value)
return value
}
// KeyValue returns kv with all map values deduplicated.
func KeyValue(kv attribute.KeyValue) attribute.KeyValue {
kv, _ = deduplicateKeyValue(kv)
return kv
}
// KeyValues returns kvs with all map values deduplicated.
//
// The returned slice is the original kvs slice if no value needs
// deduplication. Top-level keys in kvs are not deduplicated.
func KeyValues(kvs []attribute.KeyValue) []attribute.KeyValue {
kvs, _ = deduplicateKeyValues(kvs)
return kvs
}
// Set returns set with all map values deduplicated.
//
// The returned Set is the original set if no value needs deduplication.
// Top-level key uniqueness remains attribute.Set's responsibility; this only
// normalizes map attribute values.
func Set(set attribute.Set) attribute.Set {
if set.Len() == 0 {
return set
}
// Most attribute sets contain no duplicate map keys. Delay allocation until
// the first changed value so the no-op path returns the original Set.
var normalized []attribute.KeyValue
for i := 0; i < set.Len(); i++ {
kv, _ := set.Get(i)
kv, changed := deduplicateKeyValue(kv)
if normalized != nil {
normalized = append(normalized, kv)
continue
}
if !changed {
continue
}
normalized = make([]attribute.KeyValue, 0, set.Len())
for j := 0; j < i; j++ {
prior, _ := set.Get(j)
normalized = append(normalized, prior)
}
normalized = append(normalized, kv)
}
if normalized == nil {
return set
}
return attribute.NewSet(normalized...)
}
func deduplicateKeyValues(kvs []attribute.KeyValue) ([]attribute.KeyValue, bool) {
// Preserve the caller's slice on the common no-op path. Once a changed
// value is found, copy the prior values exactly once and fill the rest in
// place as the scan continues.
var normalized []attribute.KeyValue
for i, kv := range kvs {
kv, changed := deduplicateKeyValue(kv)
if normalized != nil {
normalized[i] = kv
continue
}
if !changed {
continue
}
normalized = make([]attribute.KeyValue, len(kvs))
copy(normalized, kvs[:i])
normalized[i] = kv
}
if normalized == nil {
return kvs, false
}
return normalized, true
}
func deduplicateKeyValue(kv attribute.KeyValue) (attribute.KeyValue, bool) {
value, changed := deduplicateValue(kv.Value)
if changed {
kv.Value = value
}
return kv, changed
}
func deduplicateValue(value attribute.Value) (attribute.Value, bool) {
switch value.Type() {
case attribute.SLICE:
return deduplicateSliceValue(value)
case attribute.MAP:
return deduplicateMapValue(value)
default:
return value, false
}
}
func deduplicateSliceValue(value attribute.Value) (attribute.Value, bool) {
storage := valueStorage(value)
length := valueLen(storage)
// Slice values can contain map values, so recurse into each element while
// keeping the original attribute.Value when no element changes.
var normalized []attribute.Value
for i := 0; i < length; i++ {
elem := valueAt(storage, i)
elem, changed := deduplicateValue(elem)
if normalized != nil {
normalized[i] = elem
continue
}
if !changed {
continue
}
normalized = make([]attribute.Value, length)
for j := 0; j < i; j++ {
normalized[j] = valueAt(storage, j)
}
normalized[i] = elem
}
if normalized == nil {
return value, false
}
return attribute.SliceValue(normalized...), true
}
func deduplicateMapValue(value attribute.Value) (attribute.Value, bool) {
storage := valueStorage(value)
length := keyValueLen(storage)
if length <= 1 {
// A single map entry cannot duplicate its own key, but its value might
// contain a map or slice that needs recursive normalization.
if length == 1 {
kv, changed := deduplicateKeyValue(keyValueAt(storage, 0))
if changed {
return attribute.MapValue(kv), true
}
}
return value, false
}
var normalized []attribute.KeyValue
for i := 0; i < length; {
// attribute.MapValue stores key-values sorted by key using a stable
// sort. Equal keys therefore form a contiguous run, and the last
// element in that run is the last value provided by the caller.
first := keyValueAt(storage, i)
j := i + 1
for j < length && keyValueAt(storage, j).Key == first.Key {
j++
}
kv, nestedChanged := deduplicateKeyValue(keyValueAt(storage, j-1))
// j-i > 1 means the current key run contained duplicates.
changed := nestedChanged || j-i > 1
if normalized != nil {
normalized = append(normalized, kv)
} else if changed {
normalized = make([]attribute.KeyValue, 0, length)
for k := 0; k < i; k++ {
normalized = append(normalized, keyValueAt(storage, k))
}
normalized = append(normalized, kv)
}
i = j
}
if normalized == nil {
return value, false
}
return attribute.MapValue(normalized...), true
}
func valueStorage(value attribute.Value) any {
// attribute.Value does not expose allocation-free map/slice iteration.
// The raw mirror lets us read the immutable backing array directly and
// reserve AsMap/AsSlice-style allocation for paths that actually change.
return (*rawValue)(unsafe.Pointer(&value)).slice //nolint:gosec // Read-only mirror of attribute.Value for allocation-free iteration.
}
func valueLen(storage any) int {
// attribute.Value stores small slices in fixed-size array values. Handle
// the common sizes directly and fall back to reflection for larger arrays.
switch storage.(type) {
case [0]attribute.Value:
return 0
case [1]attribute.Value:
return 1
case [2]attribute.Value:
return 2
case [3]attribute.Value:
return 3
case [4]attribute.Value:
return 4
case [5]attribute.Value:
return 5
default:
return arrayLen(storage, valueType)
}
}
func valueAt(storage any, i int) attribute.Value {
switch values := storage.(type) {
case [1]attribute.Value:
return values[i]
case [2]attribute.Value:
return values[i]
case [3]attribute.Value:
return values[i]
case [4]attribute.Value:
return values[i]
case [5]attribute.Value:
return values[i]
default:
return arrayAt[attribute.Value](storage, valueType, i)
}
}
func keyValueLen(storage any) int {
// attribute.Value stores small maps in fixed-size key-value arrays. Handle
// the common sizes directly and fall back to reflection for larger arrays.
switch storage.(type) {
case [0]attribute.KeyValue:
return 0
case [1]attribute.KeyValue:
return 1
case [2]attribute.KeyValue:
return 2
case [3]attribute.KeyValue:
return 3
case [4]attribute.KeyValue:
return 4
case [5]attribute.KeyValue:
return 5
default:
return arrayLen(storage, keyValueType)
}
}
func keyValueAt(storage any, i int) attribute.KeyValue {
switch kvs := storage.(type) {
case [1]attribute.KeyValue:
return kvs[i]
case [2]attribute.KeyValue:
return kvs[i]
case [3]attribute.KeyValue:
return kvs[i]
case [4]attribute.KeyValue:
return kvs[i]
case [5]attribute.KeyValue:
return kvs[i]
default:
return arrayAt[attribute.KeyValue](storage, keyValueType, i)
}
}
func arrayLen(storage any, elem reflect.Type) int {
// Be defensive around invalid or unexpected Value storage. Returning zero
// makes malformed storage a no-op instead of panicking in telemetry paths.
array := reflect.ValueOf(storage)
if array.Kind() != reflect.Array || array.Type().Elem() != elem {
return 0
}
return array.Len()
}
func arrayAt[T any](storage any, elem reflect.Type, i int) T {
// Match arrayLen's fail-closed behavior for unexpected storage.
array := reflect.ValueOf(storage)
if array.Kind() != reflect.Array || array.Type().Elem() != elem || i < 0 || i >= array.Len() {
var zero T
return zero
}
return array.Index(i).Interface().(T)
}
@@ -0,0 +1,54 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package attrdedup_test
import (
"testing"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/internal/attrdedup"
)
func BenchmarkValue(b *testing.B) {
values := []struct {
name string
value attribute.Value
}{
{
name: "FastPath",
value: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("two", "2"),
attribute.String("three", "3"),
),
},
{
name: "DuplicateMap",
value: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("one", "2"),
attribute.String("two", "3"),
),
},
{
name: "NestedMapInSlice",
value: attribute.SliceValue(
attribute.MapValue(
attribute.String("one", "1"),
attribute.String("one", "2"),
attribute.String("two", "3"),
),
),
},
}
for _, value := range values {
b.Run(value.name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
attrdedup.Value(value.value)
}
})
}
}
+303
View File
@@ -0,0 +1,303 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/attrdedup/dedup_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package attrdedup
import (
"testing"
"github.com/google/go-cmp/cmp"
"go.opentelemetry.io/otel/attribute"
)
var cmpValue = cmp.AllowUnexported(attribute.Value{})
func TestValue(t *testing.T) {
tests := []struct {
name string
value attribute.Value
want attribute.Value
}{
{
name: "unique map",
value: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("two", "2"),
),
want: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("two", "2"),
),
},
{
name: "duplicate map",
value: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("one", "2"),
attribute.String("two", "3"),
),
want: attribute.MapValue(
attribute.String("one", "2"),
attribute.String("two", "3"),
),
},
{
name: "duplicate map after prior key",
value: attribute.MapValue(
attribute.String("a", "1"),
attribute.String("b", "2"),
attribute.String("b", "3"),
attribute.String("c", "4"),
),
want: attribute.MapValue(
attribute.String("a", "1"),
attribute.String("b", "3"),
attribute.String("c", "4"),
),
},
{
name: "nested map",
value: attribute.MapValue(
attribute.Map(
"outer",
attribute.String("inner", "1"),
attribute.String("inner", "2"),
),
),
want: attribute.MapValue(
attribute.Map(
"outer",
attribute.String("inner", "2"),
),
),
},
{
name: "map inside slice",
value: attribute.SliceValue(
attribute.StringValue("prior"),
attribute.MapValue(
attribute.String("inner", "1"),
attribute.String("inner", "2"),
),
attribute.StringValue("tail"),
),
want: attribute.SliceValue(
attribute.StringValue("prior"),
attribute.MapValue(
attribute.String("inner", "2"),
),
attribute.StringValue("tail"),
),
},
{
name: "unique slice",
value: attribute.SliceValue(
attribute.StringValue("one"),
attribute.IntValue(2),
),
want: attribute.SliceValue(
attribute.StringValue("one"),
attribute.IntValue(2),
),
},
{
name: "empty and invalid keys",
value: attribute.MapValue(
attribute.KeyValue{},
attribute.String("", "empty"),
attribute.String("valid", "value"),
),
want: attribute.MapValue(
attribute.String("", "empty"),
attribute.String("valid", "value"),
),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := Value(test.value)
if diff := cmp.Diff(test.want, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestValueNoopAllocationFree(t *testing.T) {
value := attribute.MapValue(
attribute.String("one", "1"),
attribute.String("two", "2"),
)
var got attribute.Value
allocs := testing.AllocsPerRun(1000, func() {
got = Value(value)
})
if allocs != 0 {
t.Fatalf("Value() allocations = %v, want 0", allocs)
}
if diff := cmp.Diff(value, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
}
func TestValueStorageShapes(t *testing.T) {
for n := 0; n <= 6; n++ {
t.Run("map", func(t *testing.T) {
kvs := make([]attribute.KeyValue, n)
for i := range kvs {
kvs[i] = attribute.Int(string(rune('a'+i)), i)
}
value := attribute.MapValue(kvs...)
got := Value(value)
if diff := cmp.Diff(value, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
})
t.Run("slice", func(t *testing.T) {
values := make([]attribute.Value, n)
for i := range values {
values[i] = attribute.IntValue(i)
}
value := attribute.SliceValue(values...)
got := Value(value)
if diff := cmp.Diff(value, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestKeyValue(t *testing.T) {
kv := attribute.Map(
"map",
attribute.String("nested", "first"),
attribute.String("nested", "second"),
)
want := attribute.Map(
"map",
attribute.String("nested", "second"),
)
got := KeyValue(kv)
if diff := cmp.Diff(want, got, cmpValue); diff != "" {
t.Fatalf("KeyValue() mismatch (-want +got):\n%s", diff)
}
}
func TestKeyValuesNoopReturnsInput(t *testing.T) {
kvs := []attribute.KeyValue{
attribute.String("one", "1"),
attribute.Map("two", attribute.String("nested", "value")),
}
got := KeyValues(kvs)
if len(got) != len(kvs) {
t.Fatalf("KeyValues() length = %d, want %d", len(got), len(kvs))
}
if &got[0] != &kvs[0] {
t.Fatal("KeyValues() copied a no-op input")
}
}
func TestKeyValues(t *testing.T) {
kvs := []attribute.KeyValue{
attribute.String("top", "value"),
attribute.Map(
"map",
attribute.String("nested", "first"),
attribute.String("nested", "second"),
),
attribute.String("tail", "value"),
}
want := []attribute.KeyValue{
attribute.String("top", "value"),
attribute.Map(
"map",
attribute.String("nested", "second"),
),
attribute.String("tail", "value"),
}
got := KeyValues(kvs)
if diff := cmp.Diff(want, got, cmpValue); diff != "" {
t.Fatalf("KeyValues() mismatch (-want +got):\n%s", diff)
}
}
func TestSet(t *testing.T) {
set := attribute.NewSet(
attribute.String("a-top", "value"),
attribute.Map(
"m-map",
attribute.String("nested", "first"),
attribute.String("nested", "second"),
),
attribute.String("z-tail", "value"),
)
want := attribute.NewSet(
attribute.String("a-top", "value"),
attribute.Map(
"m-map",
attribute.String("nested", "second"),
),
attribute.String("z-tail", "value"),
)
got := Set(set)
if diff := cmp.Diff(want.ToSlice(), got.ToSlice(), cmpValue); diff != "" {
t.Fatalf("Set() mismatch (-want +got):\n%s", diff)
}
}
func TestSetNoop(t *testing.T) {
set := attribute.NewSet(
attribute.String("top", "value"),
attribute.Map("map", attribute.String("nested", "value")),
)
got := Set(set)
if !got.Equals(&set) {
t.Fatal("Set() changed a no-op input")
}
}
func TestSetEmpty(t *testing.T) {
set := attribute.Set{}
got := Set(set)
if !got.Equals(&set) {
t.Fatal("Set() changed an empty input")
}
}
func TestInvalidArrayStorage(t *testing.T) {
if got := arrayLen("invalid", valueType); got != 0 {
t.Fatalf("arrayLen() = %d, want 0", got)
}
if got := arrayAt[attribute.Value]("invalid", valueType, 0); got.Type() != attribute.EMPTY {
t.Fatalf("arrayAt() = %v, want empty value", got)
}
if got := arrayAt[attribute.Value](
[1]attribute.Value{attribute.StringValue("value")},
keyValueType,
0,
); got.Type() != attribute.EMPTY {
t.Fatalf("arrayAt() = %v, want empty value", got)
}
if got := arrayAt[attribute.Value](
[1]attribute.Value{attribute.StringValue("value")},
valueType,
-1,
); got.Type() != attribute.EMPTY {
t.Fatalf("arrayAt() = %v, want empty value", got)
}
}
+2
View File
@@ -6,3 +6,5 @@ package internal // import "go.opentelemetry.io/otel/sdk/internal"
//go:generate gotmpl --body=../../internal/shared/x/x.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/otel/sdk\" }" --out=x/x.go
//go:generate gotmpl --body=../../internal/shared/x/x_test.go.tmpl "--data={}" --out=x/x_test.go
//go:generate gotmpl --body=../../internal/shared/attrdedup/dedup.go.tmpl "--data={}" --out=attrdedup/dedup.go
//go:generate gotmpl --body=../../internal/shared/attrdedup/dedup_test.go.tmpl "--data={}" --out=attrdedup/dedup_test.go
+313
View File
@@ -0,0 +1,313 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/attrdedup/dedup.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
// Package attrdedup deduplicates attribute map values.
package attrdedup // import "go.opentelemetry.io/otel/sdk/log/internal/attrdedup"
import (
"reflect"
"unsafe"
"go.opentelemetry.io/otel/attribute"
)
var (
keyValueType = reflect.TypeFor[attribute.KeyValue]()
valueType = reflect.TypeFor[attribute.Value]()
)
// rawValue mirrors attribute.Value. It is used only to read immutable slice
// storage without calling AsMap or AsSlice on no-op paths.
type rawValue struct {
vtype attribute.Type
numeric uint64
stringly string
slice any
}
// Value returns value with all map values deduplicated.
//
// Duplicate map keys are resolved using last-value-wins semantics.
func Value(value attribute.Value) attribute.Value {
value, _ = deduplicateValue(value)
return value
}
// KeyValue returns kv with all map values deduplicated.
func KeyValue(kv attribute.KeyValue) attribute.KeyValue {
kv, _ = deduplicateKeyValue(kv)
return kv
}
// KeyValues returns kvs with all map values deduplicated.
//
// The returned slice is the original kvs slice if no value needs
// deduplication. Top-level keys in kvs are not deduplicated.
func KeyValues(kvs []attribute.KeyValue) []attribute.KeyValue {
kvs, _ = deduplicateKeyValues(kvs)
return kvs
}
// Set returns set with all map values deduplicated.
//
// The returned Set is the original set if no value needs deduplication.
// Top-level key uniqueness remains attribute.Set's responsibility; this only
// normalizes map attribute values.
func Set(set attribute.Set) attribute.Set {
if set.Len() == 0 {
return set
}
// Most attribute sets contain no duplicate map keys. Delay allocation until
// the first changed value so the no-op path returns the original Set.
var normalized []attribute.KeyValue
for i := 0; i < set.Len(); i++ {
kv, _ := set.Get(i)
kv, changed := deduplicateKeyValue(kv)
if normalized != nil {
normalized = append(normalized, kv)
continue
}
if !changed {
continue
}
normalized = make([]attribute.KeyValue, 0, set.Len())
for j := 0; j < i; j++ {
prior, _ := set.Get(j)
normalized = append(normalized, prior)
}
normalized = append(normalized, kv)
}
if normalized == nil {
return set
}
return attribute.NewSet(normalized...)
}
func deduplicateKeyValues(kvs []attribute.KeyValue) ([]attribute.KeyValue, bool) {
// Preserve the caller's slice on the common no-op path. Once a changed
// value is found, copy the prior values exactly once and fill the rest in
// place as the scan continues.
var normalized []attribute.KeyValue
for i, kv := range kvs {
kv, changed := deduplicateKeyValue(kv)
if normalized != nil {
normalized[i] = kv
continue
}
if !changed {
continue
}
normalized = make([]attribute.KeyValue, len(kvs))
copy(normalized, kvs[:i])
normalized[i] = kv
}
if normalized == nil {
return kvs, false
}
return normalized, true
}
func deduplicateKeyValue(kv attribute.KeyValue) (attribute.KeyValue, bool) {
value, changed := deduplicateValue(kv.Value)
if changed {
kv.Value = value
}
return kv, changed
}
func deduplicateValue(value attribute.Value) (attribute.Value, bool) {
switch value.Type() {
case attribute.SLICE:
return deduplicateSliceValue(value)
case attribute.MAP:
return deduplicateMapValue(value)
default:
return value, false
}
}
func deduplicateSliceValue(value attribute.Value) (attribute.Value, bool) {
storage := valueStorage(value)
length := valueLen(storage)
// Slice values can contain map values, so recurse into each element while
// keeping the original attribute.Value when no element changes.
var normalized []attribute.Value
for i := 0; i < length; i++ {
elem := valueAt(storage, i)
elem, changed := deduplicateValue(elem)
if normalized != nil {
normalized[i] = elem
continue
}
if !changed {
continue
}
normalized = make([]attribute.Value, length)
for j := 0; j < i; j++ {
normalized[j] = valueAt(storage, j)
}
normalized[i] = elem
}
if normalized == nil {
return value, false
}
return attribute.SliceValue(normalized...), true
}
func deduplicateMapValue(value attribute.Value) (attribute.Value, bool) {
storage := valueStorage(value)
length := keyValueLen(storage)
if length <= 1 {
// A single map entry cannot duplicate its own key, but its value might
// contain a map or slice that needs recursive normalization.
if length == 1 {
kv, changed := deduplicateKeyValue(keyValueAt(storage, 0))
if changed {
return attribute.MapValue(kv), true
}
}
return value, false
}
var normalized []attribute.KeyValue
for i := 0; i < length; {
// attribute.MapValue stores key-values sorted by key using a stable
// sort. Equal keys therefore form a contiguous run, and the last
// element in that run is the last value provided by the caller.
first := keyValueAt(storage, i)
j := i + 1
for j < length && keyValueAt(storage, j).Key == first.Key {
j++
}
kv, nestedChanged := deduplicateKeyValue(keyValueAt(storage, j-1))
// j-i > 1 means the current key run contained duplicates.
changed := nestedChanged || j-i > 1
if normalized != nil {
normalized = append(normalized, kv)
} else if changed {
normalized = make([]attribute.KeyValue, 0, length)
for k := 0; k < i; k++ {
normalized = append(normalized, keyValueAt(storage, k))
}
normalized = append(normalized, kv)
}
i = j
}
if normalized == nil {
return value, false
}
return attribute.MapValue(normalized...), true
}
func valueStorage(value attribute.Value) any {
// attribute.Value does not expose allocation-free map/slice iteration.
// The raw mirror lets us read the immutable backing array directly and
// reserve AsMap/AsSlice-style allocation for paths that actually change.
return (*rawValue)(unsafe.Pointer(&value)).slice //nolint:gosec // Read-only mirror of attribute.Value for allocation-free iteration.
}
func valueLen(storage any) int {
// attribute.Value stores small slices in fixed-size array values. Handle
// the common sizes directly and fall back to reflection for larger arrays.
switch storage.(type) {
case [0]attribute.Value:
return 0
case [1]attribute.Value:
return 1
case [2]attribute.Value:
return 2
case [3]attribute.Value:
return 3
case [4]attribute.Value:
return 4
case [5]attribute.Value:
return 5
default:
return arrayLen(storage, valueType)
}
}
func valueAt(storage any, i int) attribute.Value {
switch values := storage.(type) {
case [1]attribute.Value:
return values[i]
case [2]attribute.Value:
return values[i]
case [3]attribute.Value:
return values[i]
case [4]attribute.Value:
return values[i]
case [5]attribute.Value:
return values[i]
default:
return arrayAt[attribute.Value](storage, valueType, i)
}
}
func keyValueLen(storage any) int {
// attribute.Value stores small maps in fixed-size key-value arrays. Handle
// the common sizes directly and fall back to reflection for larger arrays.
switch storage.(type) {
case [0]attribute.KeyValue:
return 0
case [1]attribute.KeyValue:
return 1
case [2]attribute.KeyValue:
return 2
case [3]attribute.KeyValue:
return 3
case [4]attribute.KeyValue:
return 4
case [5]attribute.KeyValue:
return 5
default:
return arrayLen(storage, keyValueType)
}
}
func keyValueAt(storage any, i int) attribute.KeyValue {
switch kvs := storage.(type) {
case [1]attribute.KeyValue:
return kvs[i]
case [2]attribute.KeyValue:
return kvs[i]
case [3]attribute.KeyValue:
return kvs[i]
case [4]attribute.KeyValue:
return kvs[i]
case [5]attribute.KeyValue:
return kvs[i]
default:
return arrayAt[attribute.KeyValue](storage, keyValueType, i)
}
}
func arrayLen(storage any, elem reflect.Type) int {
// Be defensive around invalid or unexpected Value storage. Returning zero
// makes malformed storage a no-op instead of panicking in telemetry paths.
array := reflect.ValueOf(storage)
if array.Kind() != reflect.Array || array.Type().Elem() != elem {
return 0
}
return array.Len()
}
func arrayAt[T any](storage any, elem reflect.Type, i int) T {
// Match arrayLen's fail-closed behavior for unexpected storage.
array := reflect.ValueOf(storage)
if array.Kind() != reflect.Array || array.Type().Elem() != elem || i < 0 || i >= array.Len() {
var zero T
return zero
}
return array.Index(i).Interface().(T)
}
+303
View File
@@ -0,0 +1,303 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/attrdedup/dedup_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package attrdedup
import (
"testing"
"github.com/google/go-cmp/cmp"
"go.opentelemetry.io/otel/attribute"
)
var cmpValue = cmp.AllowUnexported(attribute.Value{})
func TestValue(t *testing.T) {
tests := []struct {
name string
value attribute.Value
want attribute.Value
}{
{
name: "unique map",
value: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("two", "2"),
),
want: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("two", "2"),
),
},
{
name: "duplicate map",
value: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("one", "2"),
attribute.String("two", "3"),
),
want: attribute.MapValue(
attribute.String("one", "2"),
attribute.String("two", "3"),
),
},
{
name: "duplicate map after prior key",
value: attribute.MapValue(
attribute.String("a", "1"),
attribute.String("b", "2"),
attribute.String("b", "3"),
attribute.String("c", "4"),
),
want: attribute.MapValue(
attribute.String("a", "1"),
attribute.String("b", "3"),
attribute.String("c", "4"),
),
},
{
name: "nested map",
value: attribute.MapValue(
attribute.Map(
"outer",
attribute.String("inner", "1"),
attribute.String("inner", "2"),
),
),
want: attribute.MapValue(
attribute.Map(
"outer",
attribute.String("inner", "2"),
),
),
},
{
name: "map inside slice",
value: attribute.SliceValue(
attribute.StringValue("prior"),
attribute.MapValue(
attribute.String("inner", "1"),
attribute.String("inner", "2"),
),
attribute.StringValue("tail"),
),
want: attribute.SliceValue(
attribute.StringValue("prior"),
attribute.MapValue(
attribute.String("inner", "2"),
),
attribute.StringValue("tail"),
),
},
{
name: "unique slice",
value: attribute.SliceValue(
attribute.StringValue("one"),
attribute.IntValue(2),
),
want: attribute.SliceValue(
attribute.StringValue("one"),
attribute.IntValue(2),
),
},
{
name: "empty and invalid keys",
value: attribute.MapValue(
attribute.KeyValue{},
attribute.String("", "empty"),
attribute.String("valid", "value"),
),
want: attribute.MapValue(
attribute.String("", "empty"),
attribute.String("valid", "value"),
),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := Value(test.value)
if diff := cmp.Diff(test.want, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestValueNoopAllocationFree(t *testing.T) {
value := attribute.MapValue(
attribute.String("one", "1"),
attribute.String("two", "2"),
)
var got attribute.Value
allocs := testing.AllocsPerRun(1000, func() {
got = Value(value)
})
if allocs != 0 {
t.Fatalf("Value() allocations = %v, want 0", allocs)
}
if diff := cmp.Diff(value, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
}
func TestValueStorageShapes(t *testing.T) {
for n := 0; n <= 6; n++ {
t.Run("map", func(t *testing.T) {
kvs := make([]attribute.KeyValue, n)
for i := range kvs {
kvs[i] = attribute.Int(string(rune('a'+i)), i)
}
value := attribute.MapValue(kvs...)
got := Value(value)
if diff := cmp.Diff(value, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
})
t.Run("slice", func(t *testing.T) {
values := make([]attribute.Value, n)
for i := range values {
values[i] = attribute.IntValue(i)
}
value := attribute.SliceValue(values...)
got := Value(value)
if diff := cmp.Diff(value, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestKeyValue(t *testing.T) {
kv := attribute.Map(
"map",
attribute.String("nested", "first"),
attribute.String("nested", "second"),
)
want := attribute.Map(
"map",
attribute.String("nested", "second"),
)
got := KeyValue(kv)
if diff := cmp.Diff(want, got, cmpValue); diff != "" {
t.Fatalf("KeyValue() mismatch (-want +got):\n%s", diff)
}
}
func TestKeyValuesNoopReturnsInput(t *testing.T) {
kvs := []attribute.KeyValue{
attribute.String("one", "1"),
attribute.Map("two", attribute.String("nested", "value")),
}
got := KeyValues(kvs)
if len(got) != len(kvs) {
t.Fatalf("KeyValues() length = %d, want %d", len(got), len(kvs))
}
if &got[0] != &kvs[0] {
t.Fatal("KeyValues() copied a no-op input")
}
}
func TestKeyValues(t *testing.T) {
kvs := []attribute.KeyValue{
attribute.String("top", "value"),
attribute.Map(
"map",
attribute.String("nested", "first"),
attribute.String("nested", "second"),
),
attribute.String("tail", "value"),
}
want := []attribute.KeyValue{
attribute.String("top", "value"),
attribute.Map(
"map",
attribute.String("nested", "second"),
),
attribute.String("tail", "value"),
}
got := KeyValues(kvs)
if diff := cmp.Diff(want, got, cmpValue); diff != "" {
t.Fatalf("KeyValues() mismatch (-want +got):\n%s", diff)
}
}
func TestSet(t *testing.T) {
set := attribute.NewSet(
attribute.String("a-top", "value"),
attribute.Map(
"m-map",
attribute.String("nested", "first"),
attribute.String("nested", "second"),
),
attribute.String("z-tail", "value"),
)
want := attribute.NewSet(
attribute.String("a-top", "value"),
attribute.Map(
"m-map",
attribute.String("nested", "second"),
),
attribute.String("z-tail", "value"),
)
got := Set(set)
if diff := cmp.Diff(want.ToSlice(), got.ToSlice(), cmpValue); diff != "" {
t.Fatalf("Set() mismatch (-want +got):\n%s", diff)
}
}
func TestSetNoop(t *testing.T) {
set := attribute.NewSet(
attribute.String("top", "value"),
attribute.Map("map", attribute.String("nested", "value")),
)
got := Set(set)
if !got.Equals(&set) {
t.Fatal("Set() changed a no-op input")
}
}
func TestSetEmpty(t *testing.T) {
set := attribute.Set{}
got := Set(set)
if !got.Equals(&set) {
t.Fatal("Set() changed an empty input")
}
}
func TestInvalidArrayStorage(t *testing.T) {
if got := arrayLen("invalid", valueType); got != 0 {
t.Fatalf("arrayLen() = %d, want 0", got)
}
if got := arrayAt[attribute.Value]("invalid", valueType, 0); got.Type() != attribute.EMPTY {
t.Fatalf("arrayAt() = %v, want empty value", got)
}
if got := arrayAt[attribute.Value](
[1]attribute.Value{attribute.StringValue("value")},
keyValueType,
0,
); got.Type() != attribute.EMPTY {
t.Fatalf("arrayAt() = %v, want empty value", got)
}
if got := arrayAt[attribute.Value](
[1]attribute.Value{attribute.StringValue("value")},
valueType,
-1,
); got.Type() != attribute.EMPTY {
t.Fatalf("arrayAt() = %v, want empty value", got)
}
}
+2
View File
@@ -6,3 +6,5 @@ package internal // import "go.opentelemetry.io/otel/sdk/log/internal"
//go:generate gotmpl --body=../../../internal/shared/x/x.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/otel/sdk/log\" }" --out=x/x.go
//go:generate gotmpl --body=../../../internal/shared/x/x_test.go.tmpl "--data={}" --out=x/x_test.go
//go:generate gotmpl --body=../../../internal/shared/attrdedup/dedup.go.tmpl "--data={}" --out=attrdedup/dedup.go
//go:generate gotmpl --body=../../../internal/shared/attrdedup/dedup_test.go.tmpl "--data={}" --out=attrdedup/dedup_test.go
+15 -6
View File
@@ -15,6 +15,7 @@ import (
"go.opentelemetry.io/otel/log/embedded"
"go.opentelemetry.io/otel/log/noop"
"go.opentelemetry.io/otel/sdk/instrumentation"
"go.opentelemetry.io/otel/sdk/log/internal/attrdedup"
"go.opentelemetry.io/otel/sdk/resource"
)
@@ -118,11 +119,15 @@ func (p *LoggerProvider) Logger(name string, opts ...log.LoggerOption) log.Logge
}
cfg := log.NewLoggerConfig(opts...)
attrs := cfg.InstrumentationAttributes()
if !p.allowDupKeys {
attrs = attrdedup.Set(attrs)
}
scope := instrumentation.Scope{
Name: name,
Version: cfg.InstrumentationVersion(),
SchemaURL: cfg.SchemaURL(),
Attributes: cfg.InstrumentationAttributes(),
Attributes: attrs,
}
p.loggersMu.Lock()
@@ -257,16 +262,20 @@ func WithAttributeValueLengthLimit(limit int) LoggerProviderOption {
})
}
// WithAllowKeyDuplication sets whether deduplication is skipped for log attributes or other key-value collections.
// WithAllowKeyDuplication sets whether deduplication is skipped for log record
// and instrumentation scope key-value collections.
//
// By default, the key-value collections within a log record are deduplicated to comply with the OpenTelemetry Specification.
// Deduplication means that if multiple key–value pairs with the same key are present, only a single pair
// is retained and others are discarded.
// By default, the key-value collections within a log record and
// instrumentation scope are deduplicated to comply with the OpenTelemetry
// Specification.
// Deduplication means that if multiple key-value pairs with the same key are
// present, only a single pair is retained and others are discarded. Resource
// attributes are always deduplicated by go.opentelemetry.io/otel/sdk/resource.
//
// Disabling deduplication with this option can improve performance e.g. of adding attributes to the log record.
//
// Note that if you disable deduplication, you are responsible for ensuring that duplicate
// key-value pairs within in a single collection are not emitted,
// key-value pairs within a single collection are not emitted,
// or that the telemetry receiver can handle such duplicates.
func WithAllowKeyDuplication() LoggerProviderOption {
return loggerProviderOptionFunc(func(cfg providerConfig) providerConfig {
+46
View File
@@ -250,6 +250,52 @@ func TestWithResource(t *testing.T) {
}
}
func TestMapDeduplication(t *testing.T) {
dup := attribute.Map(
"map",
attribute.String("key", "first"),
attribute.String("key", "second"),
)
dedup := attribute.Map("map", attribute.String("key", "second"))
res := resource.NewSchemaless(dup)
t.Run("Resource", func(t *testing.T) {
got := newProviderConfig([]LoggerProviderOption{WithResource(res)}).resource
assert.Equal(t, []attribute.KeyValue{dedup}, got.Attributes())
})
t.Run("ResourceAlwaysDeduplicates", func(t *testing.T) {
got := newProviderConfig([]LoggerProviderOption{
WithResource(res),
WithAllowKeyDuplication(),
}).resource
assert.Equal(t, []attribute.KeyValue{dedup}, got.Attributes())
})
t.Run("Scope", func(t *testing.T) {
p := newProcessor("processor")
lp := NewLoggerProvider(WithProcessor(p))
l := lp.Logger("scope", log.WithInstrumentationAttributes(dup))
l.Emit(t.Context(), log.Record{})
require.Len(t, p.records, 1)
assert.Equal(t, attribute.NewSet(dedup), p.records[0].InstrumentationScope().Attributes)
})
t.Run("ScopeWithAllowKeyDuplication", func(t *testing.T) {
p := newProcessor("processor")
lp := NewLoggerProvider(WithProcessor(p), WithAllowKeyDuplication())
l := lp.Logger("scope", log.WithInstrumentationAttributes(dup))
l.Emit(t.Context(), log.Record{})
require.Len(t, p.records, 1)
assert.Equal(t, attribute.NewSet(dup), p.records[0].InstrumentationScope().Attributes)
})
}
func TestLoggerProviderConcurrentSafe(t *testing.T) {
const goRoutineN = 10
+3
View File
@@ -16,6 +16,7 @@ import (
"go.opentelemetry.io/otel/metric/embedded"
"go.opentelemetry.io/otel/sdk/instrumentation"
"go.opentelemetry.io/otel/sdk/metric/internal/aggregate"
"go.opentelemetry.io/otel/sdk/metric/internal/attrdedup"
)
var zeroScope instrumentation.Scope
@@ -206,9 +207,11 @@ func extractRawKVs[T any](opts []T) []attribute.KeyValue {
}
func resolveAttributes(configAttrs attribute.Set, rawKVs []attribute.KeyValue) attribute.Set {
configAttrs = attrdedup.Set(configAttrs)
if len(rawKVs) == 0 {
return configAttrs
}
rawKVs = attrdedup.KeyValues(rawKVs)
merged := make([]attribute.KeyValue, 0, configAttrs.Len()+len(rawKVs))
merged = append(merged, configAttrs.ToSlice()...)
// rawKVs are appended after configAttrs, meaning they will override any duplicate keys in configAttrs.
+313
View File
@@ -0,0 +1,313 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/attrdedup/dedup.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
// Package attrdedup deduplicates attribute map values.
package attrdedup // import "go.opentelemetry.io/otel/sdk/metric/internal/attrdedup"
import (
"reflect"
"unsafe"
"go.opentelemetry.io/otel/attribute"
)
var (
keyValueType = reflect.TypeFor[attribute.KeyValue]()
valueType = reflect.TypeFor[attribute.Value]()
)
// rawValue mirrors attribute.Value. It is used only to read immutable slice
// storage without calling AsMap or AsSlice on no-op paths.
type rawValue struct {
vtype attribute.Type
numeric uint64
stringly string
slice any
}
// Value returns value with all map values deduplicated.
//
// Duplicate map keys are resolved using last-value-wins semantics.
func Value(value attribute.Value) attribute.Value {
value, _ = deduplicateValue(value)
return value
}
// KeyValue returns kv with all map values deduplicated.
func KeyValue(kv attribute.KeyValue) attribute.KeyValue {
kv, _ = deduplicateKeyValue(kv)
return kv
}
// KeyValues returns kvs with all map values deduplicated.
//
// The returned slice is the original kvs slice if no value needs
// deduplication. Top-level keys in kvs are not deduplicated.
func KeyValues(kvs []attribute.KeyValue) []attribute.KeyValue {
kvs, _ = deduplicateKeyValues(kvs)
return kvs
}
// Set returns set with all map values deduplicated.
//
// The returned Set is the original set if no value needs deduplication.
// Top-level key uniqueness remains attribute.Set's responsibility; this only
// normalizes map attribute values.
func Set(set attribute.Set) attribute.Set {
if set.Len() == 0 {
return set
}
// Most attribute sets contain no duplicate map keys. Delay allocation until
// the first changed value so the no-op path returns the original Set.
var normalized []attribute.KeyValue
for i := 0; i < set.Len(); i++ {
kv, _ := set.Get(i)
kv, changed := deduplicateKeyValue(kv)
if normalized != nil {
normalized = append(normalized, kv)
continue
}
if !changed {
continue
}
normalized = make([]attribute.KeyValue, 0, set.Len())
for j := 0; j < i; j++ {
prior, _ := set.Get(j)
normalized = append(normalized, prior)
}
normalized = append(normalized, kv)
}
if normalized == nil {
return set
}
return attribute.NewSet(normalized...)
}
func deduplicateKeyValues(kvs []attribute.KeyValue) ([]attribute.KeyValue, bool) {
// Preserve the caller's slice on the common no-op path. Once a changed
// value is found, copy the prior values exactly once and fill the rest in
// place as the scan continues.
var normalized []attribute.KeyValue
for i, kv := range kvs {
kv, changed := deduplicateKeyValue(kv)
if normalized != nil {
normalized[i] = kv
continue
}
if !changed {
continue
}
normalized = make([]attribute.KeyValue, len(kvs))
copy(normalized, kvs[:i])
normalized[i] = kv
}
if normalized == nil {
return kvs, false
}
return normalized, true
}
func deduplicateKeyValue(kv attribute.KeyValue) (attribute.KeyValue, bool) {
value, changed := deduplicateValue(kv.Value)
if changed {
kv.Value = value
}
return kv, changed
}
func deduplicateValue(value attribute.Value) (attribute.Value, bool) {
switch value.Type() {
case attribute.SLICE:
return deduplicateSliceValue(value)
case attribute.MAP:
return deduplicateMapValue(value)
default:
return value, false
}
}
func deduplicateSliceValue(value attribute.Value) (attribute.Value, bool) {
storage := valueStorage(value)
length := valueLen(storage)
// Slice values can contain map values, so recurse into each element while
// keeping the original attribute.Value when no element changes.
var normalized []attribute.Value
for i := 0; i < length; i++ {
elem := valueAt(storage, i)
elem, changed := deduplicateValue(elem)
if normalized != nil {
normalized[i] = elem
continue
}
if !changed {
continue
}
normalized = make([]attribute.Value, length)
for j := 0; j < i; j++ {
normalized[j] = valueAt(storage, j)
}
normalized[i] = elem
}
if normalized == nil {
return value, false
}
return attribute.SliceValue(normalized...), true
}
func deduplicateMapValue(value attribute.Value) (attribute.Value, bool) {
storage := valueStorage(value)
length := keyValueLen(storage)
if length <= 1 {
// A single map entry cannot duplicate its own key, but its value might
// contain a map or slice that needs recursive normalization.
if length == 1 {
kv, changed := deduplicateKeyValue(keyValueAt(storage, 0))
if changed {
return attribute.MapValue(kv), true
}
}
return value, false
}
var normalized []attribute.KeyValue
for i := 0; i < length; {
// attribute.MapValue stores key-values sorted by key using a stable
// sort. Equal keys therefore form a contiguous run, and the last
// element in that run is the last value provided by the caller.
first := keyValueAt(storage, i)
j := i + 1
for j < length && keyValueAt(storage, j).Key == first.Key {
j++
}
kv, nestedChanged := deduplicateKeyValue(keyValueAt(storage, j-1))
// j-i > 1 means the current key run contained duplicates.
changed := nestedChanged || j-i > 1
if normalized != nil {
normalized = append(normalized, kv)
} else if changed {
normalized = make([]attribute.KeyValue, 0, length)
for k := 0; k < i; k++ {
normalized = append(normalized, keyValueAt(storage, k))
}
normalized = append(normalized, kv)
}
i = j
}
if normalized == nil {
return value, false
}
return attribute.MapValue(normalized...), true
}
func valueStorage(value attribute.Value) any {
// attribute.Value does not expose allocation-free map/slice iteration.
// The raw mirror lets us read the immutable backing array directly and
// reserve AsMap/AsSlice-style allocation for paths that actually change.
return (*rawValue)(unsafe.Pointer(&value)).slice //nolint:gosec // Read-only mirror of attribute.Value for allocation-free iteration.
}
func valueLen(storage any) int {
// attribute.Value stores small slices in fixed-size array values. Handle
// the common sizes directly and fall back to reflection for larger arrays.
switch storage.(type) {
case [0]attribute.Value:
return 0
case [1]attribute.Value:
return 1
case [2]attribute.Value:
return 2
case [3]attribute.Value:
return 3
case [4]attribute.Value:
return 4
case [5]attribute.Value:
return 5
default:
return arrayLen(storage, valueType)
}
}
func valueAt(storage any, i int) attribute.Value {
switch values := storage.(type) {
case [1]attribute.Value:
return values[i]
case [2]attribute.Value:
return values[i]
case [3]attribute.Value:
return values[i]
case [4]attribute.Value:
return values[i]
case [5]attribute.Value:
return values[i]
default:
return arrayAt[attribute.Value](storage, valueType, i)
}
}
func keyValueLen(storage any) int {
// attribute.Value stores small maps in fixed-size key-value arrays. Handle
// the common sizes directly and fall back to reflection for larger arrays.
switch storage.(type) {
case [0]attribute.KeyValue:
return 0
case [1]attribute.KeyValue:
return 1
case [2]attribute.KeyValue:
return 2
case [3]attribute.KeyValue:
return 3
case [4]attribute.KeyValue:
return 4
case [5]attribute.KeyValue:
return 5
default:
return arrayLen(storage, keyValueType)
}
}
func keyValueAt(storage any, i int) attribute.KeyValue {
switch kvs := storage.(type) {
case [1]attribute.KeyValue:
return kvs[i]
case [2]attribute.KeyValue:
return kvs[i]
case [3]attribute.KeyValue:
return kvs[i]
case [4]attribute.KeyValue:
return kvs[i]
case [5]attribute.KeyValue:
return kvs[i]
default:
return arrayAt[attribute.KeyValue](storage, keyValueType, i)
}
}
func arrayLen(storage any, elem reflect.Type) int {
// Be defensive around invalid or unexpected Value storage. Returning zero
// makes malformed storage a no-op instead of panicking in telemetry paths.
array := reflect.ValueOf(storage)
if array.Kind() != reflect.Array || array.Type().Elem() != elem {
return 0
}
return array.Len()
}
func arrayAt[T any](storage any, elem reflect.Type, i int) T {
// Match arrayLen's fail-closed behavior for unexpected storage.
array := reflect.ValueOf(storage)
if array.Kind() != reflect.Array || array.Type().Elem() != elem || i < 0 || i >= array.Len() {
var zero T
return zero
}
return array.Index(i).Interface().(T)
}
+303
View File
@@ -0,0 +1,303 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/attrdedup/dedup_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package attrdedup
import (
"testing"
"github.com/google/go-cmp/cmp"
"go.opentelemetry.io/otel/attribute"
)
var cmpValue = cmp.AllowUnexported(attribute.Value{})
func TestValue(t *testing.T) {
tests := []struct {
name string
value attribute.Value
want attribute.Value
}{
{
name: "unique map",
value: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("two", "2"),
),
want: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("two", "2"),
),
},
{
name: "duplicate map",
value: attribute.MapValue(
attribute.String("one", "1"),
attribute.String("one", "2"),
attribute.String("two", "3"),
),
want: attribute.MapValue(
attribute.String("one", "2"),
attribute.String("two", "3"),
),
},
{
name: "duplicate map after prior key",
value: attribute.MapValue(
attribute.String("a", "1"),
attribute.String("b", "2"),
attribute.String("b", "3"),
attribute.String("c", "4"),
),
want: attribute.MapValue(
attribute.String("a", "1"),
attribute.String("b", "3"),
attribute.String("c", "4"),
),
},
{
name: "nested map",
value: attribute.MapValue(
attribute.Map(
"outer",
attribute.String("inner", "1"),
attribute.String("inner", "2"),
),
),
want: attribute.MapValue(
attribute.Map(
"outer",
attribute.String("inner", "2"),
),
),
},
{
name: "map inside slice",
value: attribute.SliceValue(
attribute.StringValue("prior"),
attribute.MapValue(
attribute.String("inner", "1"),
attribute.String("inner", "2"),
),
attribute.StringValue("tail"),
),
want: attribute.SliceValue(
attribute.StringValue("prior"),
attribute.MapValue(
attribute.String("inner", "2"),
),
attribute.StringValue("tail"),
),
},
{
name: "unique slice",
value: attribute.SliceValue(
attribute.StringValue("one"),
attribute.IntValue(2),
),
want: attribute.SliceValue(
attribute.StringValue("one"),
attribute.IntValue(2),
),
},
{
name: "empty and invalid keys",
value: attribute.MapValue(
attribute.KeyValue{},
attribute.String("", "empty"),
attribute.String("valid", "value"),
),
want: attribute.MapValue(
attribute.String("", "empty"),
attribute.String("valid", "value"),
),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := Value(test.value)
if diff := cmp.Diff(test.want, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestValueNoopAllocationFree(t *testing.T) {
value := attribute.MapValue(
attribute.String("one", "1"),
attribute.String("two", "2"),
)
var got attribute.Value
allocs := testing.AllocsPerRun(1000, func() {
got = Value(value)
})
if allocs != 0 {
t.Fatalf("Value() allocations = %v, want 0", allocs)
}
if diff := cmp.Diff(value, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
}
func TestValueStorageShapes(t *testing.T) {
for n := 0; n <= 6; n++ {
t.Run("map", func(t *testing.T) {
kvs := make([]attribute.KeyValue, n)
for i := range kvs {
kvs[i] = attribute.Int(string(rune('a'+i)), i)
}
value := attribute.MapValue(kvs...)
got := Value(value)
if diff := cmp.Diff(value, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
})
t.Run("slice", func(t *testing.T) {
values := make([]attribute.Value, n)
for i := range values {
values[i] = attribute.IntValue(i)
}
value := attribute.SliceValue(values...)
got := Value(value)
if diff := cmp.Diff(value, got, cmpValue); diff != "" {
t.Fatalf("Value() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestKeyValue(t *testing.T) {
kv := attribute.Map(
"map",
attribute.String("nested", "first"),
attribute.String("nested", "second"),
)
want := attribute.Map(
"map",
attribute.String("nested", "second"),
)
got := KeyValue(kv)
if diff := cmp.Diff(want, got, cmpValue); diff != "" {
t.Fatalf("KeyValue() mismatch (-want +got):\n%s", diff)
}
}
func TestKeyValuesNoopReturnsInput(t *testing.T) {
kvs := []attribute.KeyValue{
attribute.String("one", "1"),
attribute.Map("two", attribute.String("nested", "value")),
}
got := KeyValues(kvs)
if len(got) != len(kvs) {
t.Fatalf("KeyValues() length = %d, want %d", len(got), len(kvs))
}
if &got[0] != &kvs[0] {
t.Fatal("KeyValues() copied a no-op input")
}
}
func TestKeyValues(t *testing.T) {
kvs := []attribute.KeyValue{
attribute.String("top", "value"),
attribute.Map(
"map",
attribute.String("nested", "first"),
attribute.String("nested", "second"),
),
attribute.String("tail", "value"),
}
want := []attribute.KeyValue{
attribute.String("top", "value"),
attribute.Map(
"map",
attribute.String("nested", "second"),
),
attribute.String("tail", "value"),
}
got := KeyValues(kvs)
if diff := cmp.Diff(want, got, cmpValue); diff != "" {
t.Fatalf("KeyValues() mismatch (-want +got):\n%s", diff)
}
}
func TestSet(t *testing.T) {
set := attribute.NewSet(
attribute.String("a-top", "value"),
attribute.Map(
"m-map",
attribute.String("nested", "first"),
attribute.String("nested", "second"),
),
attribute.String("z-tail", "value"),
)
want := attribute.NewSet(
attribute.String("a-top", "value"),
attribute.Map(
"m-map",
attribute.String("nested", "second"),
),
attribute.String("z-tail", "value"),
)
got := Set(set)
if diff := cmp.Diff(want.ToSlice(), got.ToSlice(), cmpValue); diff != "" {
t.Fatalf("Set() mismatch (-want +got):\n%s", diff)
}
}
func TestSetNoop(t *testing.T) {
set := attribute.NewSet(
attribute.String("top", "value"),
attribute.Map("map", attribute.String("nested", "value")),
)
got := Set(set)
if !got.Equals(&set) {
t.Fatal("Set() changed a no-op input")
}
}
func TestSetEmpty(t *testing.T) {
set := attribute.Set{}
got := Set(set)
if !got.Equals(&set) {
t.Fatal("Set() changed an empty input")
}
}
func TestInvalidArrayStorage(t *testing.T) {
if got := arrayLen("invalid", valueType); got != 0 {
t.Fatalf("arrayLen() = %d, want 0", got)
}
if got := arrayAt[attribute.Value]("invalid", valueType, 0); got.Type() != attribute.EMPTY {
t.Fatalf("arrayAt() = %v, want empty value", got)
}
if got := arrayAt[attribute.Value](
[1]attribute.Value{attribute.StringValue("value")},
keyValueType,
0,
); got.Type() != attribute.EMPTY {
t.Fatalf("arrayAt() = %v, want empty value", got)
}
if got := arrayAt[attribute.Value](
[1]attribute.Value{attribute.StringValue("value")},
valueType,
-1,
); got.Type() != attribute.EMPTY {
t.Fatalf("arrayAt() = %v, want empty value", got)
}
}
+10
View File
@@ -0,0 +1,10 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
// Package internal provides internal functionality for the sdk/metric package.
package internal // import "go.opentelemetry.io/otel/sdk/metric/internal"
//go:generate gotmpl --body=../../../internal/shared/x/x.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/otel/sdk/metric\" }" --out=x/x.go
//go:generate gotmpl --body=../../../internal/shared/x/x_test.go.tmpl "--data={}" --out=x/x_test.go
//go:generate gotmpl --body=../../../internal/shared/attrdedup/dedup.go.tmpl "--data={}" --out=attrdedup/dedup.go
//go:generate gotmpl --body=../../../internal/shared/attrdedup/dedup_test.go.tmpl "--data={}" --out=attrdedup/dedup_test.go
+32
View File
@@ -89,6 +89,38 @@ func TestMeterInstrumentConcurrentSafe(*testing.T) {
wg.Wait()
}
func TestMapDeduplication(t *testing.T) {
t.Setenv(envVarResourceAttributes, "")
dup := attribute.Map(
"map",
attribute.String("key", "first"),
attribute.String("key", "second"),
)
dedup := attribute.Map("map", attribute.String("key", "second"))
res := resource.NewSchemaless(dup)
reader := NewManualReader()
mp := NewMeterProvider(WithReader(reader), WithResource(res))
meter := mp.Meter("scope", metric.WithInstrumentationAttributes(dup))
counter, err := meter.Int64Counter("counter")
require.NoError(t, err)
counter.Add(t.Context(), 1, metric.WithAttributes(dup))
var rm metricdata.ResourceMetrics
require.NoError(t, reader.Collect(t.Context(), &rm))
assert.Equal(t, []attribute.KeyValue{dedup}, rm.Resource.Attributes())
require.Len(t, rm.ScopeMetrics, 1)
assert.Equal(t, attribute.NewSet(dedup), rm.ScopeMetrics[0].Scope.Attributes)
require.Len(t, rm.ScopeMetrics[0].Metrics, 1)
sum, ok := rm.ScopeMetrics[0].Metrics[0].Data.(metricdata.Sum[int64])
require.True(t, ok)
require.Len(t, sum.DataPoints, 1)
assert.Equal(t, attribute.NewSet(dedup), sum.DataPoints[0].Attributes)
}
var emptyCallback metric.Callback = func(context.Context, metric.Observer) error { return nil }
// A Meter Should be able register Callbacks Concurrently.
+3 -1
View File
@@ -12,6 +12,7 @@ import (
"go.opentelemetry.io/otel/metric/embedded"
"go.opentelemetry.io/otel/metric/noop"
"go.opentelemetry.io/otel/sdk/instrumentation"
"go.opentelemetry.io/otel/sdk/metric/internal/attrdedup"
)
// MeterProvider handles the creation and coordination of Meters. All Meters
@@ -76,11 +77,12 @@ func (mp *MeterProvider) Meter(name string, options ...metric.MeterOption) metri
}
c := metric.NewMeterConfig(options...)
attrs := attrdedup.Set(c.InstrumentationAttributes())
s := instrumentation.Scope{
Name: name,
Version: c.InstrumentationVersion(),
SchemaURL: c.SchemaURL(),
Attributes: c.InstrumentationAttributes(),
Attributes: attrs,
}
global.Info(
+3 -1
View File
@@ -23,7 +23,9 @@ type Option interface {
apply(config) config
}
// WithAttributes adds attributes to the configured Resource.
// WithAttributes adds attributes to the configured Resource. Duplicate
// top-level attribute keys and duplicate keys inside map values are
// resolved using last-value-wins semantics.
func WithAttributes(attributes ...attribute.KeyValue) Option {
return WithDetectors(detectAttributes{attributes})
}
+14 -7
View File
@@ -11,6 +11,7 @@ import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/internal/attrdedup"
"go.opentelemetry.io/otel/sdk/internal/x"
)
@@ -47,6 +48,8 @@ var (
var ErrSchemaURLConflict = errors.New("conflicting Schema URL")
// New returns a [Resource] built using opts.
// Duplicate top-level attribute keys and duplicate keys inside map
// values are resolved using last-value-wins semantics.
//
// This may return a partial Resource along with an error containing
// [ErrPartialResource] if options that provide a [Detector] are used and that
@@ -66,25 +69,29 @@ func New(ctx context.Context, opts ...Option) (*Resource, error) {
return r, detect(ctx, r, cfg.detectors)
}
// NewWithAttributes creates a resource from attrs and associates the resource with a
// schema URL. If attrs contains duplicate keys, the last value will be used. If attrs
// contains any invalid items those items will be dropped. The attrs are assumed to be
// in a schema identified by schemaURL.
// NewWithAttributes creates a resource from attrs and associates the resource
// with a schema URL. If attrs contains duplicate top-level attribute keys or
// duplicate keys inside map values, the last value will be used. If attrs
// contains any invalid items those items will be dropped. The attrs are assumed
// to be in a schema identified by schemaURL.
func NewWithAttributes(schemaURL string, attrs ...attribute.KeyValue) *Resource {
resource := NewSchemaless(attrs...)
resource.schemaURL = schemaURL
return resource
}
// NewSchemaless creates a resource from attrs. If attrs contains duplicate keys,
// the last value will be used. If attrs contains any invalid items those items will
// be dropped. The resource will not be associated with a schema URL. If the schema
// NewSchemaless creates a resource from attrs. If attrs contains duplicate
// top-level attribute keys or duplicate keys inside map values, the last
// value will be used. If attrs contains any invalid items those items will be
// dropped. The resource will not be associated with a schema URL. If the schema
// of the attrs is known use NewWithAttributes instead.
func NewSchemaless(attrs ...attribute.KeyValue) *Resource {
if len(attrs) == 0 {
return &Resource{}
}
attrs = attrdedup.KeyValues(attrs)
// Ensure attributes comply with the specification:
// https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/common/README.md#attribute
s, _ := attribute.NewSetWithFiltered(attrs, func(kv attribute.KeyValue) bool {
+40
View File
@@ -70,6 +70,46 @@ func TestNewWithAttributes(t *testing.T) {
}
}
func TestMapDeduplication(t *testing.T) {
attr := attribute.Map(
"map",
attribute.String("key", "first"),
attribute.String("key", "second"),
)
want := attribute.Map("map", attribute.String("key", "second"))
t.Run("NewSchemaless", func(t *testing.T) {
res := resource.NewSchemaless(attr)
assert.Equal(t, []attribute.KeyValue{want}, res.Attributes())
})
t.Run("NewWithAttributes", func(t *testing.T) {
res := resource.NewWithAttributes(v121, attr)
assert.Equal(t, []attribute.KeyValue{want}, res.Attributes())
})
t.Run("NewWithAttributesOption", func(t *testing.T) {
res, err := resource.New(t.Context(), resource.WithAttributes(attr))
require.NoError(t, err)
assert.Equal(t, []attribute.KeyValue{want}, res.Attributes())
})
t.Run("NewWithDetector", func(t *testing.T) {
detectorResource := resource.NewSchemaless(attr)
res, err := resource.New(t.Context(), resource.WithDetectors(staticDetector{res: detectorResource}))
require.NoError(t, err)
assert.Equal(t, []attribute.KeyValue{want}, res.Attributes())
})
}
type staticDetector struct {
res *resource.Resource
}
func (d staticDetector) Detect(context.Context) (*resource.Resource, error) {
return d.res, nil
}
func TestMerge(t *testing.T) {
cases := []struct {
name string
+3 -1
View File
@@ -13,6 +13,7 @@ import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/internal/global"
"go.opentelemetry.io/otel/sdk/instrumentation"
"go.opentelemetry.io/otel/sdk/internal/attrdedup"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace/internal/observ"
"go.opentelemetry.io/otel/trace"
@@ -142,6 +143,7 @@ func (p *TracerProvider) Tracer(name string, opts ...trace.TracerOption) trace.T
return noop.NewTracerProvider().Tracer(name, opts...)
}
c := trace.NewTracerConfig(opts...)
attrs := attrdedup.Set(c.InstrumentationAttributes())
if name == "" {
name = defaultTracerName
}
@@ -149,7 +151,7 @@ func (p *TracerProvider) Tracer(name string, opts ...trace.TracerOption) trace.T
Name: name,
Version: c.InstrumentationVersion(),
SchemaURL: c.SchemaURL(),
Attributes: c.InstrumentationAttributes(),
Attributes: attrs,
}
t, ok := func() (trace.Tracer, bool) {
+17 -2
View File
@@ -19,6 +19,7 @@ import (
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/internal/global"
"go.opentelemetry.io/otel/sdk/instrumentation"
"go.opentelemetry.io/otel/sdk/internal/attrdedup"
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
"go.opentelemetry.io/otel/trace"
@@ -269,6 +270,7 @@ func (s *recordingSpan) SetAttributes(attributes ...attribute.KeyValue) {
s.addDroppedAttr(1)
continue
}
a = dedupAttr(a)
a = truncateAttr(s.tracer.provider.spanLimits.AttributeValueLengthLimit, a)
s.attributes = append(s.attributes, a)
}
@@ -329,6 +331,7 @@ func (s *recordingSpan) addOverCapAttrs(limit int, attrs []attribute.KeyValue) {
if idx, ok := exists[a.Key]; ok {
// Perform all updates before dropping, even when at capacity.
a = dedupAttr(a)
a = truncateAttr(s.tracer.provider.spanLimits.AttributeValueLengthLimit, a)
s.attributes[idx] = a
continue
@@ -339,6 +342,7 @@ func (s *recordingSpan) addOverCapAttrs(limit int, attrs []attribute.KeyValue) {
// updates are checked and performed.
s.addDroppedAttr(1)
} else {
a = dedupAttr(a)
a = truncateAttr(s.tracer.provider.spanLimits.AttributeValueLengthLimit, a)
s.attributes = append(s.attributes, a)
exists[a.Key] = len(s.attributes) - 1
@@ -346,6 +350,15 @@ func (s *recordingSpan) addOverCapAttrs(limit int, attrs []attribute.KeyValue) {
}
}
func dedupAttr(attr attribute.KeyValue) attribute.KeyValue {
switch attr.Value.Type() {
case attribute.SLICE, attribute.MAP:
return attrdedup.KeyValue(attr)
default:
return attr
}
}
// truncateAttr returns a truncated version of attr. Only string, string
// slice, byte slice, slice, and map attribute values are truncated. String
// values are truncated to at most a length of limit. Each string slice value
@@ -717,7 +730,8 @@ func (s *recordingSpan) AddEvent(name string, o ...trace.EventOption) {
// This method assumes s.mu.Lock is held by the caller.
func (s *recordingSpan) addEvent(name string, o ...trace.EventOption) {
c := trace.NewEventConfig(o...)
e := Event{Name: name, Attributes: c.Attributes(), Time: c.Timestamp()}
attrs := attrdedup.KeyValues(c.Attributes())
e := Event{Name: name, Attributes: attrs, Time: c.Timestamp()}
// Discard attributes over limit.
limit := s.tracer.provider.spanLimits.AttributePerEventCountLimit
@@ -890,7 +904,8 @@ func (s *recordingSpan) AddLink(link trace.Link) {
return
}
l := Link{SpanContext: link.SpanContext, Attributes: link.Attributes}
attrs := attrdedup.KeyValues(link.Attributes)
l := Link{SpanContext: link.SpanContext, Attributes: attrs}
// Discard attributes over limit.
limit := s.tracer.provider.spanLimits.AttributePerLinkCountLimit
+49
View File
@@ -1513,6 +1513,55 @@ func TestWithResource(t *testing.T) {
}
}
func TestMapDeduplication(t *testing.T) {
dup := attribute.Map(
"map",
attribute.String("key", "first"),
attribute.String("key", "second"),
)
dedup := attribute.Map("map", attribute.String("key", "second"))
res := resource.NewSchemaless(dup)
linkSC := trace.NewSpanContext(trace.SpanContextConfig{
TraceID: trace.TraceID{1},
SpanID: trace.SpanID{1},
})
te := NewTestExporter()
tp := NewTracerProvider(
WithSyncer(te),
WithSampler(AlwaysSample()),
WithResource(res),
)
_, span := tp.Tracer(
"scope",
trace.WithInstrumentationAttributes(dup),
).Start(
t.Context(),
"span0",
trace.WithAttributes(dup),
)
span.AddEvent("event", trace.WithAttributes(dup))
span.AddLink(trace.Link{
SpanContext: linkSC,
Attributes: []attribute.KeyValue{dup},
})
got, err := endSpan(te, span)
require.NoError(t, err)
assert.Equal(t, []attribute.KeyValue{dedup}, got.Attributes())
require.Len(t, got.Events(), 1)
assert.Equal(t, []attribute.KeyValue{dedup}, got.Events()[0].Attributes)
assert.Zero(t, got.Events()[0].DroppedAttributeCount)
require.Len(t, got.Links(), 1)
assert.Equal(t, []attribute.KeyValue{dedup}, got.Links()[0].Attributes)
assert.Zero(t, got.Links()[0].DroppedAttributeCount)
assert.Equal(t, []attribute.KeyValue{dedup}, got.Resource().Attributes())
assert.Equal(t, attribute.NewSet(dedup), got.InstrumentationScope().Attributes)
}
func TestWithInstrumentationVersionAndSchema(t *testing.T) {
te := NewTestExporter()
tp := NewTracerProvider(WithSyncer(te), WithResource(resource.Empty()))