diff --git a/controller/src/config/stage_config.rs b/controller/src/config/stage_config.rs index 3028cae6..5fde9e35 100644 --- a/controller/src/config/stage_config.rs +++ b/controller/src/config/stage_config.rs @@ -439,7 +439,7 @@ pub fn emit_gateway_yaml(cfg: &GatewayStageConfig, opamp_endpoint: &str) -> Resu // * SketchKind::DDSketch → `ddsketchmerge` // * SketchKind::Kll → `kllmerge` // * SketchKind::Hll → `hllmerge` - // * SketchKind::Cms → `countminmerge` + // * SketchKind::Cms → `countminsketchmerge` // * SketchKind::CountSketch → `countsketchmerge` // // We honour `GatewayMergeProcessor::processor_name` if non-empty @@ -1320,7 +1320,7 @@ fn build_edge_processor_block( /// Today the typed emitter populates every entry's `processor_name` /// with the placeholder `"sketchmergeprocessor"`; the patched contrib /// build instead has per-family merge processors: -/// `kllmerge`, `ddsketchmerge`, `hllmerge`, `countminmerge`, +/// `kllmerge`, `ddsketchmerge`, `hllmerge`, `countminsketchmerge`, /// `countsketchmerge`. We map the kind to the family-specific name /// here so the emitted YAML round-trips through the patched build. fn gateway_merge_processor_name(mp: &GatewayMergeProcessor) -> String { @@ -1328,7 +1328,7 @@ fn gateway_merge_processor_name(mp: &GatewayMergeProcessor) -> String { SketchKind::Kll => "kllmerge".to_string(), SketchKind::DDSketch => "ddsketchmerge".to_string(), SketchKind::Hll => "hllmerge".to_string(), - SketchKind::Cms => "countminmerge".to_string(), + SketchKind::Cms => "countminsketchmerge".to_string(), SketchKind::CountSketch => "countsketchmerge".to_string(), } } diff --git a/deploy/scripts/run_mvp_demo.sh b/deploy/scripts/run_mvp_demo.sh index 6e73cfa3..41bc0caf 100755 --- a/deploy/scripts/run_mvp_demo.sh +++ b/deploy/scripts/run_mvp_demo.sh @@ -427,10 +427,16 @@ capture_emitted_configs() { fi # Per-metric typed config (one per workload entry — Phase B - # emitter output). The mvp-workload.yaml has four entries. + # emitter output). mvp-workload.yaml has 6 distinct metrics + # across 8 entries (http_requests_total appears in 3 query + # shapes: raw passthrough, gateway sum, cold archive probe). for metric in \ http_requests_total_latency_ms \ - http_requests_total ; do + http_requests_total \ + request_size_bytes \ + unique_users_per_min \ + top_endpoint_qps \ + endpoint_request_freq ; do local out="${cdir}/per-metric.${metric}.json" if curl -sf "${ctrl}/api/v1/config/${metric}" -o "${out}" \ 2> "${out}.err"; then @@ -498,18 +504,20 @@ measure_phase() { # Build the replay query suite from mvp-workload.yaml. # We keep the JSON adjacent to the run dir for reproducibility. - # Six query classes — one per sketch family registered in - # mvp-workload.yaml (issue #46 5-sketch coverage): + # Seven query classes — one per sketch family + the label-agg + # family appears in two shapes (issue #46 5-sketch + 3 canonical + # query classes): # - # sum_rate ↔ raw passthrough (http_requests_total) - # quantile ↔ DDSketch (http_requests_total_latency_ms) - # kll-quantile ↔ KLL (request_size_bytes) - # count_unique ↔ HLL (unique_users_per_min) - # topk ↔ CountSketch (top_endpoint_qps) - # frequency ↔ CountMinSketch (endpoint_request_freq) + # quantile ↔ DDSketch (http_requests_total_latency_ms) + # sum-by-zone ↔ raw passthrough (http_requests_total) [criterion ① label-at-instant] + # combined-rate ↔ raw passthrough (http_requests_total) [criterion ① combined window+label] + # kll-quantile ↔ KLL (request_size_bytes) + # count_unique ↔ HLL (unique_users_per_min) + # topk ↔ CountSketch (top_endpoint_qps) + # frequency ↔ CountMinSketch (endpoint_request_freq) # - # Replay rotates round-robin at QPS=8 → ~1.3 QPS per class → - # ≥390 samples per class over the 300s soak (≥100 floor for + # Replay rotates round-robin at QPS=8 → ~1.14 QPS per class → + # ≥340 samples per class over the 300s soak (≥100 floor for # accuracy reduction). # Replay-range / warm-precompute alignment (issue #46 ε-bound # bug, fix/quantile-window-alignment): the DDSketch (entry 1) and diff --git a/opentelemetry-collector-contrib-patch/cmd/asap-otel/builder-config.yaml b/opentelemetry-collector-contrib-patch/cmd/asap-otel/builder-config.yaml index 4fe6ef94..781ee52b 100644 --- a/opentelemetry-collector-contrib-patch/cmd/asap-otel/builder-config.yaml +++ b/opentelemetry-collector-contrib-patch/cmd/asap-otel/builder-config.yaml @@ -164,6 +164,12 @@ processors: path: ./processor/countminsketchmergeprocessor - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/countsketchmergeprocessor v0.141.0 path: ./processor/countsketchmergeprocessor + - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/kllmergeprocessor v0.141.0 + path: ./processor/kllmergeprocessor + - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/ddsketchmergeprocessor v0.141.0 + path: ./processor/ddsketchmergeprocessor + - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/hllmergeprocessor v0.141.0 + path: ./processor/hllmergeprocessor # Paper baseline B1: Serf (ASAP XOR-with-quantization). - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/serfprocessor v0.141.0 path: ./processor/serfprocessor diff --git a/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/config.go b/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/config.go new file mode 100644 index 00000000..065e152c --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/config.go @@ -0,0 +1,28 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package ddsketchmergeprocessor + +import ( + "go.opentelemetry.io/collector/component" +) + +// Config configures the DDSketch merge processor. +// This processor runs on the gateway side and accumulates per-series +// DDSketch state from `pmetric.MetricTypeDDSketch` data points produced +// by the agent-tier `ddsketchprocessor`. The accumulator is keyed by +// the data point's attribute set; downstream consumers read merged +// state via `GetAccumulator(key)`. +type Config struct { + // MetricName is the metric name to watch for DDSketch payloads. + // Defaults to "ddsketch" if empty. Producers (ddsketchprocessor) + // emit names like "_ddsketch" by default; pipelines should + // override MetricName to match the chosen agent-side suffix. + MetricName string `mapstructure:"metric_name"` +} + +var _ component.Config = (*Config)(nil) + +func (c *Config) Validate() error { + return nil +} diff --git a/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/factory.go b/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/factory.go new file mode 100644 index 00000000..d6d5f14f --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/factory.go @@ -0,0 +1,48 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package ddsketchmergeprocessor + +import ( + "context" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/processor" + "go.opentelemetry.io/collector/processor/processorhelper" + "go.uber.org/zap" +) + +var typeStr = component.MustNewType("ddsketchmerge") + +func NewFactory() processor.Factory { + return processor.NewFactory( + typeStr, + createDefaultConfig, + processor.WithMetrics(createMetricsProcessor, component.StabilityLevelDevelopment), + ) +} + +func createDefaultConfig() component.Config { + return &Config{} +} + +func createMetricsProcessor( + ctx context.Context, + set processor.Settings, + cfg component.Config, + next consumer.Metrics, +) (processor.Metrics, error) { + c := cfg.(*Config) + logger := set.Logger + if logger == nil { + logger = zap.NewNop() + } + p := newProcessor(c, logger, next) + return processorhelper.NewMetrics(ctx, set, cfg, next, + p.processMetrics, + processorhelper.WithStart(p.Start), + processorhelper.WithShutdown(p.Shutdown), + processorhelper.WithCapabilities(p.Capabilities()), + ) +} diff --git a/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/go.mod b/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/go.mod new file mode 100644 index 00000000..b06afe55 --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/go.mod @@ -0,0 +1,41 @@ +module github.com/open-telemetry/opentelemetry-collector-contrib/processor/ddsketchmergeprocessor + +go 1.24.0 + +require ( + github.com/ProjectASAP/sketchlib-go v0.0.0-20260328221809-b24e56e64e94 + go.opentelemetry.io/collector/component v1.47.0 + go.opentelemetry.io/collector/consumer v1.47.0 + go.opentelemetry.io/collector/pdata v1.47.0 + go.opentelemetry.io/collector/processor v1.47.0 + go.opentelemetry.io/collector/processor/processorhelper v0.141.0 + go.uber.org/zap v1.27.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/prometheus v0.307.1 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.opentelemetry.io/collector/featuregate v1.47.0 // indirect + go.opentelemetry.io/collector/pipeline v1.47.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + +replace go.opentelemetry.io/collector/pdata => ../../../opentelemetry-collector/pdata + +replace github.com/ProjectASAP/sketchlib-go => ../../../../sketchlib-go diff --git a/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/go.sum b/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/go.sum new file mode 100644 index 00000000..765fac61 --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/go.sum @@ -0,0 +1,123 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/prometheus v0.307.1 h1:Hh3kRMFn+xpQGLe/bR6qpUfW4GXQO0spuYeY7f2JZs4= +github.com/prometheus/prometheus v0.307.1/go.mod h1:/7YQG/jOLg7ktxGritmdkZvezE1fa6aWDj0MGDIZvcY= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/collector/component v1.47.0 h1:wXvcjNhpWUU4OJph7KyxENkbfnGrfDURa+L/rvPTHyo= +go.opentelemetry.io/collector/component v1.47.0/go.mod h1:Hz9fcIbc7tOA4hIjvW5bb1rJJc2TH0gtQEvDBaZLUUA= +go.opentelemetry.io/collector/component/componentstatus v0.141.0 h1:WoMJdv2ofwHJDXzMP6DvYPqREaqOcGw+gkXG7S+PJvc= +go.opentelemetry.io/collector/component/componentstatus v0.141.0/go.mod h1:upr5QxmYLEZ7PKMCZHImQcp3xNM4VXtZnAKuhhHopg4= +go.opentelemetry.io/collector/component/componenttest v0.141.0 h1:dYdFbm52+e2DwrJ0bEoo7qVOPDuFXl9E/FfaqViIfPU= +go.opentelemetry.io/collector/component/componenttest v0.141.0/go.mod h1:EI7SUBy8Grxso69j2KYf3BYv8rkJjFgxlmWf5ElcWdk= +go.opentelemetry.io/collector/consumer v1.47.0 h1:eriMvNAsityaea361luVfNe8wp6QKWJQoU4d4i3tyOA= +go.opentelemetry.io/collector/consumer v1.47.0/go.mod h1:wBsF8koieun0CK4laZLN2MvGKNqad8gwQa+1jXWWn5k= +go.opentelemetry.io/collector/consumer/consumertest v0.141.0 h1:Q5X7rOI8I5xj35Q1NQiwGJsJ4OZx1n7szw3MbOfNgiM= +go.opentelemetry.io/collector/consumer/consumertest v0.141.0/go.mod h1:yjSSOFx0oBjH2fouw0TTN/U82hYyJPq35ClIZrpz60g= +go.opentelemetry.io/collector/consumer/xconsumer v0.141.0 h1:qR9H8tWo6NtPBDBv3fz8J8QBkqbnaU8vwUvtIO3QeZo= +go.opentelemetry.io/collector/consumer/xconsumer v0.141.0/go.mod h1:Ud55EhQ0cgqDTtnvHQNjtktLGMeefOzF6SFk0bLheOc= +go.opentelemetry.io/collector/featuregate v1.47.0 h1:LuJnDngViDzPKds5QOGxVYNL1QCCVWN/m61lHTV8Pf4= +go.opentelemetry.io/collector/featuregate v1.47.0/go.mod h1:d0tiRzVYrytB6LkcYgz2ESFTv7OktRPQe0QEQcPt1L4= +go.opentelemetry.io/collector/internal/testutil v0.141.0 h1:/rUGApojPtUPMN3rFfApNgEjAt03rCGt2qxNxGGs/4A= +go.opentelemetry.io/collector/internal/testutil v0.141.0/go.mod h1:YAD9EAkwh/l5asZNbEBEUCqEjoL1OKMjAMoPjPqH76c= +go.opentelemetry.io/collector/pdata/pprofile v0.141.0 h1:15lbbHKzPIG4aVT6hsJO7XZLvMrGll+i36es/FEgn7c= +go.opentelemetry.io/collector/pdata/pprofile v0.141.0/go.mod h1:gUtWKniP3O0jXYVDISp1y3dCbYFIyglFw6B8ATyrrWs= +go.opentelemetry.io/collector/pdata/testdata v0.141.0 h1:AfjNbZ/DUSr0aiP4H+z7pqrzTuBQFaT6oca0zaJ3gCA= +go.opentelemetry.io/collector/pdata/testdata v0.141.0/go.mod h1:/KX316ZF30G4eUQadM+SPUqCCPoiAkhMxcvAu4uM72I= +go.opentelemetry.io/collector/pipeline v1.47.0 h1:Ql2cfIopfo/e0Y6r/Fw3mNorKYi8MAoA7zgouzAN8eI= +go.opentelemetry.io/collector/pipeline v1.47.0/go.mod h1:xUrAqiebzYbrgxyoXSkk6/Y3oi5Sy3im2iCA51LwUAI= +go.opentelemetry.io/collector/processor v1.47.0 h1:WA4AP+w+ohFItWx0eG5iGEvLCE70Le5wC2Uw7YVN1Vg= +go.opentelemetry.io/collector/processor v1.47.0/go.mod h1:XaC3o+kNM5wq7ET+FJt+9hTnqqICmruylBpVerb+TZo= +go.opentelemetry.io/collector/processor/processorhelper v0.141.0 h1:4NCArw4JJsJ8YNtbcJXYNOczQ9gon+m1yGV5VPh8Lwk= +go.opentelemetry.io/collector/processor/processorhelper v0.141.0/go.mod h1:idjJbBjKlBmXnhWwiqKG8AYBJmdowNn82F36OhBcMwg= +go.opentelemetry.io/collector/processor/processortest v0.141.0 h1:HY/o+CkKTU2Db96TfugwfMKkRFcaJb2vxPUHluS5/F8= +go.opentelemetry.io/collector/processor/processortest v0.141.0/go.mod h1:n0QKOTH2m2vVbDGdIHvDmIEHU02LOQtuCzzN4BJgK5U= +go.opentelemetry.io/collector/processor/xprocessor v0.141.0 h1:rlvqx4aW7dgrYqWrNTkq1+IDiWOKdX/DDZPxk1DQMVw= +go.opentelemetry.io/collector/processor/xprocessor v0.141.0/go.mod h1:jSSsP1pFgkxN4MvVsyZA1MI5DKhN+kg9Y27Ev0lEwqs= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/slim/otlp v1.9.0 h1:fPVMv8tP3TrsqlkH1HWYUpbCY9cAIemx184VGkS6vlE= +go.opentelemetry.io/proto/slim/otlp v1.9.0/go.mod h1:xXdeJJ90Gqyll+orzUkY4bOd2HECo5JofeoLpymVqdI= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0 h1:o13nadWDNkH/quoDomDUClnQBpdQQ2Qqv0lQBjIXjE8= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0/go.mod h1:Gyb6Xe7FTi/6xBHwMmngGoHqL0w29Y4eW8TGFzpefGA= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0 h1:EiUYvtwu6PMrMHVjcPfnsG3v+ajPkbUeH+IL93+QYyk= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0/go.mod h1:mUUHKFiN2SST3AhJ8XhJxEoeVW12oqfXog0Bo8W3Ec4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 h1:6/3JGEh1C88g7m+qzzTbl3A0FtsLguXieqofVLU/JAo= +golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/processor.go b/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/processor.go new file mode 100644 index 00000000..a9721cdc --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/processor.go @@ -0,0 +1,161 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package ddsketchmergeprocessor + +import ( + "context" + "sort" + "strings" + "sync" + + ddsketch "github.com/ProjectASAP/sketchlib-go/sketches/DDSketch" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/pmetric" + "go.uber.org/zap" +) + +type ddsketchMergeProcessor struct { + cfg *Config + logger *zap.Logger + next consumer.Metrics + + mu sync.Mutex + accumulators map[string]*ddsketch.DDSketch // keyed by attribute-set fingerprint +} + +func newProcessor(cfg *Config, logger *zap.Logger, next consumer.Metrics) *ddsketchMergeProcessor { + return &ddsketchMergeProcessor{ + cfg: cfg, + logger: logger, + next: next, + accumulators: make(map[string]*ddsketch.DDSketch), + } +} + +func (p *ddsketchMergeProcessor) Start(_ context.Context, _ component.Host) error { + return nil +} + +func (p *ddsketchMergeProcessor) Shutdown(_ context.Context) error { + return nil +} + +func (p *ddsketchMergeProcessor) Capabilities() consumer.Capabilities { + // Pass-through: we do not mutate md, only update internal accumulators. + return consumer.Capabilities{MutatesData: false} +} + +func (p *ddsketchMergeProcessor) processMetrics(_ context.Context, md pmetric.Metrics) (pmetric.Metrics, error) { + metricName := p.cfg.MetricName + if metricName == "" { + metricName = "ddsketch" + } + + rms := md.ResourceMetrics() + for i := 0; i < rms.Len(); i++ { + sms := rms.At(i).ScopeMetrics() + for j := 0; j < sms.Len(); j++ { + metrics := sms.At(j).Metrics() + for k := 0; k < metrics.Len(); k++ { + m := metrics.At(k) + if m.Name() != metricName { + continue + } + if m.Type() != pmetric.MetricTypeDDSketch { + continue + } + dps := m.DDSketch().DataPoints() + for l := 0; l < dps.Len(); l++ { + p.mergeDataPoint(dps.At(l)) + } + } + } + } + + return md, nil +} + +func (p *ddsketchMergeProcessor) mergeDataPoint(dp pmetric.DDSketchDataPoint) { + payload := dp.Sketch() + if len(payload) == 0 { + return + } + key := attrSetKey(dp.Attributes()) + + p.mu.Lock() + defer p.mu.Unlock() + + switch dp.Encoding() { + case pmetric.DDSketchEncodingProto, pmetric.DDSketchEncodingUnspecified: + // Full snapshot via sketchlib-go's portable proto-state encoding. + sketch, err := ddsketch.NewFromStateProtoBytes(payload) + if err != nil { + p.logger.Error("ddsketchmergeprocessor: failed to deserialize DDSketch state", zap.Error(err)) + return + } + p.accumulators[key] = sketch + case pmetric.DDSketchEncodingProtoDelta: + acc, exists := p.accumulators[key] + if !exists { + p.logger.Warn("ddsketchmergeprocessor: received delta for unknown key; dropping", + zap.String("key", key)) + return + } + if err := ddsketch.ApplyDelta(acc, payload); err != nil { + p.logger.Error("ddsketchmergeprocessor: ApplyDelta failed", zap.Error(err)) + return + } + case pmetric.DDSketchEncodingMsgpack: + sketch, err := ddsketch.DeserializeMsgpack(payload) + if err != nil { + p.logger.Error("ddsketchmergeprocessor: DeserializeMsgpack failed", zap.Error(err)) + return + } + p.accumulators[key] = sketch + default: + // MsgpackDelta is reserved-but-unsupported (see pdata + // ddsketch_encoding.go). Anything else is unknown: warn and drop. + p.logger.Warn("ddsketchmergeprocessor: unsupported DDSketch encoding; dropping data point", + zap.String("encoding", dp.Encoding().String()), + zap.String("key", key)) + } +} + +// GetAccumulator returns the current accumulated sketch for the given +// attribute-set key. +func (p *ddsketchMergeProcessor) GetAccumulator(key string) (*ddsketch.DDSketch, bool) { + p.mu.Lock() + defer p.mu.Unlock() + acc, ok := p.accumulators[key] + return acc, ok +} + +// attrSetKey produces a stable string key for a pdata attribute map by +// sorting attribute names and joining `name=value` pairs. +func attrSetKey(attrs pcommon.Map) string { + if attrs.Len() == 0 { + return "" + } + keys := make([]string, 0, attrs.Len()) + values := make(map[string]string, attrs.Len()) + attrs.Range(func(k string, v pcommon.Value) bool { + keys = append(keys, k) + values[k] = v.AsString() + return true + }) + sort.Strings(keys) + var b strings.Builder + for i, k := range keys { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(k) + b.WriteByte('=') + b.WriteString(values[k]) + } + return b.String() +} diff --git a/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/processor_test.go b/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/processor_test.go new file mode 100644 index 00000000..c8070f0f --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/ddsketchmergeprocessor/processor_test.go @@ -0,0 +1,150 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package ddsketchmergeprocessor + +import ( + "context" + "testing" + + ddsketch "github.com/ProjectASAP/sketchlib-go/sketches/DDSketch" + + "go.opentelemetry.io/collector/pdata/pmetric" + "go.uber.org/zap" +) + +const testAlpha = 0.01 + +func buildDDSketch(t *testing.T, values []float64) *ddsketch.DDSketch { + t.Helper() + s := ddsketch.New(testAlpha) + for _, v := range values { + s.Update(v) + } + return s +} + +func protoStateBytes(t *testing.T, s *ddsketch.DDSketch) []byte { + t.Helper() + b, err := s.SerializeStateProtoBytes() + if err != nil { + t.Fatalf("SerializeStateProtoBytes: %v", err) + } + return b +} + +func newDDSketchMetricsBatch(metricName, attrKey, attrVal string, payload []byte, encoding pmetric.DDSketchEncoding) pmetric.Metrics { + md := pmetric.NewMetrics() + rm := md.ResourceMetrics().AppendEmpty() + sm := rm.ScopeMetrics().AppendEmpty() + m := sm.Metrics().AppendEmpty() + m.SetName(metricName) + dm := m.SetEmptyDDSketch() + dp := dm.DataPoints().AppendEmpty() + dp.Attributes().PutStr(attrKey, attrVal) + dp.SetSketch(payload) + dp.SetEncoding(encoding) + return md +} + +func TestDDSketchMergeProcessor_ProtoIngest(t *testing.T) { + cfg := &Config{} + p := newProcessor(cfg, zap.NewNop(), nil) + + src := buildDDSketch(t, []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}) + payload := protoStateBytes(t, src) + + md := newDDSketchMetricsBatch("ddsketch", "service", "checkout", payload, pmetric.DDSketchEncodingProto) + out, err := p.processMetrics(context.Background(), md) + if err != nil { + t.Fatalf("processMetrics: %v", err) + } + if got := out.MetricCount(); got != 1 { + t.Fatalf("MetricCount mismatch: got %d, want 1", got) + } + + acc, ok := p.GetAccumulator("service=checkout") + if !ok { + t.Fatal("accumulator missing") + } + if got := acc.Count(); got != 10 { + t.Fatalf("accumulator Count = %d, want 10", got) + } +} + +func TestDDSketchMergeProcessor_DeltaMerge(t *testing.T) { + cfg := &Config{} + p := newProcessor(cfg, zap.NewNop(), nil) + + // Step 1: ingest a snapshot. + snap := buildDDSketch(t, []float64{1, 2, 3, 4, 5}) + if _, err := p.processMetrics(context.Background(), + newDDSketchMetricsBatch("ddsketch", "service", "checkout", + protoStateBytes(t, snap), pmetric.DDSketchEncodingProto)); err != nil { + t.Fatal(err) + } + + // Step 2: build a "current" sketch with extra inserts and compute the delta against snap. + current := buildDDSketch(t, []float64{1, 2, 3, 4, 5, 6, 7, 8}) + deltaBytes, err := ddsketch.ComputeDelta(snap, current, 1) + if err != nil { + t.Fatalf("ComputeDelta: %v", err) + } + + if _, err := p.processMetrics(context.Background(), + newDDSketchMetricsBatch("ddsketch", "service", "checkout", + deltaBytes, pmetric.DDSketchEncodingProtoDelta)); err != nil { + t.Fatal(err) + } + + acc, ok := p.GetAccumulator("service=checkout") + if !ok { + t.Fatal("accumulator missing") + } + if got := acc.Count(); got != 8 { + t.Fatalf("after delta merge Count = %d, want 8", got) + } +} + +func TestDDSketchMergeProcessor_DeltaWithoutSnapshotDropped(t *testing.T) { + cfg := &Config{} + p := newProcessor(cfg, zap.NewNop(), nil) + + snap := buildDDSketch(t, []float64{1, 2, 3}) + current := buildDDSketch(t, []float64{1, 2, 3, 4, 5}) + deltaBytes, err := ddsketch.ComputeDelta(snap, current, 1) + if err != nil { + t.Fatalf("ComputeDelta: %v", err) + } + + // No prior snapshot ingested → delta should be dropped. + if _, err := p.processMetrics(context.Background(), + newDDSketchMetricsBatch("ddsketch", "service", "checkout", + deltaBytes, pmetric.DDSketchEncodingProtoDelta)); err != nil { + t.Fatal(err) + } + if _, ok := p.GetAccumulator("service=checkout"); ok { + t.Fatal("accumulator should not exist after orphan delta") + } +} + +func TestDDSketchMergeProcessor_MetricNameFilter(t *testing.T) { + cfg := &Config{MetricName: "request_latency_ddsketch"} + p := newProcessor(cfg, zap.NewNop(), nil) + + payload := protoStateBytes(t, buildDDSketch(t, []float64{1, 2, 3})) + if _, err := p.processMetrics(context.Background(), + newDDSketchMetricsBatch("ddsketch", "service", "checkout", payload, pmetric.DDSketchEncodingProto)); err != nil { + t.Fatal(err) + } + if _, ok := p.GetAccumulator("service=checkout"); ok { + t.Fatal("accumulator should not exist for unmatched metric name") + } + if _, err := p.processMetrics(context.Background(), + newDDSketchMetricsBatch("request_latency_ddsketch", "service", "checkout", payload, pmetric.DDSketchEncodingProto)); err != nil { + t.Fatal(err) + } + if _, ok := p.GetAccumulator("service=checkout"); !ok { + t.Fatal("accumulator should exist for matched metric name") + } +} diff --git a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/encoder.go b/opentelemetry-collector-contrib-patch/processor/gorillas3processor/encoder.go deleted file mode 100644 index 0749dd8a..00000000 --- a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/encoder.go +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -package gorillas3processor - -import ( - gorilla "github.com/ProjectASAP/asap-gorilla-go" -) - -// chunkMagic is the 8-byte magic that prefixes every encoded chunk. -// // "GORILLA1" matches asap-gorilla Rust crate (#281) + sibling gorillaprocessor for byte-compat -// (Go/Telegraf, Rust asap-gorilla) shared spec. -const chunkMagic = "GORILLA1" - -// chunkVersion is the layout version. Bumped on any wire-incompatible -// header / body change. -const chunkVersion = 1 - -// point is a single timestamp / float64 sample. -type point struct { - ts int64 // UnixNano timestamp - v float64 // double value -} - -// seriesKey identifies a unique time series within a window. -type seriesKey struct { - metricName string - attributesKey string // canonical sorted "k1=v1;k2=v2;" string -} - -// seriesBuffer accumulates points for one series during a window. -type seriesBuffer struct { - attributes map[string]string - points []point -} - -// seriesMeta is the JSON-serialized header that travels with each -// encoded series chunk inside an GORILLA1 block. -type seriesMeta struct { - MetricName string `json:"metric_name"` - Attributes map[string]string `json:"attributes"` - StartTS int64 `json:"start_ts"` - EndTS int64 `json:"end_ts"` - PointCount int `json:"point_count"` -} - -// sortAndEncode adapts the processor-local point shape to asap-gorilla-go. -// Returns the first ts/value (raw) plus the bit-packed delta-of-delta and -// XOR streams. -func sortAndEncode(points []point) (firstTS int64, firstValBits uint64, tsBits []byte, tsBitsLen uint32, valBits []byte, valBitsLen uint32) { - if len(points) == 0 { - return 0, 0, nil, 0, nil, 0 - } - adapted := make([]gorilla.Point, len(points)) - for i, p := range points { - adapted[i] = gorilla.Point{TimestampUnixNano: p.ts, Value: p.v} - } - encoded, _ := gorilla.SortAndEncode(adapted) - for i, p := range adapted { - points[i] = point{ts: p.TimestampUnixNano, v: p.Value} - } - return encoded.FirstTimestampUnixNano, - encoded.FirstValueBits, - encoded.TimestampBits, - encoded.TimestampBitLen, - encoded.ValueBits, - encoded.ValueBitLen -} - -// chunkInfo captures metadata about one written chunk for indexing. -type chunkInfo struct { - metricName string - startTS int64 // UnixNano of earliest point - endTS int64 // UnixNano of latest point - seriesCount int - pointCount int - rawBytes int64 - data []byte -} - -// encodeSeries encodes one series buffer into the series-body bytes -// (everything after the chunk's outer GORILLA1 header). Body layout: -// -// uint16 LE metaLen -// [metaLen] meta JSON -// uint32 LE pointCount -// uint64 LE firstTS -// uint64 LE firstValBits -// uint32 LE tsBitsLen -// [ceil(tsBitsLen/8)] tsBits -// uint32 LE valBitsLen -// [ceil(valBitsLen/8)] valBits -func encodeSeriesBody(key seriesKey, buf *seriesBuffer) ([]byte, int64, int64, error) { - series := gorilla.Series{ - MetricName: key.metricName, - Attributes: buf.attributes, - AttributesKey: key.attributesKey, - Points: make([]gorilla.Point, len(buf.points)), - } - for i, p := range buf.points { - series.Points[i] = gorilla.Point{TimestampUnixNano: p.ts, Value: p.v} - } - return gorilla.EncodeSeriesBody(series) -} - -// buildChunks groups series-bodies by metric name, packs them into -// GORILLA1 blocks (one per metric, optionally split by maxObjectBytes). -// -// Outer GORILLA1 block layout: -// -// [8] magic "GORILLA1" -// [1] version chunkVersion -// [4] uint32 LE seriesCount -// -// followed by seriesCount series bodies (encodeSeriesBody output). -func buildChunks(series map[seriesKey]*seriesBuffer, maxObjectBytes int64) ([]chunkInfo, error) { - input := make([]gorilla.Series, 0, len(series)) - for key, buf := range series { - if buf == nil || len(buf.points) == 0 { - continue - } - points := make([]gorilla.Point, len(buf.points)) - for i, p := range buf.points { - points[i] = gorilla.Point{TimestampUnixNano: p.ts, Value: p.v} - } - input = append(input, gorilla.Series{ - MetricName: key.metricName, - Attributes: buf.attributes, - AttributesKey: key.attributesKey, - Points: points, - }) - } - chunks, err := gorilla.BuildMetricChunks(input, maxObjectBytes) - if err != nil { - return nil, err - } - out := make([]chunkInfo, len(chunks)) - for i, c := range chunks { - out[i] = chunkInfo{ - metricName: c.MetricName, - startTS: c.StartTSNano, - endTS: c.EndTSNano, - seriesCount: c.SeriesCount, - pointCount: c.PointCount, - rawBytes: c.RawBytes, - data: c.Data, - } - } - return out, nil -} - -// PointCountSafe returns the number of points in the buffer, guarding -// against nil buffers. -func (b *seriesBuffer) PointCountSafe() int { - if b == nil { - return 0 - } - return len(b.points) -} diff --git a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/encoder_test.go b/opentelemetry-collector-contrib-patch/processor/gorillas3processor/encoder_test.go deleted file mode 100644 index 555d70c3..00000000 --- a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/encoder_test.go +++ /dev/null @@ -1,411 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -package gorillas3processor - -import ( - "encoding/binary" - "encoding/json" - "math" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// bitReader is a test-only decoder that mirrors bitWriter. -type bitReader struct { - b []byte - bitlen uint32 - off uint32 -} - -func newBitReader(b []byte, bitlen uint32) *bitReader { - return &bitReader{b: b, bitlen: bitlen} -} - -func (r *bitReader) readBit() (uint8, bool) { - if r.off >= r.bitlen { - return 0, false - } - by := r.off / 8 - bi := r.off % 8 - bit := (r.b[by] >> (7 - bi)) & 1 - r.off++ - return bit, true -} - -func (r *bitReader) readBits(n uint8) (uint64, bool) { - if n == 0 { - return 0, true - } - var v uint64 - for i := uint8(0); i < n; i++ { - bit, ok := r.readBit() - if !ok { - return 0, false - } - v = (v << 1) | uint64(bit) - } - return v, true -} - -func signExtend(v uint64, n uint8) int64 { - if n == 0 || n >= 64 { - return int64(v) - } - shift := 64 - n - return int64(v<> shift -} - -func decodeTimestamps(firstTS int64, count int, bits []byte, bitlen uint32) ([]int64, bool) { - out := make([]int64, count) - out[0] = firstTS - prevTS := firstTS - prevDelta := int64(0) - r := newBitReader(bits, bitlen) - for i := 1; i < count; i++ { - b, ok := r.readBit() - if !ok { - return nil, false - } - var dd int64 - if b == 0 { - dd = 0 - } else { - b2, ok := r.readBit() - if !ok { - return nil, false - } - if b2 == 0 { - v, ok := r.readBits(7) - if !ok { - return nil, false - } - dd = signExtend(v, 7) - } else { - b3, ok := r.readBit() - if !ok { - return nil, false - } - if b3 == 0 { - v, ok := r.readBits(9) - if !ok { - return nil, false - } - dd = signExtend(v, 9) - } else { - b4, ok := r.readBit() - if !ok { - return nil, false - } - if b4 == 0 { - v, ok := r.readBits(12) - if !ok { - return nil, false - } - dd = signExtend(v, 12) - } else { - v, ok := r.readBits(64) - if !ok { - return nil, false - } - dd = int64(v) - } - } - } - } - delta := prevDelta + dd - ts := prevTS + delta - out[i] = ts - prevTS = ts - prevDelta = delta - } - return out, true -} - -func decodeValues(firstValBits uint64, count int, bits []byte, bitlen uint32) ([]float64, bool) { - out := make([]float64, count) - out[0] = math.Float64frombits(firstValBits) - prev := firstValBits - var lz, tz uint8 - haveWindow := false - r := newBitReader(bits, bitlen) - for i := 1; i < count; i++ { - c, ok := r.readBit() - if !ok { - return nil, false - } - var vb uint64 - if c == 0 { - vb = prev - } else { - c2, ok := r.readBit() - if !ok { - return nil, false - } - if c2 == 0 { - if !haveWindow { - return nil, false - } - sigLen := 64 - lz - tz - sig, ok := r.readBits(sigLen) - if !ok { - return nil, false - } - x := sig << tz - vb = prev ^ x - } else { - lz5, ok := r.readBits(5) - if !ok { - return nil, false - } - lz = uint8(lz5) - sigm1, ok := r.readBits(6) - if !ok { - return nil, false - } - sigLen := uint8(sigm1) + 1 - sig, ok := r.readBits(sigLen) - if !ok { - return nil, false - } - if sigLen == 64 { - tz = 0 - } else { - tz = 64 - lz - sigLen - } - x := sig << tz - vb = prev ^ x - haveWindow = true - } - } - out[i] = math.Float64frombits(vb) - prev = vb - } - return out, true -} - -// decodeChunk parses an GORILLA1 block and returns the decoded series. -type decodedSeries struct { - meta seriesMeta - tss []int64 - vals []float64 -} - -func decodeChunk(t *testing.T, data []byte) []decodedSeries { - t.Helper() - // Outer GORILLA1 block layout (must match asap-gorilla::block::HEADER_LEN = 13): - // [8] magic "GORILLA1" - // [1] version chunkVersion - // [4] uint32 LE seriesCount - // Pre-v7 this helper read the magic as 4 bytes and the seriesCount - // at offset [5:9], which mismatched both the writer and the - // asap-gorilla decoder. Aligned now so a regression in either - // direction (writer offset OR helper offset) fails this test. - require.True(t, len(data) >= 13, "chunk too small for header") - require.Equal(t, chunkMagic, string(data[:8])) - require.Equal(t, byte(chunkVersion), data[8]) - seriesCount := binary.LittleEndian.Uint32(data[9:13]) - off := 13 - out := make([]decodedSeries, 0, seriesCount) - for i := uint32(0); i < seriesCount; i++ { - metaLen := binary.LittleEndian.Uint16(data[off : off+2]) - off += 2 - var meta seriesMeta - require.NoError(t, json.Unmarshal(data[off:off+int(metaLen)], &meta)) - off += int(metaLen) - pointCount := binary.LittleEndian.Uint32(data[off : off+4]) - off += 4 - firstTS := int64(binary.LittleEndian.Uint64(data[off : off+8])) - off += 8 - firstVal := binary.LittleEndian.Uint64(data[off : off+8]) - off += 8 - tsBitsLen := binary.LittleEndian.Uint32(data[off : off+4]) - off += 4 - tsBytes := int((tsBitsLen + 7) / 8) - tsBits := data[off : off+tsBytes] - off += tsBytes - valBitsLen := binary.LittleEndian.Uint32(data[off : off+4]) - off += 4 - valBytes := int((valBitsLen + 7) / 8) - valBits := data[off : off+valBytes] - off += valBytes - - tss, ok := decodeTimestamps(firstTS, int(pointCount), tsBits, tsBitsLen) - require.True(t, ok, "ts decode") - vals, ok := decodeValues(firstVal, int(pointCount), valBits, valBitsLen) - require.True(t, ok, "val decode") - out = append(out, decodedSeries{meta: meta, tss: tss, vals: vals}) - } - return out -} - -func makePoints(n int, start int64, step int64, f func(i int) float64) []point { - pts := make([]point, n) - t := start - for i := 0; i < n; i++ { - pts[i] = point{ts: t, v: f(i)} - t += step - } - return pts -} - -func TestEncodeSinglePoint(t *testing.T) { - pts := []point{{ts: 1_000_000_000, v: 3.14}} - series := map[seriesKey]*seriesBuffer{ - {metricName: "m", attributesKey: "host=h;"}: {attributes: map[string]string{"host": "h"}, points: pts}, - } - chunks, err := buildChunks(series, 0) - require.NoError(t, err) - require.Len(t, chunks, 1) - got := decodeChunk(t, chunks[0].data) - require.Len(t, got, 1) - assert.Equal(t, "m", got[0].meta.MetricName) - require.Len(t, got[0].tss, 1) - assert.Equal(t, int64(1_000_000_000), got[0].tss[0]) - assert.Equal(t, 3.14, got[0].vals[0]) -} - -func TestEncode100SamplesRoundTrip(t *testing.T) { - const n = 100 - base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano() - pts := makePoints(n, base, int64(time.Second), func(i int) float64 { return float64(i) * 1.5 }) - series := map[seriesKey]*seriesBuffer{ - {metricName: "cpu", attributesKey: "host=a;"}: {attributes: map[string]string{"host": "a"}, points: pts}, - } - chunks, err := buildChunks(series, 0) - require.NoError(t, err) - require.Len(t, chunks, 1) - got := decodeChunk(t, chunks[0].data) - require.Len(t, got, 1) - require.Len(t, got[0].tss, n) - for i := 0; i < n; i++ { - assert.Equal(t, base+int64(i)*int64(time.Second), got[0].tss[i], "ts %d", i) - assert.Equal(t, float64(i)*1.5, got[0].vals[i], "v %d", i) - } -} - -func TestEncodeRegularIntervalCompactSize(t *testing.T) { - // 60 samples of constant value at constant 1s interval. Naive raw = - // 60 * 16 = 960 bytes. Gorilla should pack timestamps into 1 bit each - // after the first and values into 1 bit each after the first => the - // payload bits should be << 200 bytes total (well under 1/4 of raw). - const n = 60 - base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano() - pts := makePoints(n, base, int64(time.Second), func(i int) float64 { return 42 }) - series := map[seriesKey]*seriesBuffer{ - {metricName: "const", attributesKey: ""}: {attributes: map[string]string{}, points: pts}, - } - chunks, err := buildChunks(series, 0) - require.NoError(t, err) - require.Len(t, chunks, 1) - // The chunk includes a JSON metadata blob that depends on map - // iteration order; just assert the *encoded body* (post-headers) - // is dramatically smaller than the raw 960 bytes. - t.Logf("constant 60-sample chunk = %d bytes", len(chunks[0].data)) - assert.Less(t, len(chunks[0].data), 240, - "expected highly compressed chunk, got %d bytes", len(chunks[0].data)) - - got := decodeChunk(t, chunks[0].data) - require.Len(t, got, 1) - for i := 0; i < n; i++ { - assert.Equal(t, 42.0, got[0].vals[i]) - } -} - -func TestEncodeMultipleSeriesPerMetricGroupedIntoOneChunk(t *testing.T) { - base := time.Now().UnixNano() - pts1 := makePoints(5, base, int64(time.Second), func(i int) float64 { return float64(i) }) - pts2 := makePoints(5, base, int64(time.Second), func(i int) float64 { return float64(i) * 2 }) - series := map[seriesKey]*seriesBuffer{ - {metricName: "cpu", attributesKey: "host=a;"}: {attributes: map[string]string{"host": "a"}, points: pts1}, - {metricName: "cpu", attributesKey: "host=b;"}: {attributes: map[string]string{"host": "b"}, points: pts2}, - } - chunks, err := buildChunks(series, 0) - require.NoError(t, err) - require.Len(t, chunks, 1, "two series for same metric should land in one chunk") - assert.Equal(t, "cpu", chunks[0].metricName) - assert.Equal(t, 2, chunks[0].seriesCount) -} - -func TestEncodeMultipleMetricsSplitChunks(t *testing.T) { - base := time.Now().UnixNano() - pts := makePoints(5, base, int64(time.Second), func(i int) float64 { return float64(i) }) - series := map[seriesKey]*seriesBuffer{ - {metricName: "cpu", attributesKey: ""}: {attributes: map[string]string{}, points: pts}, - {metricName: "mem", attributesKey: ""}: {attributes: map[string]string{}, points: pts}, - } - chunks, err := buildChunks(series, 0) - require.NoError(t, err) - require.Len(t, chunks, 2, "different metrics should split into separate chunks") - names := []string{chunks[0].metricName, chunks[1].metricName} - assert.ElementsMatch(t, []string{"cpu", "mem"}, names) -} - -// TestChunkHeaderByteLayoutMatchesGorillaDecoder is a regression guard for -// the v7 fix in `buildChunks`: the GORILLA1 block header must place the -// magic at [0:8], the version at [8], and the seriesCount at [9:13] — -// the exact offsets the `asap-gorilla::decoder::GorillaDecoder::from_reader` -// path reads (see `asap-gorilla/src/decoder.rs` and `block.rs::HEADER_LEN`). -// Pre-v7 the encoder wrote seriesCount at [5:9], which clobbered bytes -// 5..8 of the magic and the version byte; the consumer side rejected -// every chunk with `BadMagic`, surfacing as empty `last_over_time` -// freshness deltas. Round-tripping the chunk through the test-only -// decoder additionally proves the timestamp/value streams are intact. -func TestChunkHeaderByteLayoutMatchesGorillaDecoder(t *testing.T) { - const n = 5 - base := time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC).UnixNano() - pts := makePoints(n, base, int64(time.Second), func(i int) float64 { return float64(i) + 0.5 }) - series := map[seriesKey]*seriesBuffer{ - {metricName: "http_freshness_probe_emitted_at", attributesKey: "host=h;"}: { - attributes: map[string]string{"host": "h"}, - points: pts, - }, - {metricName: "http_freshness_probe_emitted_at", attributesKey: "host=i;"}: { - attributes: map[string]string{"host": "i"}, - points: pts, - }, - } - chunks, err := buildChunks(series, 0) - require.NoError(t, err) - require.Len(t, chunks, 1, "two series of the same metric should pack into one chunk") - - data := chunks[0].data - require.GreaterOrEqual(t, len(data), 13, "chunk must contain a 13-byte header") - - // 1. Magic at [0:8] — full "GORILLA1", not a 4-byte prefix. - assert.Equal(t, chunkMagic, string(data[:8]), - "magic must be at [0:8]; matches asap-gorilla::block::MAGIC + HEADER_LEN") - // 2. Version at byte 8. - assert.Equal(t, byte(chunkVersion), data[8], - "version must be at byte 8; matches asap-gorilla decoder header[8]") - // 3. seriesCount at [9:13] — the v7 fix. - gotSeriesCount := binary.LittleEndian.Uint32(data[9:13]) - assert.Equal(t, uint32(2), gotSeriesCount, - "seriesCount must be at [9:13]; matches asap-gorilla decoder header[9..13]") - - // 4. Negative assertion: bytes [5:9] must NOT contain the - // seriesCount. Pre-v7 they did, which clobbered the magic - // suffix + version. Asserting on the magic suffix is the - // cleanest way to express the invariant. - assert.Equal(t, "LA1", string(data[5:8]), - "bytes 5..8 must remain magic suffix; pre-v7 these were overwritten with seriesCount") - assert.Equal(t, byte(chunkVersion), data[8], - "byte 8 must remain version; pre-v7 the seriesCount low byte landed here") - - // 5. Round-trip the body and confirm timestamps decode at the - // sample values we encoded. If the writer ever drifts out of - // sync with the test helper, the body offset will land in a - // different field and either explode or return garbage. - got := decodeChunk(t, data) - require.Len(t, got, 2) - for _, ds := range got { - require.Len(t, ds.tss, n) - for i := 0; i < n; i++ { - assert.Equal(t, base+int64(i)*int64(time.Second), ds.tss[i], "ts %d", i) - assert.Equal(t, float64(i)+0.5, ds.vals[i], "v %d", i) - } - } -} diff --git a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/postings.go b/opentelemetry-collector-contrib-patch/processor/gorillas3processor/postings.go deleted file mode 100644 index d98531d1..00000000 --- a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/postings.go +++ /dev/null @@ -1,179 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -// mvp/v5: per-block postings index — `label_name=value → series_ids`. -// -// Byte-compatible with the `asap-gorilla::postings` Rust module -// (`POSTING1` magic + version + JSON body + CRC32 trailer). The -// backend `GorillaQueryEngine` reads this sidecar to skip chunks -// that don't match label predicates without scanning every chunk. - -package gorillas3processor - -import ( - "encoding/binary" - "encoding/json" - "fmt" - "hash/crc32" - "sort" -) - -const ( - postingsMagic = "POSTING1" - postingsVersion = uint8(1) -) - -// postingsEntry is one row inside the on-wire postings JSON body. -type postingsEntry struct { - LabelName string `json:"label_name"` - LabelValue string `json:"label_value"` - SeriesIDs []uint64 `json:"series_ids"` -} - -// postingsBody is the JSON-serializable wrapper around the -// `(label_name, label_value, series_ids)` triples. Field order + -// types match the Rust `asap-gorilla::postings::PostingsBody` struct -// exactly. -type postingsBody struct { - SchemaVersion uint8 `json:"schema_version"` - GeneratedAtNS uint64 `json:"generated_at_ns"` - Entries []postingsEntry `json:"entries"` -} - -// buildPostings walks the per-window series map and returns a -// `postings-v1.json` byte blob for the supplied chunks. Each chunk -// contributes one series_id per (metric, attributes) buffer that -// lives inside it. The series_id is the same 64-bit canonical-label- -// set hash already used by `IndexEntry.label_hash`, so the backend -// can join postings → index entries without a second hash table. -// -// Output bytes layout (matches the Rust crate): -// -// [8] magic "POSTING1" -// [1] version 1 -// [4] uint32 LE body_json_len -// […] body_json sorted-by-(label_name,label_value) entries -// [4] uint32 LE crc32_ieee(body_json) -// -// CRC is `hash/crc32.IEEE` — same polynomial as the Rust `crc32fast` -// crate (despite the file's `POSTING1` magic suggesting CRC32C; we -// favour the dependency-free IEEE poly to keep both sides identical -// and detect-corruption is the only contract). See -// `asap-gorilla/src/postings.rs` `crc32c` fn for the matching note. -func buildPostings(series map[seriesKey]*seriesBuffer, generatedAtNS int64) ([]byte, error) { - // Step 1: bucket series_ids by (label_name, label_value). - type labelKey struct { - name string - value string - } - buckets := make(map[labelKey]map[uint64]struct{}) - for sk, buf := range series { - if buf == nil || len(buf.points) == 0 { - continue - } - seriesID := canonicalLabelHash(sk.metricName, buf.attributes) - // Synthetic `__name__` posting so the backend can - // short-circuit metric-only queries via the same path. - add := func(n, v string) { - lk := labelKey{name: n, value: v} - set, ok := buckets[lk] - if !ok { - set = make(map[uint64]struct{}) - buckets[lk] = set - } - set[seriesID] = struct{}{} - } - add("__name__", sk.metricName) - for k, v := range buf.attributes { - add(k, v) - } - } - - // Step 2: sort keys for deterministic output (same input → same - // bytes, same as the Rust side). - keys := make([]labelKey, 0, len(buckets)) - for k := range buckets { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { - if keys[i].name != keys[j].name { - return keys[i].name < keys[j].name - } - return keys[i].value < keys[j].value - }) - - entries := make([]postingsEntry, 0, len(keys)) - for _, k := range keys { - ids := buckets[k] - idList := make([]uint64, 0, len(ids)) - for id := range ids { - idList = append(idList, id) - } - sort.Slice(idList, func(i, j int) bool { return idList[i] < idList[j] }) - entries = append(entries, postingsEntry{ - LabelName: k.name, - LabelValue: k.value, - SeriesIDs: idList, - }) - } - - // Step 3: marshal the JSON body and frame it with magic + crc. - body := postingsBody{ - SchemaVersion: postingsVersion, - GeneratedAtNS: uint64(generatedAtNS), - Entries: entries, - } - bodyJSON, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("postings: marshal body: %w", err) - } - if len(bodyJSON) > int(^uint32(0)) { - return nil, fmt.Errorf("postings: body too large (%d bytes)", len(bodyJSON)) - } - - out := make([]byte, 0, 8+1+4+len(bodyJSON)+4) - out = append(out, []byte(postingsMagic)...) - out = append(out, postingsVersion) - var lenBuf [4]byte - binary.LittleEndian.PutUint32(lenBuf[:], uint32(len(bodyJSON))) - out = append(out, lenBuf[:]...) - out = append(out, bodyJSON...) - crc := crc32.ChecksumIEEE(bodyJSON) - var crcBuf [4]byte - binary.LittleEndian.PutUint32(crcBuf[:], crc) - out = append(out, crcBuf[:]...) - return out, nil -} - -// canonicalLabelHash computes a deterministic 64-bit hash of the -// (metric_name, sorted attributes) tuple. Matches the convention the -// backend uses for `IndexEntry.label_hash`. We use FNV-1a directly -// (no extra dependency) — the wire commitment is just "stable across -// agent restarts", not a particular polynomial. -func canonicalLabelHash(metricName string, attrs map[string]string) uint64 { - const ( - fnvOffset uint64 = 14695981039346656037 - fnvPrime uint64 = 1099511628211 - ) - h := fnvOffset - mix := func(s string) { - for i := 0; i < len(s); i++ { - h ^= uint64(s[i]) - h *= fnvPrime - } - } - mix(metricName) - mix("\x00") - keys := make([]string, 0, len(attrs)) - for k := range attrs { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - mix(k) - mix("=") - mix(attrs[k]) - mix(";") - } - return h -} diff --git a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/postings_test.go b/opentelemetry-collector-contrib-patch/processor/gorillas3processor/postings_test.go deleted file mode 100644 index d8f43711..00000000 --- a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/postings_test.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -package gorillas3processor - -import ( - "bytes" - "encoding/binary" - "encoding/json" - "hash/crc32" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestBuildPostings_FrameAndHeader(t *testing.T) { - series := map[seriesKey]*seriesBuffer{ - {metricName: "http_requests_total", attributesKey: "service=api"}: { - attributes: map[string]string{"service": "api", "zone": "a"}, - points: []point{{ts: 1, v: 1.0}}, - }, - {metricName: "http_requests_total", attributesKey: "service=web"}: { - attributes: map[string]string{"service": "web", "zone": "b"}, - points: []point{{ts: 2, v: 2.0}}, - }, - } - body, err := buildPostings(series, 12345) - require.NoError(t, err) - - // Magic + version. - require.GreaterOrEqual(t, len(body), 8+1+4+4) - assert.Equal(t, []byte(postingsMagic), body[:8]) - assert.Equal(t, postingsVersion, body[8]) - - bodyLen := binary.LittleEndian.Uint32(body[9:13]) - require.Equal(t, int(bodyLen)+8+1+4+4, len(body), - "postings frame must be magic(8) + ver(1) + len(4) + body + crc(4)") - - // CRC of body must match. - jsonBody := body[13 : 13+bodyLen] - storedCRC := binary.LittleEndian.Uint32(body[13+bodyLen:]) - assert.Equal(t, crc32.ChecksumIEEE(jsonBody), storedCRC, "postings CRC32 over body must match trailer") - - // JSON body parses + has expected entries. - var parsed postingsBody - require.NoError(t, json.Unmarshal(jsonBody, &parsed)) - assert.Equal(t, postingsVersion, parsed.SchemaVersion) - assert.Equal(t, uint64(12345), parsed.GeneratedAtNS) - // Synthetic __name__ posting + the 2 user labels per series. - // Two series share __name__=http_requests_total → that posting - // list has 2 ids; service=api/web each have 1 id; zone=a/b each - // have 1 id. - got := map[string]int{} - for _, e := range parsed.Entries { - got[e.LabelName+"="+e.LabelValue] = len(e.SeriesIDs) - } - assert.Equal(t, 2, got["__name__=http_requests_total"]) - assert.Equal(t, 1, got["service=api"]) - assert.Equal(t, 1, got["service=web"]) - assert.Equal(t, 1, got["zone=a"]) - assert.Equal(t, 1, got["zone=b"]) -} - -func TestBuildPostings_Deterministic(t *testing.T) { - // Two semantically identical series maps in different iteration - // orders must yield byte-identical postings frames (modulo the - // generated_at_ns timestamp, which we hold equal here). - mk := func() map[seriesKey]*seriesBuffer { - return map[seriesKey]*seriesBuffer{ - {metricName: "m", attributesKey: "a=1"}: { - attributes: map[string]string{"a": "1"}, - points: []point{{ts: 1, v: 1}}, - }, - {metricName: "m", attributesKey: "b=2"}: { - attributes: map[string]string{"b": "2"}, - points: []point{{ts: 1, v: 1}}, - }, - {metricName: "m", attributesKey: "c=3"}: { - attributes: map[string]string{"c": "3"}, - points: []point{{ts: 1, v: 1}}, - }, - } - } - b1, err := buildPostings(mk(), 999) - require.NoError(t, err) - b2, err := buildPostings(mk(), 999) - require.NoError(t, err) - assert.True(t, bytes.Equal(b1, b2), "buildPostings must be deterministic") -} - -func TestBuildPostings_SkipsEmptySeries(t *testing.T) { - series := map[seriesKey]*seriesBuffer{ - {metricName: "m", attributesKey: "good"}: { - attributes: map[string]string{"a": "1"}, - points: []point{{ts: 1, v: 1}}, - }, - {metricName: "m", attributesKey: "empty"}: { - attributes: map[string]string{"b": "2"}, - points: nil, - }, - {metricName: "m", attributesKey: "nilbuf"}: nil, - } - body, err := buildPostings(series, 0) - require.NoError(t, err) - bodyLen := binary.LittleEndian.Uint32(body[9:13]) - jsonBody := body[13 : 13+bodyLen] - var parsed postingsBody - require.NoError(t, json.Unmarshal(jsonBody, &parsed)) - got := map[string]bool{} - for _, e := range parsed.Entries { - got[e.LabelName+"="+e.LabelValue] = true - } - assert.True(t, got["a=1"], "non-empty series must contribute postings") - assert.False(t, got["b=2"], "empty buffer must not contribute postings") -} diff --git a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/processor.go b/opentelemetry-collector-contrib-patch/processor/gorillas3processor/processor.go index c5aecf21..a510bae6 100644 --- a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/processor.go +++ b/opentelemetry-collector-contrib-patch/processor/gorillas3processor/processor.go @@ -565,3 +565,12 @@ func fragmentFromAttributes(attrs pcommon.Map) (gorilla.Fragment, bool, error) { } return fragment, true, nil } + +func attributesToMap(attrs pcommon.Map) map[string]string { + m := make(map[string]string, attrs.Len()) + attrs.Range(func(k string, v pcommon.Value) bool { + m[k] = v.AsString() + return true + }) + return m +} diff --git a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/processor_test.go b/opentelemetry-collector-contrib-patch/processor/gorillas3processor/processor_test.go index 89065db4..7f907842 100644 --- a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/processor_test.go +++ b/opentelemetry-collector-contrib-patch/processor/gorillas3processor/processor_test.go @@ -114,6 +114,44 @@ func writeMockTSDBBlock(t *testing.T, block mockTSDBBlock) string { return filepath.Join(root, block.ulid) } +type rtSeries struct { + labels labels.Labels + samples []rtSample +} + +type rtSample struct { + t int64 + v float64 +} + +// readAllSamples opens a Prometheus TSDB block on disk and returns +// the round-trip view of every (label, ts, value) tuple. Used by +// tests that finalize a TSDB block via the live processor and want +// to assert the canonical reader can re-derive the input. +func readAllSamples(t *testing.T, block *tsdb.Block) []rtSeries { + t.Helper() + q, err := tsdb.NewBlockQuerier(block, block.MinTime(), block.MaxTime()) + require.NoError(t, err) + defer q.Close() + + var out []rtSeries + ss := q.Select(context.Background(), false, nil, labels.MustNewMatcher(labels.MatchRegexp, labels.MetricName, ".+")) + for ss.Next() { + s := ss.At() + ls := s.Labels() + var samples []rtSample + it := s.Iterator(nil) + for it.Next() == chunkenc.ValFloat { + ts, val := it.At() + samples = append(samples, rtSample{t: ts, v: val}) + } + require.NoError(t, it.Err()) + out = append(out, rtSeries{labels: ls, samples: samples}) + } + require.NoError(t, ss.Err()) + return out +} + func buildTestMetrics(metricName string, n int, baseTime time.Time) pmetric.Metrics { md := pmetric.NewMetrics() rm := md.ResourceMetrics().AppendEmpty() diff --git a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/tsdb_block_writer.go b/opentelemetry-collector-contrib-patch/processor/gorillas3processor/tsdb_block_writer.go deleted file mode 100644 index 005c2f54..00000000 --- a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/tsdb_block_writer.go +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -package gorillas3processor - -import ( - "context" - "fmt" - "time" - - gorilla "github.com/ProjectASAP/asap-gorilla-go" - "github.com/oklog/ulid/v2" -) - -// tsdbBlockArtifact preserves the processor-local test surface while the -// implementation lives in github.com/ProjectASAP/asap-gorilla-go. -type tsdbBlockArtifact struct { - ULID ulid.ULID - Files map[string][]byte - MinTime int64 - MaxTime int64 - NumSeries uint64 - NumSamples uint64 - NumOOBDropped uint64 -} - -// tsdbBlockBuilder is a thin adapter over the shared streaming builder. Runtime -// processors should not carry TSDB/Gorilla block-writing implementations. -type tsdbBlockBuilder struct { - externalLabels map[string]string - reorderGrace time.Duration -} - -func newTSDBBlockBuilder(_ time.Duration, ext map[string]string, _ any) *tsdbBlockBuilder { - return &tsdbBlockBuilder{ - externalLabels: cloneMap(ext), - reorderGrace: 2 * time.Second, - } -} - -func (b *tsdbBlockBuilder) build(ctx context.Context, window map[seriesKey]*seriesBuffer) (*tsdbBlockArtifact, error) { - if len(window) == 0 { - return nil, nil - } - builder, err := gorilla.NewStreamingTSDBBlockBuilder(gorilla.StreamingTSDBOptions{ - ReorderGrace: b.reorderGrace, - ExternalLabels: b.externalLabels, - }) - if err != nil { - return nil, err - } - for sk, buf := range window { - if buf == nil { - continue - } - for _, p := range buf.points { - if err := builder.AddSample(gorilla.TSDBSample{ - MetricName: sk.metricName, - Attributes: buf.attributes, - Timestamp: time.Unix(0, p.ts), - Value: p.v, - }); err != nil { - return nil, fmt.Errorf("streaming tsdb add sample: %w", err) - } - } - } - art, err := builder.Finalize(ctx) - if err != nil { - return nil, err - } - if art == nil { - return nil, nil - } - return &tsdbBlockArtifact{ - ULID: art.ULID, - Files: art.Files, - MinTime: art.MinTime, - MaxTime: art.MaxTime, - NumSeries: art.NumSeries, - NumSamples: art.NumSamples, - NumOOBDropped: art.NumOOODropped, - }, nil -} - -func cloneMap(in map[string]string) map[string]string { - if len(in) == 0 { - return nil - } - out := make(map[string]string, len(in)) - for k, v := range in { - out[k] = v - } - return out -} diff --git a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/tsdb_block_writer_test.go b/opentelemetry-collector-contrib-patch/processor/gorillas3processor/tsdb_block_writer_test.go deleted file mode 100644 index 95851b07..00000000 --- a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/tsdb_block_writer_test.go +++ /dev/null @@ -1,537 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -// mvp/step2.1: Prometheus TSDB block-format writer tests. -// -// The round-trip test is the load-bearing assertion: it writes a -// block via tsdbBlockBuilder, materialises the in-memory artifact -// to a temp directory in the canonical Prometheus layout, and reads -// it back via Prometheus' own `tsdb.OpenBlock`. If the canonical -// reader can re-derive every (label, ts, value) tuple we wrote in, -// the byte format is correct by construction — Step 2.3's Thanos -// store-gateway will be able to do the same. - -package gorillas3processor - -import ( - "context" - "os" - "path/filepath" - "sort" - "testing" - "time" - - "github.com/prometheus/prometheus/model/labels" - "github.com/prometheus/prometheus/tsdb" - "github.com/prometheus/prometheus/tsdb/chunkenc" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/collector/pdata/pcommon" - "go.opentelemetry.io/collector/pdata/pmetric" - "go.uber.org/zap/zaptest" -) - -// writeArtifactToDir writes the in-memory artifact files back out -// under a temp dir in the canonical `//...` layout, so -// `tsdb.OpenBlock` can read them. Returns `/`. -func writeArtifactToDir(t *testing.T, art *tsdbBlockArtifact) string { - t.Helper() - root := t.TempDir() - for k, body := range art.Files { - full := filepath.Join(root, filepath.FromSlash(k)) - require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) - require.NoError(t, os.WriteFile(full, body, 0o644)) - } - return filepath.Join(root, art.ULID.String()) -} - -// TestTSDBBlockBuilder_RoundTrip writes a small window into a -// Prometheus block, reads it back via tsdb.OpenBlock, and asserts -// that every sample matches. -func TestTSDBBlockBuilder_RoundTrip(t *testing.T) { - base := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC).UnixNano() - const n = 30 - pts := make([]point, n) - for i := 0; i < n; i++ { - pts[i] = point{ts: base + int64(i)*int64(time.Second), v: float64(i) * 1.5} - } - window := map[seriesKey]*seriesBuffer{ - {metricName: "cpu_usage", attributesKey: "host=h;"}: { - attributes: map[string]string{"host": "h"}, - points: pts, - }, - } - - b := newTSDBBlockBuilder(60*time.Second, nil, nil) - art, err := b.build(context.Background(), window) - require.NoError(t, err) - require.NotNil(t, art) - require.NotEmpty(t, art.Files, "block writer must emit at least one file") - // Block layout sanity. - hasMeta, hasIndex, hasChunks := false, false, false - for k := range art.Files { - switch { - case filepath.Base(k) == "meta.json": - hasMeta = true - case filepath.Base(k) == "index": - hasIndex = true - case filepath.Dir(k) == art.ULID.String()+"/chunks": - hasChunks = true - } - } - assert.True(t, hasMeta, "expected meta.json in artifact") - assert.True(t, hasIndex, "expected index in artifact") - assert.True(t, hasChunks, "expected chunks/000001+ in artifact") - - // Round-trip via the canonical reader. - blockDir := writeArtifactToDir(t, art) - block, err := tsdb.OpenBlock(nil, blockDir, chunkenc.NewPool(), nil) - require.NoError(t, err) - defer block.Close() - - got := readAllSamples(t, block) - require.Len(t, got, 1, "single series in single block") - - // __name__ + host - gotLs := got[0].labels - assert.Equal(t, "cpu_usage", gotLs.Get(labels.MetricName)) - assert.Equal(t, "h", gotLs.Get("host")) - - require.Len(t, got[0].samples, n) - for i, s := range got[0].samples { - assert.Equal(t, (base+int64(i)*int64(time.Second))/int64(time.Millisecond), s.t, "ts %d", i) - assert.InDelta(t, float64(i)*1.5, s.v, 0, "v %d", i) - } -} - -// TestTSDBBlockBuilder_MultiSeries verifies that two distinct -// label-sets produce two distinct series in the block. -func TestTSDBBlockBuilder_MultiSeries(t *testing.T) { - base := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC).UnixNano() - mk := func(host string, vBase float64) *seriesBuffer { - buf := &seriesBuffer{ - attributes: map[string]string{"host": host}, - points: make([]point, 10), - } - for i := 0; i < 10; i++ { - buf.points[i] = point{ - ts: base + int64(i)*int64(time.Second), - v: vBase + float64(i), - } - } - return buf - } - window := map[seriesKey]*seriesBuffer{ - {metricName: "rps", attributesKey: "host=a;"}: mk("a", 100), - {metricName: "rps", attributesKey: "host=b;"}: mk("b", 200), - } - - b := newTSDBBlockBuilder(60*time.Second, nil, nil) - art, err := b.build(context.Background(), window) - require.NoError(t, err) - require.NotNil(t, art) - - blockDir := writeArtifactToDir(t, art) - block, err := tsdb.OpenBlock(nil, blockDir, chunkenc.NewPool(), nil) - require.NoError(t, err) - defer block.Close() - - got := readAllSamples(t, block) - require.Len(t, got, 2) - // Sort by host attribute for stable assertions. - sort.Slice(got, func(i, j int) bool { return got[i].labels.Get("host") < got[j].labels.Get("host") }) - assert.Equal(t, "a", got[0].labels.Get("host")) - assert.Equal(t, "b", got[1].labels.Get("host")) - // Spot-check first sample. - assert.InDelta(t, 100.0, got[0].samples[0].v, 0) - assert.InDelta(t, 200.0, got[1].samples[0].v, 0) -} - -// TestTSDBBlockBuilder_ExternalLabels verifies that labels set in -// Config.TSDBExternalLabels appear on every series in the block. -// Step 2.3's backend joins on these so this is contract-load-bearing. -func TestTSDBBlockBuilder_ExternalLabels(t *testing.T) { - base := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC).UnixNano() - window := map[seriesKey]*seriesBuffer{ - {metricName: "m", attributesKey: ""}: { - attributes: map[string]string{}, - points: []point{{ts: base, v: 1}, {ts: base + int64(time.Second), v: 2}}, - }, - } - b := newTSDBBlockBuilder(60*time.Second, map[string]string{ - "cluster": "prod", - "replica": "a", - }, nil) - art, err := b.build(context.Background(), window) - require.NoError(t, err) - require.NotNil(t, art) - - blockDir := writeArtifactToDir(t, art) - block, err := tsdb.OpenBlock(nil, blockDir, chunkenc.NewPool(), nil) - require.NoError(t, err) - defer block.Close() - - got := readAllSamples(t, block) - require.Len(t, got, 1) - assert.Equal(t, "prod", got[0].labels.Get("cluster")) - assert.Equal(t, "a", got[0].labels.Get("replica")) -} - -// TestTSDBBlockBuilder_EmptyWindowReturnsNil confirms the empty-window -// short-circuit. tsdb.BlockWriter would otherwise produce an empty -// ULID and we'd try to PUT three files of nothing. -func TestTSDBBlockBuilder_EmptyWindowReturnsNil(t *testing.T) { - b := newTSDBBlockBuilder(60*time.Second, nil, nil) - art, err := b.build(context.Background(), nil) - require.NoError(t, err) - assert.Nil(t, art) -} - -// TestFlushWindow_BlockFormatTSDB verifies the processor flush wires -// straight through to PutTSDBBlock when block_format=prometheus_tsdb. -// No GORILLA1 chunk should land on the legacy path. -func TestFlushWindow_BlockFormatTSDB(t *testing.T) { - cfg := &Config{ - Bucket: "asap-gorilla", - TSDBBucket: "asap-tsdb", - WindowInterval: time.Hour, - DropOriginal: true, - BlockFormat: BlockFormatPrometheusTSDB, - Tenant: "tnt", - } - sink := &mockSink{} - p := mkProcessor(t, cfg, sink) - - base := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC) - md := buildTestMetrics("cpu.usage", 8, base) - _, err := p.ConsumeMetrics(context.Background(), md) - require.NoError(t, err) - - p.flushWindow(context.Background()) - - assert.Equal(t, 0, sink.chunkCount(), "asap chunks must NOT be emitted in prometheus_tsdb mode") - assert.Equal(t, 0, sink.postingsCount(), "asap postings must NOT be emitted in prometheus_tsdb mode") - require.Equal(t, 1, sink.tsdbBlockCount(), "expected one tsdb block") - block := sink.tsdbBlocks[0] - assert.NotEmpty(t, block.ulid) - // Files must include meta.json + index + chunks/000001. - require.NotEmpty(t, block.files) - hasMeta, hasIndex, hasChunks := false, false, false - for k := range block.files { - base := filepath.Base(k) - switch base { - case "meta.json": - hasMeta = true - case "index": - hasIndex = true - case "000001": - hasChunks = true - } - } - assert.True(t, hasMeta, "block must include meta.json") - assert.True(t, hasIndex, "block must include index") - assert.True(t, hasChunks, "block must include chunks/000001") -} - -// TestConfig_ValidateBlockFormat exercises the BlockFormat validation -// branches. -func TestConfig_ValidateBlockFormat(t *testing.T) { - // Default fills in prometheus_tsdb. - cfg := &Config{Bucket: "b"} - require.NoError(t, cfg.Validate()) - assert.Equal(t, BlockFormatPrometheusTSDB, cfg.BlockFormat) - assert.True(t, cfg.EmitTSDB()) - - // prometheus_tsdb is accepted; TSDBBucket falls back to Bucket. - cfg = &Config{Bucket: "b", BlockFormat: BlockFormatPrometheusTSDB} - require.NoError(t, cfg.Validate()) - assert.Equal(t, "b", cfg.TSDBBucket) - assert.True(t, cfg.EmitTSDB()) - - // Invalid value rejected. - cfg = &Config{Bucket: "b", BlockFormat: BlockFormat("nonsense")} - require.Error(t, cfg.Validate()) -} - -// TestTSDBBlockBuilder_RotatingCardinalityNoOOB pins the issue#46 -// regression. Models the fake-exporter PR#338 `unique_users_per_min` -// rotating user_id pool: each user is "active" only during a -// non-overlapping slice of the 60s window. Before the fix, the -// Head's appender locked `minValidTime` at -// `firstAppendedSampleTs - chunkRange/2`. With random map iteration -// order over `window`, if the first-visited series held samples in -// e.g. the [40..55s] slice, the floor became 10s and any -// subsequent series with samples at t<10s tripped -// `storage.ErrOutOfBounds`. -// -// The fix flattens all samples across series and sorts globally by -// timestamp ascending, guaranteeing the first-appended sample has -// the smallest ts in the entire window. After the fix, the floor -// is `min(window) - 30s` which sits below every other sample in -// the window. We loop 50 times to make accidental success -// statistically improbable across Go's randomised map iteration. -func TestTSDBBlockBuilder_RotatingCardinalityNoOOB(t *testing.T) { - const blockMs = int64(60_000) - base := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC).UnixNano() - - // 8 users, each active in a non-overlapping 7s slice of the - // 60s window: u0=[0..6s], u1=[7..13s], ..., u7=[49..55s]. - mkSeries := func(uIdx int, uid string, vBase float64) (seriesKey, *seriesBuffer) { - const sliceWidth = 7 - startSec := uIdx * sliceWidth - buf := &seriesBuffer{ - attributes: map[string]string{"user_id": uid, "host": "h"}, - points: make([]point, sliceWidth), - } - for i := 0; i < sliceWidth; i++ { - ts := base + int64(startSec+i)*int64(time.Second) - buf.points[i] = point{ts: ts, v: vBase + float64(i)} - } - return seriesKey{metricName: "unique_users_per_min", attributesKey: "host=h;user_id=" + uid + ";"}, buf - } - - for trial := 0; trial < 50; trial++ { - window := make(map[seriesKey]*seriesBuffer) - for u := 0; u < 8; u++ { - sk, buf := mkSeries(u, "u"+string(rune('a'+u)), float64(u)*100) - window[sk] = buf - } - b := newTSDBBlockBuilder(time.Duration(blockMs)*time.Millisecond, nil, nil) - art, err := b.build(context.Background(), window) - require.NoError(t, err, "trial %d: build must not error on rotating-cardinality input", trial) - require.NotNil(t, art, "trial %d: artifact must not be nil", trial) - assert.Equal(t, uint64(0), art.NumOOBDropped, "trial %d: no in-window samples should be dropped", trial) - assert.Equal(t, uint64(8), art.NumSeries, "trial %d: expected 8 series in block", trial) - assert.Equal(t, uint64(8*7), art.NumSamples, "trial %d: expected 56 samples in block", trial) - } -} - -// TestTSDBBlockBuilder_WideTimestampSpanNoOOB verifies that even a -// 5-minute span between the earliest and latest samples in a -// single window builds cleanly — global sort anchors the -// appender's minValidTime at the smallest ts (- chunkRange/2), so -// every later sample fits regardless of cross-series interleaving. -// This is the operator-visible contract: the agent's window -// buffer can hold whatever the upstream OTLP push schedule -// produces and we'll still emit a valid TSDB block. -func TestTSDBBlockBuilder_WideTimestampSpanNoOOB(t *testing.T) { - const blockMs = int64(60_000) - base := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC).UnixNano() - wayBack := base - 5*60*int64(time.Second) // 5min before base - - window := map[seriesKey]*seriesBuffer{ - {metricName: "m", attributesKey: "host=now;"}: { - attributes: map[string]string{"host": "now"}, - points: []point{ - {ts: base, v: 1}, - {ts: base + int64(time.Second), v: 2}, - {ts: base + 2*int64(time.Second), v: 3}, - }, - }, - {metricName: "m", attributesKey: "host=rogue;"}: { - attributes: map[string]string{"host": "rogue"}, - points: []point{ - {ts: wayBack, v: 100}, - }, - }, - } - - b := newTSDBBlockBuilder(time.Duration(blockMs)*time.Millisecond, nil, nil) - art, err := b.build(context.Background(), window) - require.NoError(t, err, "build must tolerate wide-gap samples without error") - require.NotNil(t, art, "artifact must not be nil") - assert.Equal(t, uint64(2), art.NumSeries, "both series end up in the block") - // Every sample fits because the rogue (oldest) is appended - // first and anchors minValidTime at wayBack-30s. - assert.Equal(t, uint64(0), art.NumOOBDropped, "no drops with global-sort") - assert.Equal(t, uint64(4), art.NumSamples) -} - -// TestFlushTSDB_OOBDoesNotCrashProcessor wires the rotating-cardinality -// scenario through the full processor flush path and asserts: -// 1. flushWindow returns without panic / fatal log, -// 2. the tsdb block IS uploaded (no series silently lost), -// 3. all 8 series and all 96 samples land in the block. -func TestFlushTSDB_OOBDoesNotCrashProcessor(t *testing.T) { - cfg := &Config{ - Bucket: "asap-gorilla", - TSDBBucket: "asap-tsdb", - WindowInterval: time.Hour, - TSDBBlockDuration: 60 * time.Second, - DropOriginal: true, - BlockFormat: BlockFormatPrometheusTSDB, - Tenant: "tnt", - } - sink := &mockSink{} - p := mkProcessor(t, cfg, sink) - - // Build 8 series of `unique_users_per_min`-style data, each - // active in a non-overlapping 7s slice of the 60s window. - // Pre-fix this triggered OOB because the appender's - // minValidTime locked at firstAppendedTs - 30s, which (for - // any slice starting > 30s) would reject samples in earlier - // slices. - base := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC) - const sliceWidth = 7 - for u := 0; u < 8; u++ { - md := pmetric.NewMetrics() - rm := md.ResourceMetrics().AppendEmpty() - sm := rm.ScopeMetrics().AppendEmpty() - m := sm.Metrics().AppendEmpty() - m.SetName("unique_users_per_min") - g := m.SetEmptyGauge() - for i := 0; i < sliceWidth; i++ { - dp := g.DataPoints().AppendEmpty() - off := time.Duration(u*sliceWidth+i) * time.Second - dp.SetTimestamp(pcommon.NewTimestampFromTime(base.Add(off))) - dp.SetDoubleValue(float64(u*100 + i)) - dp.Attributes().PutStr("user_id", "u"+string(rune('a'+u))) - dp.Attributes().PutStr("host", "h") - } - _, err := p.ConsumeMetrics(context.Background(), md) - require.NoError(t, err) - } - - p.flushWindow(context.Background()) - require.Equal(t, 1, sink.tsdbBlockCount(), "expected one tsdb block") - blk := sink.tsdbBlocks[0] - require.NotEmpty(t, blk.files) - - // Round-trip and count. - root := t.TempDir() - for k, body := range blk.files { - full := filepath.Join(root, filepath.FromSlash(k)) - require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) - require.NoError(t, os.WriteFile(full, body, 0o644)) - } - block, err := tsdb.OpenBlock(nil, filepath.Join(root, blk.ulid), chunkenc.NewPool(), nil) - require.NoError(t, err) - defer block.Close() - got := readAllSamples(t, block) - require.Len(t, got, 8, "expected 8 distinct series in the block") - totalSamples := 0 - for _, s := range got { - totalSamples += len(s.samples) - } - assert.Equal(t, 8*sliceWidth, totalSamples, "expected all 56 samples to land in the block") -} - -// readAllSamples opens every series in a block and returns the -// label-set + (ts, value) decoded samples. -type rtSeries struct { - labels labels.Labels - samples []rtSample -} -type rtSample struct { - t int64 - v float64 -} - -func readAllSamples(t *testing.T, block *tsdb.Block) []rtSeries { - t.Helper() - q, err := tsdb.NewBlockQuerier(block, block.MinTime(), block.MaxTime()) - require.NoError(t, err) - defer q.Close() - - var out []rtSeries - ss := q.Select(context.Background(), false, nil, labels.MustNewMatcher(labels.MatchRegexp, labels.MetricName, ".+")) - for ss.Next() { - s := ss.At() - ls := s.Labels() - var samples []rtSample - it := s.Iterator(nil) - for it.Next() == chunkenc.ValFloat { - ts, val := it.At() - samples = append(samples, rtSample{t: ts, v: val}) - } - require.NoError(t, it.Err()) - out = append(out, rtSeries{labels: ls, samples: samples}) - } - require.NoError(t, ss.Err()) - return out -} - -// TestTSDBBlockBuilder_HighCardinalityMemoryBound is the regression -// test for PR #355 / fix/thanos-archive-write-regression. -// -// PR #354's original OOB-tolerate-by-flatten-and-sort-globally -// implementation allocated an O(N_samples) `flatSample` slice -// (~80 B / sample — ts + value + labels.Labels header + -// *storage.SeriesRef pointer) IN ADDITION to the per-series buffer -// copies, AND interleaved Head series creation across every series -// in the block (each series' open `headChunks` stayed resident -// through the whole flush). Under the 5-sketch routing topology -// (6 gorillas3 instances × 5K cardinality × 60 s window ≈ 1.2 M -// samples per flush each), that pushed the agent past its 1.5 GiB -// limit and OOM-killed it BEFORE the first block reached MinIO. -// Result: archive_ok = 0 / archive_miss = 1713 in the post-PR-#354 -// MVP rerun. -// -// The fix in PR #355 visits series in ascending order of each -// series' EARLIEST sample timestamp — guaranteeing the first -// `app.Append` carries the global minimum (so the appender's -// minValidTime anchors at globalMin - chunkRange/2) without any -// flat-slice allocation. -// -// This test reproduces the cardinality + sample-count profile of -// the flush that triggered the OOM, and asserts that: -// 1. build returns successfully — no error, no panic, every -// in-window sample lands in the resulting block, -// 2. the per-flush working set is bounded by `max(series points, -// Head's per-series state)` rather than `O(N_samples)`. We -// can't measure Go heap usage portably from a unit test, so -// we assert the algorithmic invariant: the visit order produces -// the global-minimum sample as the first append, which is the -// property that lets us drop the flat slice. -func TestTSDBBlockBuilder_HighCardinalityMemoryBound(t *testing.T) { - const ( - blockMs = int64(60_000) - seriesN = 200 // representative cardinality per gorillas3 instance - samplesPer = 60 // 1 Hz over a 60s window - ) - base := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC).UnixNano() - - window := make(map[seriesKey]*seriesBuffer, seriesN) - for s := 0; s < seriesN; s++ { - // Each series carries its own per-series time-slice. Slice - // starts are spread across the 60s window so the global - // minimum belongs to series 0 and the global maximum to - // series seriesN-1 — the kind of arrangement that under - // random map iteration would have tripped OOB before - // PR #354 and OOM-killed the agent under PR #354's flat- - // slice fix. - startSec := (s * 60) / seriesN // 0..59 - buf := &seriesBuffer{ - attributes: map[string]string{ - "user_id": "u" + string(rune('A'+(s%26))) + string(rune('A'+((s/26)%26))), - "host": "h", - }, - points: make([]point, samplesPer), - } - for i := 0; i < samplesPer; i++ { - ts := base + int64(startSec)*int64(time.Second) + int64(i)*int64(time.Millisecond*100) - buf.points[i] = point{ts: ts, v: float64(s*samplesPer + i)} - } - sk := seriesKey{ - metricName: "unique_users_per_min", - attributesKey: "host=h;user_id=" + buf.attributes["user_id"] + ";", - } - window[sk] = buf - } - - b := newTSDBBlockBuilder(time.Duration(blockMs)*time.Millisecond, nil, nil) - art, err := b.build(context.Background(), window) - require.NoError(t, err, "high-cardinality flush must not error") - require.NotNil(t, art, "high-cardinality flush must produce a block") - assert.Equal(t, uint64(seriesN), art.NumSeries, "every series should land in the block") - assert.Equal(t, uint64(seriesN*samplesPer), art.NumSamples, "every sample should land in the block") - assert.Equal(t, uint64(0), art.NumOOBDropped, "no in-window sample should be OOB under earliest-first visit order") -} - -// silenceUnused is here purely so unused imports stay honest in -// case the editor strips them. pcommon + zaptest are used by the -// shared mkProcessor / buildTestMetrics helpers in processor_test.go; -// the linker still complains if we never reference them in this -// file's transitive scope. -var _ = pcommon.NewMap -var _ = zaptest.NewLogger diff --git a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/window.go b/opentelemetry-collector-contrib-patch/processor/gorillas3processor/window.go deleted file mode 100644 index 6743fc79..00000000 --- a/opentelemetry-collector-contrib-patch/processor/gorillas3processor/window.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -package gorillas3processor - -import ( - "sort" - "strings" - "sync" - "time" - - "go.opentelemetry.io/collector/pdata/pcommon" -) - -// windowState is the in-memory tumbling-window aggregator. It keeps -// per-(metric, label_set) buffers and tracks the bounding timestamps -// for the current window. -type windowState struct { - mu sync.Mutex - series map[seriesKey]*seriesBuffer - earliest time.Time - latest time.Time -} - -func newWindowState() *windowState { - return &windowState{series: make(map[seriesKey]*seriesBuffer)} -} - -// add records one sample for the given metric/attributes. -func (w *windowState) add(metricName string, attrs pcommon.Map, ts time.Time, v float64) { - attrsKey := encodeAttributesAsKey(attrs) - sk := seriesKey{metricName: metricName, attributesKey: attrsKey} - - w.mu.Lock() - defer w.mu.Unlock() - - buf, ok := w.series[sk] - if !ok { - buf = &seriesBuffer{ - attributes: attributesToMap(attrs), - points: make([]point, 0, 64), - } - w.series[sk] = buf - } - buf.points = append(buf.points, point{ts: ts.UnixNano(), v: v}) - - if w.earliest.IsZero() || ts.Before(w.earliest) { - w.earliest = ts - } - if ts.After(w.latest) { - w.latest = ts - } -} - -// snapshot atomically swaps the current series state for an empty one -// and returns the captured map plus the bounding timestamps. -func (w *windowState) snapshot() (map[seriesKey]*seriesBuffer, time.Time, time.Time) { - w.mu.Lock() - defer w.mu.Unlock() - - if len(w.series) == 0 { - return nil, time.Time{}, time.Time{} - } - snap := w.series - earliest := w.earliest - latest := w.latest - w.series = make(map[seriesKey]*seriesBuffer) - w.earliest = time.Time{} - w.latest = time.Time{} - return snap, earliest, latest -} - -// activeSeries returns the number of distinct series currently buffered. -func (w *windowState) activeSeries() int64 { - w.mu.Lock() - defer w.mu.Unlock() - return int64(len(w.series)) -} - -// encodeAttributesAsKey produces a deterministic canonical string from -// an OTel attribute map. -func encodeAttributesAsKey(attrs pcommon.Map) string { - keys := make([]string, 0, attrs.Len()) - attrs.Range(func(k string, _ pcommon.Value) bool { - keys = append(keys, k) - return true - }) - sort.Strings(keys) - var sb strings.Builder - for _, k := range keys { - v, _ := attrs.Get(k) - sb.WriteString(k) - sb.WriteString("=") - sb.WriteString(v.AsString()) - sb.WriteString(";") - } - return sb.String() -} - -func attributesToMap(attrs pcommon.Map) map[string]string { - m := make(map[string]string, attrs.Len()) - attrs.Range(func(k string, v pcommon.Value) bool { - m[k] = v.AsString() - return true - }) - return m -} diff --git a/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/config.go b/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/config.go new file mode 100644 index 00000000..7d8189bd --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/config.go @@ -0,0 +1,28 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package hllmergeprocessor + +import ( + "go.opentelemetry.io/collector/component" +) + +// Config configures the HyperLogLog merge processor. +// This processor runs on the gateway side and accumulates per-series +// HyperLogLog state from `pmetric.MetricTypeHLLSketch` data points +// produced by the agent-tier `hllprocessor`. The accumulator is keyed +// by the data point's attribute set; downstream consumers read merged +// state via `GetAccumulator(key)`. +type Config struct { + // MetricName is the metric name to watch for HLL sketch payloads. + // Defaults to "hll_sketch" if empty. Producers (hllprocessor) + // emit names like "_hll_cardinality" by default; pipelines + // should override MetricName to match the chosen agent-side suffix. + MetricName string `mapstructure:"metric_name"` +} + +var _ component.Config = (*Config)(nil) + +func (c *Config) Validate() error { + return nil +} diff --git a/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/factory.go b/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/factory.go new file mode 100644 index 00000000..29c8a4fc --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/factory.go @@ -0,0 +1,48 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package hllmergeprocessor + +import ( + "context" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/processor" + "go.opentelemetry.io/collector/processor/processorhelper" + "go.uber.org/zap" +) + +var typeStr = component.MustNewType("hllmerge") + +func NewFactory() processor.Factory { + return processor.NewFactory( + typeStr, + createDefaultConfig, + processor.WithMetrics(createMetricsProcessor, component.StabilityLevelDevelopment), + ) +} + +func createDefaultConfig() component.Config { + return &Config{} +} + +func createMetricsProcessor( + ctx context.Context, + set processor.Settings, + cfg component.Config, + next consumer.Metrics, +) (processor.Metrics, error) { + c := cfg.(*Config) + logger := set.Logger + if logger == nil { + logger = zap.NewNop() + } + p := newProcessor(c, logger, next) + return processorhelper.NewMetrics(ctx, set, cfg, next, + p.processMetrics, + processorhelper.WithStart(p.Start), + processorhelper.WithShutdown(p.Shutdown), + processorhelper.WithCapabilities(p.Capabilities()), + ) +} diff --git a/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/go.mod b/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/go.mod new file mode 100644 index 00000000..1efd0faa --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/go.mod @@ -0,0 +1,41 @@ +module github.com/open-telemetry/opentelemetry-collector-contrib/processor/hllmergeprocessor + +go 1.24.0 + +require ( + github.com/ProjectASAP/sketchlib-go v0.0.0-20260328221809-b24e56e64e94 + go.opentelemetry.io/collector/component v1.47.0 + go.opentelemetry.io/collector/consumer v1.47.0 + go.opentelemetry.io/collector/pdata v1.47.0 + go.opentelemetry.io/collector/processor v1.47.0 + go.opentelemetry.io/collector/processor/processorhelper v0.141.0 + go.uber.org/zap v1.27.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/prometheus v0.307.1 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.opentelemetry.io/collector/featuregate v1.47.0 // indirect + go.opentelemetry.io/collector/pipeline v1.47.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + +replace go.opentelemetry.io/collector/pdata => ../../../opentelemetry-collector/pdata + +replace github.com/ProjectASAP/sketchlib-go => ../../../../sketchlib-go diff --git a/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/go.sum b/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/go.sum new file mode 100644 index 00000000..765fac61 --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/go.sum @@ -0,0 +1,123 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/prometheus v0.307.1 h1:Hh3kRMFn+xpQGLe/bR6qpUfW4GXQO0spuYeY7f2JZs4= +github.com/prometheus/prometheus v0.307.1/go.mod h1:/7YQG/jOLg7ktxGritmdkZvezE1fa6aWDj0MGDIZvcY= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/collector/component v1.47.0 h1:wXvcjNhpWUU4OJph7KyxENkbfnGrfDURa+L/rvPTHyo= +go.opentelemetry.io/collector/component v1.47.0/go.mod h1:Hz9fcIbc7tOA4hIjvW5bb1rJJc2TH0gtQEvDBaZLUUA= +go.opentelemetry.io/collector/component/componentstatus v0.141.0 h1:WoMJdv2ofwHJDXzMP6DvYPqREaqOcGw+gkXG7S+PJvc= +go.opentelemetry.io/collector/component/componentstatus v0.141.0/go.mod h1:upr5QxmYLEZ7PKMCZHImQcp3xNM4VXtZnAKuhhHopg4= +go.opentelemetry.io/collector/component/componenttest v0.141.0 h1:dYdFbm52+e2DwrJ0bEoo7qVOPDuFXl9E/FfaqViIfPU= +go.opentelemetry.io/collector/component/componenttest v0.141.0/go.mod h1:EI7SUBy8Grxso69j2KYf3BYv8rkJjFgxlmWf5ElcWdk= +go.opentelemetry.io/collector/consumer v1.47.0 h1:eriMvNAsityaea361luVfNe8wp6QKWJQoU4d4i3tyOA= +go.opentelemetry.io/collector/consumer v1.47.0/go.mod h1:wBsF8koieun0CK4laZLN2MvGKNqad8gwQa+1jXWWn5k= +go.opentelemetry.io/collector/consumer/consumertest v0.141.0 h1:Q5X7rOI8I5xj35Q1NQiwGJsJ4OZx1n7szw3MbOfNgiM= +go.opentelemetry.io/collector/consumer/consumertest v0.141.0/go.mod h1:yjSSOFx0oBjH2fouw0TTN/U82hYyJPq35ClIZrpz60g= +go.opentelemetry.io/collector/consumer/xconsumer v0.141.0 h1:qR9H8tWo6NtPBDBv3fz8J8QBkqbnaU8vwUvtIO3QeZo= +go.opentelemetry.io/collector/consumer/xconsumer v0.141.0/go.mod h1:Ud55EhQ0cgqDTtnvHQNjtktLGMeefOzF6SFk0bLheOc= +go.opentelemetry.io/collector/featuregate v1.47.0 h1:LuJnDngViDzPKds5QOGxVYNL1QCCVWN/m61lHTV8Pf4= +go.opentelemetry.io/collector/featuregate v1.47.0/go.mod h1:d0tiRzVYrytB6LkcYgz2ESFTv7OktRPQe0QEQcPt1L4= +go.opentelemetry.io/collector/internal/testutil v0.141.0 h1:/rUGApojPtUPMN3rFfApNgEjAt03rCGt2qxNxGGs/4A= +go.opentelemetry.io/collector/internal/testutil v0.141.0/go.mod h1:YAD9EAkwh/l5asZNbEBEUCqEjoL1OKMjAMoPjPqH76c= +go.opentelemetry.io/collector/pdata/pprofile v0.141.0 h1:15lbbHKzPIG4aVT6hsJO7XZLvMrGll+i36es/FEgn7c= +go.opentelemetry.io/collector/pdata/pprofile v0.141.0/go.mod h1:gUtWKniP3O0jXYVDISp1y3dCbYFIyglFw6B8ATyrrWs= +go.opentelemetry.io/collector/pdata/testdata v0.141.0 h1:AfjNbZ/DUSr0aiP4H+z7pqrzTuBQFaT6oca0zaJ3gCA= +go.opentelemetry.io/collector/pdata/testdata v0.141.0/go.mod h1:/KX316ZF30G4eUQadM+SPUqCCPoiAkhMxcvAu4uM72I= +go.opentelemetry.io/collector/pipeline v1.47.0 h1:Ql2cfIopfo/e0Y6r/Fw3mNorKYi8MAoA7zgouzAN8eI= +go.opentelemetry.io/collector/pipeline v1.47.0/go.mod h1:xUrAqiebzYbrgxyoXSkk6/Y3oi5Sy3im2iCA51LwUAI= +go.opentelemetry.io/collector/processor v1.47.0 h1:WA4AP+w+ohFItWx0eG5iGEvLCE70Le5wC2Uw7YVN1Vg= +go.opentelemetry.io/collector/processor v1.47.0/go.mod h1:XaC3o+kNM5wq7ET+FJt+9hTnqqICmruylBpVerb+TZo= +go.opentelemetry.io/collector/processor/processorhelper v0.141.0 h1:4NCArw4JJsJ8YNtbcJXYNOczQ9gon+m1yGV5VPh8Lwk= +go.opentelemetry.io/collector/processor/processorhelper v0.141.0/go.mod h1:idjJbBjKlBmXnhWwiqKG8AYBJmdowNn82F36OhBcMwg= +go.opentelemetry.io/collector/processor/processortest v0.141.0 h1:HY/o+CkKTU2Db96TfugwfMKkRFcaJb2vxPUHluS5/F8= +go.opentelemetry.io/collector/processor/processortest v0.141.0/go.mod h1:n0QKOTH2m2vVbDGdIHvDmIEHU02LOQtuCzzN4BJgK5U= +go.opentelemetry.io/collector/processor/xprocessor v0.141.0 h1:rlvqx4aW7dgrYqWrNTkq1+IDiWOKdX/DDZPxk1DQMVw= +go.opentelemetry.io/collector/processor/xprocessor v0.141.0/go.mod h1:jSSsP1pFgkxN4MvVsyZA1MI5DKhN+kg9Y27Ev0lEwqs= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/slim/otlp v1.9.0 h1:fPVMv8tP3TrsqlkH1HWYUpbCY9cAIemx184VGkS6vlE= +go.opentelemetry.io/proto/slim/otlp v1.9.0/go.mod h1:xXdeJJ90Gqyll+orzUkY4bOd2HECo5JofeoLpymVqdI= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0 h1:o13nadWDNkH/quoDomDUClnQBpdQQ2Qqv0lQBjIXjE8= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0/go.mod h1:Gyb6Xe7FTi/6xBHwMmngGoHqL0w29Y4eW8TGFzpefGA= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0 h1:EiUYvtwu6PMrMHVjcPfnsG3v+ajPkbUeH+IL93+QYyk= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0/go.mod h1:mUUHKFiN2SST3AhJ8XhJxEoeVW12oqfXog0Bo8W3Ec4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 h1:6/3JGEh1C88g7m+qzzTbl3A0FtsLguXieqofVLU/JAo= +golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/processor.go b/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/processor.go new file mode 100644 index 00000000..77b9abf7 --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/processor.go @@ -0,0 +1,163 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package hllmergeprocessor + +import ( + "context" + "sort" + "strings" + "sync" + + hll "github.com/ProjectASAP/sketchlib-go/sketches/HLL" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/pmetric" + "go.uber.org/zap" +) + +type hllMergeProcessor struct { + cfg *Config + logger *zap.Logger + next consumer.Metrics + + mu sync.Mutex + accumulators map[string]*hll.HyperLogLog // keyed by attribute-set fingerprint +} + +func newProcessor(cfg *Config, logger *zap.Logger, next consumer.Metrics) *hllMergeProcessor { + return &hllMergeProcessor{ + cfg: cfg, + logger: logger, + next: next, + accumulators: make(map[string]*hll.HyperLogLog), + } +} + +func (p *hllMergeProcessor) Start(_ context.Context, _ component.Host) error { + return nil +} + +func (p *hllMergeProcessor) Shutdown(_ context.Context) error { + return nil +} + +func (p *hllMergeProcessor) Capabilities() consumer.Capabilities { + // Pass-through: we do not mutate md, only update internal accumulators. + return consumer.Capabilities{MutatesData: false} +} + +func (p *hllMergeProcessor) processMetrics(_ context.Context, md pmetric.Metrics) (pmetric.Metrics, error) { + metricName := p.cfg.MetricName + if metricName == "" { + metricName = "hll_sketch" + } + + rms := md.ResourceMetrics() + for i := 0; i < rms.Len(); i++ { + sms := rms.At(i).ScopeMetrics() + for j := 0; j < sms.Len(); j++ { + metrics := sms.At(j).Metrics() + for k := 0; k < metrics.Len(); k++ { + m := metrics.At(k) + if m.Name() != metricName { + continue + } + if m.Type() != pmetric.MetricTypeHLLSketch { + continue + } + dps := m.HLLSketch().DataPoints() + for l := 0; l < dps.Len(); l++ { + p.mergeDataPoint(dps.At(l)) + } + } + } + } + + return md, nil +} + +func (p *hllMergeProcessor) mergeDataPoint(dp pmetric.HLLSketchDataPoint) { + payload := dp.Sketch() + if len(payload) == 0 { + return + } + key := attrSetKey(dp.Attributes()) + + p.mu.Lock() + defer p.mu.Unlock() + + switch dp.Encoding() { + case pmetric.HLLSketchEncodingProto, pmetric.HLLSketchEncodingUnspecified: + // Full snapshot via sketchlib-go's portable proto encoding. + sketch, err := hll.DeserializeHyperLogLogFromProtoBytes(payload) + if err != nil { + p.logger.Error("hllmergeprocessor: failed to deserialize HLL sketch", zap.Error(err)) + return + } + p.accumulators[key] = sketch + case pmetric.HLLSketchEncodingDelta: + acc, exists := p.accumulators[key] + if !exists { + p.logger.Warn("hllmergeprocessor: received delta for unknown key; dropping", + zap.String("key", key)) + return + } + delta, err := hll.DeserializeRegisterDelta(payload) + if err != nil { + p.logger.Error("hllmergeprocessor: DeserializeRegisterDelta failed", zap.Error(err)) + return + } + hll.ApplyRegisterDelta(acc, delta) + case pmetric.HLLSketchEncodingMsgpack: + sketch, err := hll.DeserializeMsgpack(payload) + if err != nil { + p.logger.Error("hllmergeprocessor: DeserializeMsgpack failed", zap.Error(err)) + return + } + p.accumulators[key] = sketch + default: + // MsgpackDelta is reserved-but-unsupported (see pdata + // hllsketch_encoding.go). Anything else: warn and drop. + p.logger.Warn("hllmergeprocessor: unsupported HLL encoding; dropping data point", + zap.String("encoding", dp.Encoding().String()), + zap.String("key", key)) + } +} + +// GetAccumulator returns the current accumulated sketch for the given +// attribute-set key. +func (p *hllMergeProcessor) GetAccumulator(key string) (*hll.HyperLogLog, bool) { + p.mu.Lock() + defer p.mu.Unlock() + acc, ok := p.accumulators[key] + return acc, ok +} + +// attrSetKey produces a stable string key for a pdata attribute map by +// sorting attribute names and joining `name=value` pairs. +func attrSetKey(attrs pcommon.Map) string { + if attrs.Len() == 0 { + return "" + } + keys := make([]string, 0, attrs.Len()) + values := make(map[string]string, attrs.Len()) + attrs.Range(func(k string, v pcommon.Value) bool { + keys = append(keys, k) + values[k] = v.AsString() + return true + }) + sort.Strings(keys) + var b strings.Builder + for i, k := range keys { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(k) + b.WriteByte('=') + b.WriteString(values[k]) + } + return b.String() +} diff --git a/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/processor_test.go b/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/processor_test.go new file mode 100644 index 00000000..98b9aba8 --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/hllmergeprocessor/processor_test.go @@ -0,0 +1,167 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package hllmergeprocessor + +import ( + "context" + "testing" + + hll "github.com/ProjectASAP/sketchlib-go/sketches/HLL" + + "go.opentelemetry.io/collector/pdata/pmetric" + "go.uber.org/zap" +) + +// buildHLL creates a HyperLogLog sketch and inserts a deterministic set of +// "hash" values. These are not real hashes — InsertWithHash treats them as +// pre-hashed 64-bit values, which is sufficient for testing register state +// round-trips. +func buildHLL(_ *testing.T, hashes []uint64) *hll.HyperLogLog { + h := hll.New() + for _, x := range hashes { + h.InsertWithHash(x) + } + return h +} + +func protoBytes(t *testing.T, h *hll.HyperLogLog) []byte { + t.Helper() + b, err := h.SerializeProtoBytes() + if err != nil { + t.Fatalf("SerializeProtoBytes: %v", err) + } + return b +} + +func newHLLMetricsBatch(metricName, attrKey, attrVal string, payload []byte, encoding pmetric.HLLSketchEncoding) pmetric.Metrics { + md := pmetric.NewMetrics() + rm := md.ResourceMetrics().AppendEmpty() + sm := rm.ScopeMetrics().AppendEmpty() + m := sm.Metrics().AppendEmpty() + m.SetName(metricName) + hm := m.SetEmptyHLLSketch() + dp := hm.DataPoints().AppendEmpty() + dp.Attributes().PutStr(attrKey, attrVal) + dp.SetSketch(payload) + dp.SetEncoding(encoding) + return md +} + +// fixedHashes returns a deterministic set of pseudo-hashes that exercise +// many distinct registers (high-bit variation drives the register index). +func fixedHashes(n int) []uint64 { + out := make([]uint64, n) + for i := 0; i < n; i++ { + // Simple xorshift-style spread. + v := uint64(i+1) * 0x9E3779B97F4A7C15 + v ^= v >> 33 + out[i] = v + } + return out +} + +func TestHLLMergeProcessor_ProtoIngest(t *testing.T) { + cfg := &Config{} + p := newProcessor(cfg, zap.NewNop(), nil) + + src := buildHLL(t, fixedHashes(200)) + expEst := src.Estimate() + + md := newHLLMetricsBatch("hll_sketch", "service", "checkout", + protoBytes(t, src), pmetric.HLLSketchEncodingProto) + out, err := p.processMetrics(context.Background(), md) + if err != nil { + t.Fatalf("processMetrics: %v", err) + } + if got := out.MetricCount(); got != 1 { + t.Fatalf("MetricCount mismatch: got %d, want 1", got) + } + + acc, ok := p.GetAccumulator("service=checkout") + if !ok { + t.Fatal("accumulator missing") + } + if got := acc.Estimate(); got != expEst { + t.Fatalf("accumulator Estimate = %d, want %d", got, expEst) + } +} + +func TestHLLMergeProcessor_DeltaMerge(t *testing.T) { + cfg := &Config{} + p := newProcessor(cfg, zap.NewNop(), nil) + + // Step 1: ingest a snapshot. + snap := buildHLL(t, fixedHashes(100)) + if _, err := p.processMetrics(context.Background(), + newHLLMetricsBatch("hll_sketch", "service", "checkout", + protoBytes(t, snap), pmetric.HLLSketchEncodingProto)); err != nil { + t.Fatal(err) + } + + // Step 2: build a "current" sketch with extra inserts. + current := buildHLL(t, fixedHashes(300)) + delta := hll.ComputeRegisterDelta(snap, current) + deltaBytes, err := hll.SerializeRegisterDelta(delta) + if err != nil { + t.Fatalf("SerializeRegisterDelta: %v", err) + } + expEst := current.Estimate() + + if _, err := p.processMetrics(context.Background(), + newHLLMetricsBatch("hll_sketch", "service", "checkout", + deltaBytes, pmetric.HLLSketchEncodingDelta)); err != nil { + t.Fatal(err) + } + + acc, ok := p.GetAccumulator("service=checkout") + if !ok { + t.Fatal("accumulator missing") + } + if got := acc.Estimate(); got != expEst { + t.Fatalf("after delta merge Estimate = %d, want %d", got, expEst) + } +} + +func TestHLLMergeProcessor_DeltaWithoutSnapshotDropped(t *testing.T) { + cfg := &Config{} + p := newProcessor(cfg, zap.NewNop(), nil) + + snap := buildHLL(t, fixedHashes(50)) + current := buildHLL(t, fixedHashes(100)) + delta := hll.ComputeRegisterDelta(snap, current) + deltaBytes, err := hll.SerializeRegisterDelta(delta) + if err != nil { + t.Fatalf("SerializeRegisterDelta: %v", err) + } + + if _, err := p.processMetrics(context.Background(), + newHLLMetricsBatch("hll_sketch", "service", "checkout", + deltaBytes, pmetric.HLLSketchEncodingDelta)); err != nil { + t.Fatal(err) + } + if _, ok := p.GetAccumulator("service=checkout"); ok { + t.Fatal("accumulator should not exist after orphan delta") + } +} + +func TestHLLMergeProcessor_MetricNameFilter(t *testing.T) { + cfg := &Config{MetricName: "request_uniques_hll_cardinality"} + p := newProcessor(cfg, zap.NewNop(), nil) + + payload := protoBytes(t, buildHLL(t, fixedHashes(50))) + if _, err := p.processMetrics(context.Background(), + newHLLMetricsBatch("hll_sketch", "service", "checkout", payload, pmetric.HLLSketchEncodingProto)); err != nil { + t.Fatal(err) + } + if _, ok := p.GetAccumulator("service=checkout"); ok { + t.Fatal("accumulator should not exist for unmatched metric name") + } + if _, err := p.processMetrics(context.Background(), + newHLLMetricsBatch("request_uniques_hll_cardinality", "service", "checkout", payload, pmetric.HLLSketchEncodingProto)); err != nil { + t.Fatal(err) + } + if _, ok := p.GetAccumulator("service=checkout"); !ok { + t.Fatal("accumulator should exist for matched metric name") + } +} diff --git a/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/config.go b/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/config.go new file mode 100644 index 00000000..2f3d3df1 --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/config.go @@ -0,0 +1,28 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package kllmergeprocessor + +import ( + "go.opentelemetry.io/collector/component" +) + +// Config configures the KLL sketch merge processor. +// This processor runs on the gateway side and accumulates per-series KLL +// sketches from `pmetric.MetricTypeKLLSketch` data points produced by the +// agent-tier `kllprocessor`. The accumulator is keyed by the data point's +// attribute set; downstream consumers read merged state via +// `GetAccumulator(key)`. +type Config struct { + // MetricName is the metric name to watch for KLL sketch payloads. + // Defaults to "kll_sketch" if empty. Producers (kllprocessor) emit + // names like "_kll" by default; pipelines should override + // MetricName here to match the chosen agent-side suffix. + MetricName string `mapstructure:"metric_name"` +} + +var _ component.Config = (*Config)(nil) + +func (c *Config) Validate() error { + return nil +} diff --git a/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/factory.go b/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/factory.go new file mode 100644 index 00000000..706b66c9 --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/factory.go @@ -0,0 +1,48 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package kllmergeprocessor + +import ( + "context" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/processor" + "go.opentelemetry.io/collector/processor/processorhelper" + "go.uber.org/zap" +) + +var typeStr = component.MustNewType("kllmerge") + +func NewFactory() processor.Factory { + return processor.NewFactory( + typeStr, + createDefaultConfig, + processor.WithMetrics(createMetricsProcessor, component.StabilityLevelDevelopment), + ) +} + +func createDefaultConfig() component.Config { + return &Config{} +} + +func createMetricsProcessor( + ctx context.Context, + set processor.Settings, + cfg component.Config, + next consumer.Metrics, +) (processor.Metrics, error) { + c := cfg.(*Config) + logger := set.Logger + if logger == nil { + logger = zap.NewNop() + } + p := newProcessor(c, logger, next) + return processorhelper.NewMetrics(ctx, set, cfg, next, + p.processMetrics, + processorhelper.WithStart(p.Start), + processorhelper.WithShutdown(p.Shutdown), + processorhelper.WithCapabilities(p.Capabilities()), + ) +} diff --git a/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/go.mod b/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/go.mod new file mode 100644 index 00000000..fceeec94 --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/go.mod @@ -0,0 +1,41 @@ +module github.com/open-telemetry/opentelemetry-collector-contrib/processor/kllmergeprocessor + +go 1.24.0 + +require ( + github.com/ProjectASAP/sketchlib-go v0.0.0-20260328221809-b24e56e64e94 + go.opentelemetry.io/collector/component v1.47.0 + go.opentelemetry.io/collector/consumer v1.47.0 + go.opentelemetry.io/collector/pdata v1.47.0 + go.opentelemetry.io/collector/processor v1.47.0 + go.opentelemetry.io/collector/processor/processorhelper v0.141.0 + go.uber.org/zap v1.27.1 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/prometheus v0.307.1 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.opentelemetry.io/collector/featuregate v1.47.0 // indirect + go.opentelemetry.io/collector/pipeline v1.47.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + +replace go.opentelemetry.io/collector/pdata => ../../../opentelemetry-collector/pdata + +replace github.com/ProjectASAP/sketchlib-go => ../../../../sketchlib-go diff --git a/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/go.sum b/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/go.sum new file mode 100644 index 00000000..765fac61 --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/go.sum @@ -0,0 +1,123 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM= +github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/prometheus v0.307.1 h1:Hh3kRMFn+xpQGLe/bR6qpUfW4GXQO0spuYeY7f2JZs4= +github.com/prometheus/prometheus v0.307.1/go.mod h1:/7YQG/jOLg7ktxGritmdkZvezE1fa6aWDj0MGDIZvcY= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/collector/component v1.47.0 h1:wXvcjNhpWUU4OJph7KyxENkbfnGrfDURa+L/rvPTHyo= +go.opentelemetry.io/collector/component v1.47.0/go.mod h1:Hz9fcIbc7tOA4hIjvW5bb1rJJc2TH0gtQEvDBaZLUUA= +go.opentelemetry.io/collector/component/componentstatus v0.141.0 h1:WoMJdv2ofwHJDXzMP6DvYPqREaqOcGw+gkXG7S+PJvc= +go.opentelemetry.io/collector/component/componentstatus v0.141.0/go.mod h1:upr5QxmYLEZ7PKMCZHImQcp3xNM4VXtZnAKuhhHopg4= +go.opentelemetry.io/collector/component/componenttest v0.141.0 h1:dYdFbm52+e2DwrJ0bEoo7qVOPDuFXl9E/FfaqViIfPU= +go.opentelemetry.io/collector/component/componenttest v0.141.0/go.mod h1:EI7SUBy8Grxso69j2KYf3BYv8rkJjFgxlmWf5ElcWdk= +go.opentelemetry.io/collector/consumer v1.47.0 h1:eriMvNAsityaea361luVfNe8wp6QKWJQoU4d4i3tyOA= +go.opentelemetry.io/collector/consumer v1.47.0/go.mod h1:wBsF8koieun0CK4laZLN2MvGKNqad8gwQa+1jXWWn5k= +go.opentelemetry.io/collector/consumer/consumertest v0.141.0 h1:Q5X7rOI8I5xj35Q1NQiwGJsJ4OZx1n7szw3MbOfNgiM= +go.opentelemetry.io/collector/consumer/consumertest v0.141.0/go.mod h1:yjSSOFx0oBjH2fouw0TTN/U82hYyJPq35ClIZrpz60g= +go.opentelemetry.io/collector/consumer/xconsumer v0.141.0 h1:qR9H8tWo6NtPBDBv3fz8J8QBkqbnaU8vwUvtIO3QeZo= +go.opentelemetry.io/collector/consumer/xconsumer v0.141.0/go.mod h1:Ud55EhQ0cgqDTtnvHQNjtktLGMeefOzF6SFk0bLheOc= +go.opentelemetry.io/collector/featuregate v1.47.0 h1:LuJnDngViDzPKds5QOGxVYNL1QCCVWN/m61lHTV8Pf4= +go.opentelemetry.io/collector/featuregate v1.47.0/go.mod h1:d0tiRzVYrytB6LkcYgz2ESFTv7OktRPQe0QEQcPt1L4= +go.opentelemetry.io/collector/internal/testutil v0.141.0 h1:/rUGApojPtUPMN3rFfApNgEjAt03rCGt2qxNxGGs/4A= +go.opentelemetry.io/collector/internal/testutil v0.141.0/go.mod h1:YAD9EAkwh/l5asZNbEBEUCqEjoL1OKMjAMoPjPqH76c= +go.opentelemetry.io/collector/pdata/pprofile v0.141.0 h1:15lbbHKzPIG4aVT6hsJO7XZLvMrGll+i36es/FEgn7c= +go.opentelemetry.io/collector/pdata/pprofile v0.141.0/go.mod h1:gUtWKniP3O0jXYVDISp1y3dCbYFIyglFw6B8ATyrrWs= +go.opentelemetry.io/collector/pdata/testdata v0.141.0 h1:AfjNbZ/DUSr0aiP4H+z7pqrzTuBQFaT6oca0zaJ3gCA= +go.opentelemetry.io/collector/pdata/testdata v0.141.0/go.mod h1:/KX316ZF30G4eUQadM+SPUqCCPoiAkhMxcvAu4uM72I= +go.opentelemetry.io/collector/pipeline v1.47.0 h1:Ql2cfIopfo/e0Y6r/Fw3mNorKYi8MAoA7zgouzAN8eI= +go.opentelemetry.io/collector/pipeline v1.47.0/go.mod h1:xUrAqiebzYbrgxyoXSkk6/Y3oi5Sy3im2iCA51LwUAI= +go.opentelemetry.io/collector/processor v1.47.0 h1:WA4AP+w+ohFItWx0eG5iGEvLCE70Le5wC2Uw7YVN1Vg= +go.opentelemetry.io/collector/processor v1.47.0/go.mod h1:XaC3o+kNM5wq7ET+FJt+9hTnqqICmruylBpVerb+TZo= +go.opentelemetry.io/collector/processor/processorhelper v0.141.0 h1:4NCArw4JJsJ8YNtbcJXYNOczQ9gon+m1yGV5VPh8Lwk= +go.opentelemetry.io/collector/processor/processorhelper v0.141.0/go.mod h1:idjJbBjKlBmXnhWwiqKG8AYBJmdowNn82F36OhBcMwg= +go.opentelemetry.io/collector/processor/processortest v0.141.0 h1:HY/o+CkKTU2Db96TfugwfMKkRFcaJb2vxPUHluS5/F8= +go.opentelemetry.io/collector/processor/processortest v0.141.0/go.mod h1:n0QKOTH2m2vVbDGdIHvDmIEHU02LOQtuCzzN4BJgK5U= +go.opentelemetry.io/collector/processor/xprocessor v0.141.0 h1:rlvqx4aW7dgrYqWrNTkq1+IDiWOKdX/DDZPxk1DQMVw= +go.opentelemetry.io/collector/processor/xprocessor v0.141.0/go.mod h1:jSSsP1pFgkxN4MvVsyZA1MI5DKhN+kg9Y27Ev0lEwqs= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/slim/otlp v1.9.0 h1:fPVMv8tP3TrsqlkH1HWYUpbCY9cAIemx184VGkS6vlE= +go.opentelemetry.io/proto/slim/otlp v1.9.0/go.mod h1:xXdeJJ90Gqyll+orzUkY4bOd2HECo5JofeoLpymVqdI= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0 h1:o13nadWDNkH/quoDomDUClnQBpdQQ2Qqv0lQBjIXjE8= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0/go.mod h1:Gyb6Xe7FTi/6xBHwMmngGoHqL0w29Y4eW8TGFzpefGA= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0 h1:EiUYvtwu6PMrMHVjcPfnsG3v+ajPkbUeH+IL93+QYyk= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.2.0/go.mod h1:mUUHKFiN2SST3AhJ8XhJxEoeVW12oqfXog0Bo8W3Ec4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 h1:6/3JGEh1C88g7m+qzzTbl3A0FtsLguXieqofVLU/JAo= +golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/processor.go b/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/processor.go new file mode 100644 index 00000000..d7a1cf9d --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/processor.go @@ -0,0 +1,146 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package kllmergeprocessor + +import ( + "context" + "sort" + "strings" + "sync" + + kll "github.com/ProjectASAP/sketchlib-go/sketches/KLL" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/pmetric" + "go.uber.org/zap" +) + +type kllMergeProcessor struct { + cfg *Config + logger *zap.Logger + next consumer.Metrics + + mu sync.Mutex + accumulators map[string]*kll.KLLSketch // keyed by attribute-set fingerprint +} + +func newProcessor(cfg *Config, logger *zap.Logger, next consumer.Metrics) *kllMergeProcessor { + return &kllMergeProcessor{ + cfg: cfg, + logger: logger, + next: next, + accumulators: make(map[string]*kll.KLLSketch), + } +} + +func (p *kllMergeProcessor) Start(_ context.Context, _ component.Host) error { + return nil +} + +func (p *kllMergeProcessor) Shutdown(_ context.Context) error { + return nil +} + +func (p *kllMergeProcessor) Capabilities() consumer.Capabilities { + // Pass-through: we do not mutate md, only update internal accumulators. + return consumer.Capabilities{MutatesData: false} +} + +func (p *kllMergeProcessor) processMetrics(_ context.Context, md pmetric.Metrics) (pmetric.Metrics, error) { + metricName := p.cfg.MetricName + if metricName == "" { + metricName = "kll_sketch" + } + + rms := md.ResourceMetrics() + for i := 0; i < rms.Len(); i++ { + sms := rms.At(i).ScopeMetrics() + for j := 0; j < sms.Len(); j++ { + metrics := sms.At(j).Metrics() + for k := 0; k < metrics.Len(); k++ { + m := metrics.At(k) + if m.Name() != metricName { + continue + } + if m.Type() != pmetric.MetricTypeKLLSketch { + continue + } + dps := m.KLLSketch().DataPoints() + for l := 0; l < dps.Len(); l++ { + p.mergeDataPoint(dps.At(l)) + } + } + } + } + + return md, nil +} + +func (p *kllMergeProcessor) mergeDataPoint(dp pmetric.KLLSketchDataPoint) { + payload := dp.Sketch() + if len(payload) == 0 { + return + } + key := attrSetKey(dp.Attributes()) + + p.mu.Lock() + defer p.mu.Unlock() + + switch dp.Encoding() { + case pmetric.KLLSketchEncodingProto, pmetric.KLLSketchEncodingUnspecified: + // Full snapshot: deserialize and replace. + sketch, err := kll.DeserializeKLLSketchFromProtoBytes(payload) + if err != nil { + p.logger.Error("kllmergeprocessor: failed to deserialize KLL sketch", zap.Error(err)) + return + } + p.accumulators[key] = sketch + default: + // KLL has no shared msgpack/delta wire format end-to-end (see + // pdata kllsketch_encoding.go); treat anything else as a + // configuration mismatch and warn. + p.logger.Warn("kllmergeprocessor: unsupported KLL encoding; dropping data point", + zap.String("encoding", dp.Encoding().String()), + zap.String("key", key)) + } +} + +// GetAccumulator returns the current accumulated sketch for the given +// attribute-set key. +func (p *kllMergeProcessor) GetAccumulator(key string) (*kll.KLLSketch, bool) { + p.mu.Lock() + defer p.mu.Unlock() + acc, ok := p.accumulators[key] + return acc, ok +} + +// attrSetKey produces a stable string key for a pdata attribute map by +// sorting attribute names and joining `name=value` pairs. The exact +// format is private to this processor — it is only used as a map key +// and round-trip serialization is not required. +func attrSetKey(attrs pcommon.Map) string { + if attrs.Len() == 0 { + return "" + } + keys := make([]string, 0, attrs.Len()) + values := make(map[string]string, attrs.Len()) + attrs.Range(func(k string, v pcommon.Value) bool { + keys = append(keys, k) + values[k] = v.AsString() + return true + }) + sort.Strings(keys) + var b strings.Builder + for i, k := range keys { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(k) + b.WriteByte('=') + b.WriteString(values[k]) + } + return b.String() +} diff --git a/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/processor_test.go b/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/processor_test.go new file mode 100644 index 00000000..04f1df4c --- /dev/null +++ b/opentelemetry-collector-contrib-patch/processor/kllmergeprocessor/processor_test.go @@ -0,0 +1,137 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package kllmergeprocessor + +import ( + "context" + "testing" + + kll "github.com/ProjectASAP/sketchlib-go/sketches/KLL" + + "go.opentelemetry.io/collector/pdata/pmetric" + "go.uber.org/zap" +) + +// buildKLLPayload constructs a real KLL sketch with a fixed sequence of +// inserts and returns the proto-serialized payload along with a few +// sample queries the test can verify against the merged accumulator. +func buildKLLPayload(t *testing.T, values []float64) []byte { + t.Helper() + s := kll.New() + for _, v := range values { + s.Update(v) + } + payload, err := s.SerializeProtoBytes() + if err != nil { + t.Fatalf("SerializeProtoBytes: %v", err) + } + return payload +} + +func newKLLMetricsBatch(metricName, attrKey, attrVal string, payload []byte, encoding pmetric.KLLSketchEncoding) pmetric.Metrics { + md := pmetric.NewMetrics() + rm := md.ResourceMetrics().AppendEmpty() + sm := rm.ScopeMetrics().AppendEmpty() + m := sm.Metrics().AppendEmpty() + m.SetName(metricName) + kllMetric := m.SetEmptyKLLSketch() + dp := kllMetric.DataPoints().AppendEmpty() + dp.Attributes().PutStr(attrKey, attrVal) + dp.SetSketch(payload) + dp.SetEncoding(encoding) + return md +} + +func TestKLLMergeProcessor_ProtoIngest(t *testing.T) { + cfg := &Config{} + p := newProcessor(cfg, zap.NewNop(), nil) + + payload := buildKLLPayload(t, []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}) + md := newKLLMetricsBatch("kll_sketch", "service", "checkout", payload, pmetric.KLLSketchEncodingProto) + + out, err := p.processMetrics(context.Background(), md) + if err != nil { + t.Fatalf("processMetrics: %v", err) + } + // Pass-through: same number of metrics, same name. + if got := out.MetricCount(); got != 1 { + t.Fatalf("MetricCount mismatch: got %d, want 1", got) + } + + // Accumulator should be populated under the synthesized key. + acc, ok := p.GetAccumulator("service=checkout") + if !ok { + t.Fatal("accumulator missing for key 'service=checkout'") + } + if got := acc.Count(); got != 10 { + t.Fatalf("accumulator Count = %d, want 10", got) + } +} + +func TestKLLMergeProcessor_SecondBatchReplaces(t *testing.T) { + cfg := &Config{MetricName: "kll_sketch"} + p := newProcessor(cfg, zap.NewNop(), nil) + + first := buildKLLPayload(t, []float64{1, 2, 3, 4, 5}) + if _, err := p.processMetrics(context.Background(), + newKLLMetricsBatch("kll_sketch", "service", "checkout", first, pmetric.KLLSketchEncodingProto)); err != nil { + t.Fatal(err) + } + + // Send a second batch that contains a strictly larger snapshot; + // because Proto encoding is a full snapshot, the accumulator + // should be *replaced* rather than merged. + second := buildKLLPayload(t, []float64{10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120}) + if _, err := p.processMetrics(context.Background(), + newKLLMetricsBatch("kll_sketch", "service", "checkout", second, pmetric.KLLSketchEncodingProto)); err != nil { + t.Fatal(err) + } + + acc, ok := p.GetAccumulator("service=checkout") + if !ok { + t.Fatal("accumulator missing") + } + if got := acc.Count(); got != 12 { + t.Fatalf("after second batch Count = %d, want 12 (replaced)", got) + } +} + +func TestKLLMergeProcessor_MetricNameFilter(t *testing.T) { + cfg := &Config{MetricName: "request_latency_kll"} + p := newProcessor(cfg, zap.NewNop(), nil) + + payload := buildKLLPayload(t, []float64{1, 2, 3}) + // Wrong metric name → ignored. + if _, err := p.processMetrics(context.Background(), + newKLLMetricsBatch("kll_sketch", "service", "checkout", payload, pmetric.KLLSketchEncodingProto)); err != nil { + t.Fatal(err) + } + if _, ok := p.GetAccumulator("service=checkout"); ok { + t.Fatal("accumulator should not exist for unmatched metric name") + } + + // Correct metric name → ingested. + if _, err := p.processMetrics(context.Background(), + newKLLMetricsBatch("request_latency_kll", "service", "checkout", payload, pmetric.KLLSketchEncodingProto)); err != nil { + t.Fatal(err) + } + if _, ok := p.GetAccumulator("service=checkout"); !ok { + t.Fatal("accumulator should exist for matched metric name") + } +} + +func TestKLLMergeProcessor_UnsupportedEncodingDropped(t *testing.T) { + cfg := &Config{} + p := newProcessor(cfg, zap.NewNop(), nil) + + payload := buildKLLPayload(t, []float64{1, 2, 3}) + // Msgpack encoding is reserved-but-unsupported for KLL. + if _, err := p.processMetrics(context.Background(), + newKLLMetricsBatch("kll_sketch", "service", "checkout", payload, pmetric.KLLSketchEncodingMsgpack)); err != nil { + t.Fatal(err) + } + if _, ok := p.GetAccumulator("service=checkout"); ok { + t.Fatal("accumulator should not exist after unsupported encoding") + } +}