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")
+ }
+}