@cryptotaxi247 / netdata-1 / commits / a7c78eae8

Replace otel-plugin metrics pipeline with proper aggregation. (#21771)

* Replace otel-plugin metrics pipeline with proper aggregation Replace the old metrics pipeline, which tried to auto-detect collection intervals, with a new implementation that uses configurable fixed-interval slot-based aggregation. The new pipeline properly handles all OTel metric types, except for exponential histograms, and their aggregation temporalities. Charts are emitted periodically via a tick loop and automatically removed after an expiry period with no data. Timing defaults (interval, grace period, expiry) are configured in otel.yaml as the single source of truth, with per-metric overrides available in the mapping files. The plugin validates timing constraints, enforces Netdata's update_every bounds, limits new chart creation per request to protect against cardinality explosion, and gracefully falls back to stock configuration on errors. The logs pipeline is unchanged. A lot of cleanup needs to happen, but the core aggregation logic implementation is solid and not expected to change. The most important remaining optimizations are around lock-contention between the ticker task vs. the service handler, and duplicate lookups/allocations. However, these are not correctness issues and we can deal with them later on. * Update src/crates/Cargo.toml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Do not use `nth()` in iterators. * Improve otel plugin integration documentation Update metadata.yaml with clearer overview descriptions, accurate config paths, expanded chart_configs_dir documentation, and a metric mapping file example. Regenerate opentelemetry.md. Add src/crates/**/metadata.yaml to the generate-integrations workflow so future metadata changes are picked up automatically. * Include data/aggregation kinds in metric identity. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

vkalintiris committed Feb 18, 2026 at 14:52 UTC a7c78eae8af4f97fb1a56f3a230c8f237e427af4
24 files changed +4644 -1194
.github/workflows/generate-integrations.yml
+1
@@ -7,6 +7,7 @@ on:
7 - master
8 paths: # If any of these files change, we need to regenerate integrations.js.
9 - 'src/collectors/**/metadata.yaml'
10 + - 'src/crates/**/metadata.yaml'
11 - 'src/go/plugin/**/metadata.yaml'
12 - 'src/exporting/**/metadata.yaml'
13 - 'src/health/notifications/**/metadata.yaml'
src/crates/Cargo.lock
+3 -22
@@ -194,17 +194,6 @@ version = "1.1.2"
194 source = "registry+https://github.com/rust-lang/crates.io-index"
195 checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
196
197 -[[package]]
198 -name = "atty"
199 -version = "0.2.14"
200 -source = "registry+https://github.com/rust-lang/crates.io-index"
201 -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
202 -dependencies = [
203 - "hermit-abi 0.1.19",
204 - "libc",
205 - "winapi",
206 -]
207 -
197 [[package]]
198 name = "autocfg"
199 version = "1.5.0"
@@ -810,6 +799,7 @@ dependencies = [
799 name = "flatten_otel"
800 version = "0.1.3"
801 dependencies = [
802 + "base64 0.22.1",
803 "flatten-serde-json",
804 "opentelemetry-proto",
805 "serde_json",
@@ -1189,15 +1179,6 @@ version = "0.5.0"
1179 source = "registry+https://github.com/rust-lang/crates.io-index"
1180 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
1181
1192 -[[package]]
1193 -name = "hermit-abi"
1194 -version = "0.1.19"
1195 -source = "registry+https://github.com/rust-lang/crates.io-index"
1196 -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
1197 -dependencies = [
1198 - "libc",
1199 -]
1200 -
1182 [[package]]
1183 name = "hermit-abi"
1184 version = "0.5.2"
@@ -2120,7 +2101,7 @@ version = "1.17.0"
2101 source = "registry+https://github.com/rust-lang/crates.io-index"
2102 checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
2103 dependencies = [
2123 - "hermit-abi 0.5.2",
2104 + "hermit-abi",
2105 "libc",
2106 ]
2107
@@ -2221,7 +2202,6 @@ name = "otel-plugin"
2202 version = "0.1.3"
2203 dependencies = [
2204 "anyhow",
2224 - "atty",
2205 "bytesize",
2206 "bytesize-serde",
2207 "clap",
@@ -2245,6 +2225,7 @@ dependencies = [
2225 "tokio",
2226 "tonic 0.14.2",
2227 "tracing",
2228 + "twox-hash",
2229 ]
2230
2231 [[package]]
src/crates/Cargo.toml
+2
@@ -110,6 +110,8 @@ hdrhistogram = { version = "7.5" }
110 rayon = "1.11.0"
111 itoa = "1.0"
112
113 +base64 = "0.22"
114 +
115 # OTEL-specific dependencies
116 atty = "0.2"
117 bytesize = "1.3"
src/crates/netdata-otel/flatten_otel/Cargo.toml
+1
@@ -8,6 +8,7 @@ rust-version.workspace = true
8 workspace = true
9
10 [dependencies]
11 +base64 = { workspace = true }
12 opentelemetry-proto = { workspace = true, features = ["logs", "metrics", "with-serde"] }
13 flatten-serde-json = { workspace = true }
14 serde_json = { workspace = true }
src/crates/netdata-otel/flatten_otel/src/lib.rs
+2 -3
@@ -1,3 +1,4 @@
1 +use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
2 use serde_json::{Map as JsonMap, Value as JsonValue};
3
4 use opentelemetry_proto::tonic::{
@@ -42,9 +43,7 @@ fn json_from_any_value(any_value: &AnyValue) -> JsonValue {
43 JsonValue::Array(values)
44 }
45 Some(Value::KvlistValue(kvl)) => JsonValue::Object(json_from_key_value_list(&kvl.values)),
45 - Some(Value::BytesValue(_bytes)) => {
46 - todo!("Add support for byte values");
47 - }
46 + Some(Value::BytesValue(bytes)) => JsonValue::String(BASE64.encode(bytes)),
47 None => JsonValue::Null,
48 }
49 }
src/crates/netdata-otel/otel-plugin/Cargo.toml
+2 -2
@@ -17,7 +17,6 @@ path = "src/main.rs"
17
18 [dependencies]
19 anyhow = { workspace = true }
20 -atty = { workspace = true }
20 bytesize-serde = { workspace = true }
21 bytesize = { workspace = true }
22 clap = { workspace = true, features = ["derive"] }
@@ -26,10 +25,11 @@ humantime = { workspace = true }
25 regex = { workspace = true }
26 serde_json = { workspace = true, features = ["preserve_order"] }
27 serde_regex = { workspace = true }
29 -serde = { workspace=true }
28 +serde = { workspace=true, features = ["rc"] }
29 serde_yaml = { workspace = true }
30 tokio = { workspace = true }
31 tonic = { workspace = true, features = ["gzip", "tls-ring"] }
32 +twox-hash = { workspace = true, features = ["xxhash64"] }
33
34 journal-common = { workspace = true }
35 journal-core = { workspace = true }
src/crates/netdata-otel/otel-plugin/configs/otel.d/v1/metrics/hostmetrics-receiver.yaml
+150 -172
@@ -1,172 +1,150 @@
1 -configs:
2 - - select:
3 - instrumentation_scope_name: .*hostmetricsreceiver.*networkscraper$
4 - metric_name: system\.network\.connections
5 - extract:
6 - chart_instance_pattern: metric.attributes.protocol
7 - dimension_name: metric.attributes.state
8 - - select:
9 - instrumentation_scope_name: .*hostmetricsreceiver.*networkscraper$
10 - metric_name: system\.network\.dropped
11 - extract:
12 - chart_instance_pattern: metric.attributes.device
13 - dimension_name: metric.attributes.direction
14 - - select:
15 - instrumentation_scope_name: .*hostmetricsreceiver.*networkscraper$
16 - metric_name: system\.network\.errors
17 - extract:
18 - chart_instance_pattern: metric.attributes.device
19 - dimension_name: metric.attributes.direction
20 - - select:
21 - instrumentation_scope_name: .*hostmetricsreceiver.*networkscraper$
22 - metric_name: system\.network\.io
23 - extract:
24 - chart_instance_pattern: metric.attributes.device
25 - dimension_name: metric.attributes.direction
26 - - select:
27 - instrumentation_scope_name: .*hostmetricsreceiver.*networkscraper$
28 - metric_name: system\.network\.packets
29 - extract:
30 - chart_instance_pattern: metric.attributes.device
31 - dimension_name: metric.attributes.direction
32 - - select:
33 - instrumentation_scope_name: .*hostmetricsreceiver.*cpuscraper$
34 - metric_name: system\.cpu\.time
35 - extract:
36 - chart_instance_pattern: metric.attributes.cpu
37 - dimension_name: metric.attributes.state
38 - - select:
39 - instrumentation_scope_name: .*hostmetricsreceiver.*cpuscraper$
40 - metric_name: system\.cpu\.frequency
41 - extract:
42 - chart_instance_pattern: metric.attributes.cpu
43 - - select:
44 - instrumentation_scope_name: .*hostmetricsreceiver.*cpuscraper$
45 - metric_name: system\.cpu\.utilization
46 - extract:
47 - chart_instance_pattern: metric.attributes.cpu
48 - dimension_name: metric.attributes.state
49 - - select:
50 - instrumentation_scope_name: .*hostmetricsreceiver.*diskscraper$
51 - metric_name: system\.disk\.io$
52 - extract:
53 - chart_instance_pattern: metric.attributes.device
54 - dimension_name: metric.attributes.direction
55 - - select:
56 - instrumentation_scope_name: .*hostmetricsreceiver.*diskscraper$
57 - metric_name: system\.disk\.io_time
58 - extract:
59 - chart_instance_pattern: metric.attributes.device
60 - - select:
61 - instrumentation_scope_name: .*hostmetricsreceiver.*diskscraper$
62 - metric_name: system\.disk\.merged
63 - extract:
64 - chart_instance_pattern: metric.attributes.device
65 - dimension_name: metric.attributes.direction
66 - - select:
67 - instrumentation_scope_name: .*hostmetricsreceiver.*diskscraper$
68 - metric_name: system\.disk\.operation_time
69 - extract:
70 - chart_instance_pattern: metric.attributes.device
71 - dimension_name: metric.attributes.direction
72 - - select:
73 - instrumentation_scope_name: .*hostmetricsreceiver.*diskscraper$
74 - metric_name: system\.disk\.operations
75 - extract:
76 - chart_instance_pattern: metric.attributes.device
77 - dimension_name: metric.attributes.direction
78 - - select:
79 - instrumentation_scope_name: .*hostmetricsreceiver.*diskscraper$
80 - metric_name: system\.disk\.pending_operations
81 - extract:
82 - chart_instance_pattern: metric.attributes.device
83 - - select:
84 - instrumentation_scope_name: .*hostmetricsreceiver.*diskscraper$
85 - metric_name: system\.disk\.weighted_io
86 - extract:
87 - chart_instance_pattern: metric.attributes.device
88 - - select:
89 - instrumentation_scope_name: .*hostmetricsreceiver.*filesystemscraper$
90 - metric_name: system\.filesystem\.inodes\.usage
91 - extract:
92 - chart_instance_pattern: metric.attributes.mountpoint
93 - dimension_name: metric.attributes.state
94 - - select:
95 - instrumentation_scope_name: .*hostmetricsreceiver.*filesystemscraper$
96 - metric_name: system\.filesystem\.usage
97 - extract:
98 - chart_instance_pattern: metric.attributes.mountpoint
99 - dimension_name: metric.attributes.state
100 - - select:
101 - instrumentation_scope_name: .*hostmetricsreceiver.*filesystemscraper$
102 - metric_name: system\.filesystem\.utilization
103 - extract:
104 - chart_instance_pattern: metric.attributes.mountpoint
105 - - select:
106 - instrumentation_scope_name: .*hostmetricsreceiver.*memoryscraper$
107 - metric_name: system\.memory\.utilization
108 - extract:
109 - dimension_name: metric.attributes.state
110 - - select:
111 - instrumentation_scope_name: .*hostmetricsreceiver.*pagingscraper$
112 - metric_name: system\.paging\.faults
113 - extract:
114 - dimension_name: metric.attributes.type
115 - - select:
116 - instrumentation_scope_name: .*hostmetricsreceiver.*pagingscraper$
117 - metric_name: system\.paging\.operations
118 - extract:
119 - chart_instance_pattern: metric.attributes.type
120 - dimension_name: metric.attributes.direction
121 - - select:
122 - instrumentation_scope_name: .*hostmetricsreceiver.*pagingscraper$
123 - metric_name: system\.paging\.usage
124 - extract:
125 - chart_instance_pattern: metric.attributes.device
126 - dimension_name: metric.attributes.state
127 - - select:
128 - instrumentation_scope_name: .*hostmetricsreceiver.*pagingscraper$
129 - metric_name: system\.paging\.utilization
130 - extract:
131 - chart_instance_pattern: metric.attributes.device
132 - dimension_name: metric.attributes.state
133 - - select:
134 - instrumentation_scope_name: .*hostmetricsreceiver.*processesscraper$
135 - metric_name: system\.processes\.count
136 - extract:
137 - dimension_name: metric.attributes.status
138 - - select:
139 - instrumentation_scope_name: .*hostmetricsreceiver.*processscraper$
140 - metric_name: process\.cpu\.time
141 - extract:
142 - dimension_name: metric.attributes.state
143 - - select:
144 - instrumentation_scope_name: .*hostmetricsreceiver.*processscraper$
145 - metric_name: process\.disk\.io
146 - extract:
147 - dimension_name: metric.attributes.direction
148 - - select:
149 - instrumentation_scope_name: .*hostmetricsreceiver.*processscraper$
150 - metric_name: process\.context_switches
151 - extract:
152 - dimension_name: metric.attributes.type
153 - - select:
154 - instrumentation_scope_name: .*hostmetricsreceiver.*processscraper$
155 - metric_name: process\.cpu\.utilization
156 - extract:
157 - dimension_name: metric.attributes.state
158 - - select:
159 - instrumentation_scope_name: .*hostmetricsreceiver.*processscraper$
160 - metric_name: process\.disk\.operations
161 - extract:
162 - dimension_name: metric.attributes.direction
163 - - select:
164 - instrumentation_scope_name: .*hostmetricsreceiver.*processscraper$
165 - metric_name: process\.paging\.faults
166 - extract:
167 - dimension_name: metric.attributes.type
168 - - select:
169 - instrumentation_scope_name: .*hostmetricsreceiver.*processscraper$
170 - metric_name: process\.paging\.faults
171 - extract:
172 - dimension_name: metric.attributes.type
1 +metrics:
2 + "system.network.connections":
3 + - instrumentation_scope:
4 + name: .*hostmetricsreceiver.*networkscraper$
5 + dimension_attribute_key: state
6 +
7 + "system.network.dropped":
8 + - instrumentation_scope:
9 + name: .*hostmetricsreceiver.*networkscraper$
10 + dimension_attribute_key: direction
11 +
12 + "system.network.errors":
13 + - instrumentation_scope:
14 + name: .*hostmetricsreceiver.*networkscraper$
15 + dimension_attribute_key: direction
16 +
17 + "system.network.io":
18 + - instrumentation_scope:
19 + name: .*hostmetricsreceiver.*networkscraper$
20 + dimension_attribute_key: direction
21 +
22 + "system.network.packets":
23 + - instrumentation_scope:
24 + name: .*hostmetricsreceiver.*networkscraper$
25 + dimension_attribute_key: direction
26 +
27 + "system.cpu.time":
28 + - instrumentation_scope:
29 + name: .*hostmetricsreceiver.*cpuscraper$
30 + dimension_attribute_key: state
31 +
32 + "system.cpu.frequency":
33 + - instrumentation_scope:
34 + name: .*hostmetricsreceiver.*cpuscraper$
35 +
36 + "system.cpu.utilization":
37 + - instrumentation_scope:
38 + name: .*hostmetricsreceiver.*cpuscraper$
39 + dimension_attribute_key: state
40 +
41 + "system.disk.io":
42 + - instrumentation_scope:
43 + name: .*hostmetricsreceiver.*diskscraper$
44 + dimension_attribute_key: direction
45 +
46 + "system.disk.io_time":
47 + - instrumentation_scope:
48 + name: .*hostmetricsreceiver.*diskscraper$
49 +
50 + "system.disk.merged":
51 + - instrumentation_scope:
52 + name: .*hostmetricsreceiver.*diskscraper$
53 + dimension_attribute_key: direction
54 +
55 + "system.disk.operation_time":
56 + - instrumentation_scope:
57 + name: .*hostmetricsreceiver.*diskscraper$
58 + dimension_attribute_key: direction
59 +
60 + "system.disk.operations":
61 + - instrumentation_scope:
62 + name: .*hostmetricsreceiver.*diskscraper$
63 + dimension_attribute_key: direction
64 +
65 + "system.disk.pending_operations":
66 + - instrumentation_scope:
67 + name: .*hostmetricsreceiver.*diskscraper$
68 +
69 + "system.disk.weighted_io":
70 + - instrumentation_scope:
71 + name: .*hostmetricsreceiver.*diskscraper$
72 +
73 + "system.filesystem.inodes.usage":
74 + - instrumentation_scope:
75 + name: .*hostmetricsreceiver.*filesystemscraper$
76 + dimension_attribute_key: state
77 +
78 + "system.filesystem.usage":
79 + - instrumentation_scope:
80 + name: .*hostmetricsreceiver.*filesystemscraper$
81 + dimension_attribute_key: state
82 +
83 + "system.filesystem.utilization":
84 + - instrumentation_scope:
85 + name: .*hostmetricsreceiver.*filesystemscraper$
86 +
87 + "system.memory.utilization":
88 + - instrumentation_scope:
89 + name: .*hostmetricsreceiver.*memoryscraper$
90 + dimension_attribute_key: state
91 +
92 + "system.memory.usage":
93 + - instrumentation_scope:
94 + name: .*hostmetricsreceiver.*memoryscraper$
95 + dimension_attribute_key: state
96 +
97 + "system.paging.faults":
98 + - instrumentation_scope:
99 + name: .*hostmetricsreceiver.*pagingscraper$
100 + dimension_attribute_key: type
101 +
102 + "system.paging.operations":
103 + - instrumentation_scope:
104 + name: .*hostmetricsreceiver.*pagingscraper$
105 + dimension_attribute_key: direction
106 +
107 + "system.paging.usage":
108 + - instrumentation_scope:
109 + name: .*hostmetricsreceiver.*pagingscraper$
110 + dimension_attribute_key: state
111 +
112 + "system.paging.utilization":
113 + - instrumentation_scope:
114 + name: .*hostmetricsreceiver.*pagingscraper$
115 + dimension_attribute_key: state
116 +
117 + "system.processes.count":
118 + - instrumentation_scope:
119 + name: .*hostmetricsreceiver.*processesscraper$
120 + dimension_attribute_key: status
121 +
122 + "process.cpu.time":
123 + - instrumentation_scope:
124 + name: .*hostmetricsreceiver.*processscraper$
125 + dimension_attribute_key: state
126 +
127 + "process.disk.io":
128 + - instrumentation_scope:
129 + name: .*hostmetricsreceiver.*processscraper$
130 + dimension_attribute_key: direction
131 +
132 + "process.context_switches":
133 + - instrumentation_scope:
134 + name: .*hostmetricsreceiver.*processscraper$
135 + dimension_attribute_key: type
136 +
137 + "process.cpu.utilization":
138 + - instrumentation_scope:
139 + name: .*hostmetricsreceiver.*processscraper$
140 + dimension_attribute_key: state
141 +
142 + "process.disk.operations":
143 + - instrumentation_scope:
144 + name: .*hostmetricsreceiver.*processscraper$
145 + dimension_attribute_key: direction
146 +
147 + "process.paging.faults":
148 + - instrumentation_scope:
149 + name: .*hostmetricsreceiver.*processscraper$
150 + dimension_attribute_key: type
src/crates/netdata-otel/otel-plugin/configs/otel.yaml.in
+14 -8
@@ -15,17 +15,23 @@ endpoint:
15 tls_ca_cert_path: null
16
17 metrics:
18 - # Print flattened metrics to stdout for debugging, instead of ingesting them
19 - print_flattened: false
18 + # Directory with configuration files for mapping OTEL metrics to Netdata charts
19 + chart_configs_dir: @configdir_POST@/otel.d/v1/metrics
20
21 - # Number of samples to buffer for collection interval detection
22 - buffer_samples: 10
21 + # Collection interval in seconds (1–3600). Defines the Netdata chart update frequency.
22 + interval_secs: 10
23
24 - # Maximum number of new charts to create per collection interval
25 - throttle_charts: 100
24 + # Grace period in seconds. After the last data point, the plugin waits this long
25 + # before gap-filling. When interval_secs is overridden, this auto-derives as
26 + # 5 * interval_secs unless explicitly set.
27 + grace_period_secs: 60
28
27 - # Directory with configuration files for mapping OTEL metrics to Netdata charts
28 - chart_configs_dir: @configdir_POST@/otel.d/v1/metrics
29 + # Expiry duration in seconds. Charts with no data for this long are removed.
30 + expiry_duration_secs: 900
31 +
32 + # Maximum number of new charts that can be created per gRPC request.
33 + # Limits cardinality explosion from high-cardinality label combinations.
34 + max_new_charts_per_request: 100
35
36 logs:
37 # Directory to store journal files for logs.
src/crates/netdata-otel/otel-plugin/integrations/opentelemetry.md
+70 -14
@@ -22,17 +22,35 @@ Module: otel
22
23 ## Overview
24
25 -This plugin ingests OpenTelemetry metrics and logs via the OTLP/gRPC protocol.
25 +This plugin enables the Netdata Agent to receive OpenTelemetry metrics and logs
26 +via the OTLP/gRPC protocol from any compatible source — collectors, SDKs, or
27 +instrumented applications.
28
27 -It receives OTLP-formatted data from any OpenTelemetry-compatible source (collectors, SDKs, instrumented applications)
28 -and automatically creates Netdata charts for visualization and alerting.
29 +Metrics are automatically visualized as Netdata charts with full alerting support.
30 +Logs are stored in systemd-compatible journal files and can be explored through
31 +the Netdata Logs tab.
32
30 -For logs, it stores them in systemd-compatible journal files with configurable rotation and retention policies.
33
34 +The plugin listens on a configurable gRPC endpoint for incoming OTLP data.
35
33 -The plugin listens on a gRPC endpoint (default `127.0.0.1:4317`) for incoming OTLP data.
34 -It supports both metrics and logs signals. Metrics are mapped to Netdata charts using configurable
35 -mapping rules. Logs are stored in journal files for querying via the Netdata Logs tab.
36 +Incoming metrics are mapped to Netdata charts using YAML mapping rules placed in the
37 +chart configs directory (default `/etc/netdata/otel.d/v1/metrics/`). Each file can
38 +contain entries that match metrics by instrumentation scope and metric name, and control
39 +how data point attributes translate to chart instances and dimensions. Per-metric
40 +overrides for the collection interval and grace period are also supported. Without a
41 +matching rule, the plugin creates charts using default settings. Charts with no incoming
42 +data are automatically expired and removed.
43 +
44 +| Mapping file option | Description |
45 +|:--------------------|:------------|
46 +| `instrumentation_scope.name` | Regex to match the instrumentation scope name |
47 +| `instrumentation_scope.version` | Regex to match the instrumentation scope version |
48 +| `dimension_attribute_key` | Data point attribute whose value becomes the dimension name |
49 +| `interval_secs` | Per-metric collection interval override (1–3600 seconds) |
50 +| `grace_period_secs` | Per-metric grace period override |
51 +
52 +Incoming logs are written to journal files with configurable rotation and retention
53 +policies.
54
55
56 This collector is only supported on the following platforms:
@@ -98,9 +116,11 @@ The plugin is configured via `otel.yaml` in the Netdata configuration directory.
116 | endpoint.tls_cert_path | Path to TLS certificate file. Enables TLS when provided. | | no |
117 | endpoint.tls_key_path | Path to TLS private key file. Required when TLS certificate is provided. | | no |
118 | endpoint.tls_ca_cert_path | Path to TLS CA certificate file for client authentication. | | no |
101 -| metrics.chart_configs_dir | Directory with YAML files for mapping OTLP metrics to Netdata charts. | otel.d/v1/metrics/ | no |
102 -| metrics.buffer_samples | Number of samples to buffer for collection interval detection. | 10 | no |
103 -| metrics.throttle_charts | Maximum number of new charts to create per collection interval. | 100 | no |
119 +| metrics.chart_configs_dir | Directory containing YAML files that define how OTLP metrics are mapped to Netdata charts. Each file can match metrics by instrumentation scope and name, set the dimension attribute key, and override timing parameters. The plugin ships stock mappings; user files in this directory take priority. | /etc/netdata/otel.d/v1/metrics/ | no |
120 +| metrics.interval_secs | Collection interval in seconds (1–3600). Defines the Netdata chart update frequency. | 10 | no |
121 +| metrics.grace_period_secs | Grace period in seconds. After the last data point, the plugin waits this long before gap-filling. | 60 | no |
122 +| metrics.expiry_duration_secs | Expiry duration in seconds. Charts with no data for this long are removed. | 900 | no |
123 +| metrics.max_new_charts_per_request | Maximum number of new charts that can be created per gRPC request. Limits cardinality explosion from high-cardinality label combinations. | 100 | no |
124 | logs.journal_dir | Directory to store journal files for ingested logs. | | yes |
125 | logs.size_of_journal_file | Maximum file size before rotating to a new journal file. | 100MB | no |
126 | logs.entries_of_journal_file | Maximum log entries per journal file. | 50000 | no |
@@ -137,12 +157,45 @@ Listen on default endpoint with default settings.
157 endpoint:
158 path: "127.0.0.1:4317"
159 metrics:
140 - chart_configs_dir: otel.d/v1/metrics/
141 - buffer_samples: 10
160 + chart_configs_dir: /etc/netdata/otel.d/v1/metrics/
161 + interval_secs: 10
162 + grace_period_secs: 60
163 + expiry_duration_secs: 900
164 + max_new_charts_per_request: 100
165 logs:
166 journal_dir: /var/log/netdata/otel-journals
167
168 ```
169 +###### Metric mapping file
170 +
171 +Place YAML files like this in `/etc/netdata/otel.d/v1/metrics/` to control how
172 +OTLP metrics are mapped to Netdata charts. This example maps metrics from the
173 +OpenTelemetry Collector hostmetrics receiver.
174 +
175 +
176 +<details open><summary>Config</summary>
177 +
178 +```yaml
179 +metrics:
180 + "system.network.connections":
181 + - instrumentation_scope:
182 + name: .*hostmetricsreceiver.*networkscraper$
183 + dimension_attribute_key: state
184 +
185 + "system.cpu.utilization":
186 + - instrumentation_scope:
187 + name: .*hostmetricsreceiver.*cpuscraper$
188 + dimension_attribute_key: state
189 +
190 + "system.memory.usage":
191 + - instrumentation_scope:
192 + name: .*hostmetricsreceiver.*memoryscraper$
193 + dimension_attribute_key: state
194 + interval_secs: 5
195 +
196 +```
197 +</details>
198 +
199 ###### TLS-enabled configuration
200
201 Listen with TLS enabled for secure connections.
@@ -155,8 +208,11 @@ endpoint:
208 tls_cert_path: /etc/netdata/ssl/cert.pem
209 tls_key_path: /etc/netdata/ssl/key.pem
210 metrics:
158 - chart_configs_dir: otel.d/v1/metrics/
159 - buffer_samples: 10
211 + chart_configs_dir: /etc/netdata/otel.d/v1/metrics/
212 + interval_secs: 10
213 + grace_period_secs: 60
214 + expiry_duration_secs: 900
215 + max_new_charts_per_request: 100
216 logs:
217 journal_dir: /var/log/netdata/otel-journals
218
src/crates/netdata-otel/otel-plugin/metadata.yaml
+72 -18
@@ -28,16 +28,34 @@ modules:
28 overview:
29 data_collection:
30 metrics_description: |
31 - This plugin ingests OpenTelemetry metrics and logs via the OTLP/gRPC protocol.
31 + This plugin enables the Netdata Agent to receive OpenTelemetry metrics and logs
32 + via the OTLP/gRPC protocol from any compatible source — collectors, SDKs, or
33 + instrumented applications.
34
33 - It receives OTLP-formatted data from any OpenTelemetry-compatible source (collectors, SDKs, instrumented applications)
34 - and automatically creates Netdata charts for visualization and alerting.
35 -
36 - For logs, it stores them in systemd-compatible journal files with configurable rotation and retention policies.
35 + Metrics are automatically visualized as Netdata charts with full alerting support.
36 + Logs are stored in systemd-compatible journal files and can be explored through
37 + the Netdata Logs tab.
38 method_description: |
38 - The plugin listens on a gRPC endpoint (default `127.0.0.1:4317`) for incoming OTLP data.
39 - It supports both metrics and logs signals. Metrics are mapped to Netdata charts using configurable
40 - mapping rules. Logs are stored in journal files for querying via the Netdata Logs tab.
39 + The plugin listens on a configurable gRPC endpoint for incoming OTLP data.
40 +
41 + Incoming metrics are mapped to Netdata charts using YAML mapping rules placed in the
42 + chart configs directory (default `/etc/netdata/otel.d/v1/metrics/`). Each file can
43 + contain entries that match metrics by instrumentation scope and metric name, and control
44 + how data point attributes translate to chart instances and dimensions. Per-metric
45 + overrides for the collection interval and grace period are also supported. Without a
46 + matching rule, the plugin creates charts using default settings. Charts with no incoming
47 + data are automatically expired and removed.
48 +
49 + | Mapping file option | Description |
50 + |:--------------------|:------------|
51 + | `instrumentation_scope.name` | Regex to match the instrumentation scope name |
52 + | `instrumentation_scope.version` | Regex to match the instrumentation scope version |
53 + | `dimension_attribute_key` | Data point attribute whose value becomes the dimension name |
54 + | `interval_secs` | Per-metric collection interval override (1–3600 seconds) |
55 + | `grace_period_secs` | Per-metric grace period override |
56 +
57 + Incoming logs are written to journal files with configurable rotation and retention
58 + policies.
59 supported_platforms:
60 include:
61 - Linux
@@ -87,15 +105,23 @@ modules:
105 default_value: ""
106 required: false
107 - name: metrics.chart_configs_dir
90 - description: Directory with YAML files for mapping OTLP metrics to Netdata charts.
91 - default_value: "otel.d/v1/metrics/"
108 + description: Directory containing YAML files that define how OTLP metrics are mapped to Netdata charts. Each file can match metrics by instrumentation scope and name, set the dimension attribute key, and override timing parameters. The plugin ships stock mappings; user files in this directory take priority.
109 + default_value: "/etc/netdata/otel.d/v1/metrics/"
110 required: false
93 - - name: metrics.buffer_samples
94 - description: Number of samples to buffer for collection interval detection.
111 + - name: metrics.interval_secs
112 + description: Collection interval in seconds (1–3600). Defines the Netdata chart update frequency.
113 default_value: 10
114 required: false
97 - - name: metrics.throttle_charts
98 - description: Maximum number of new charts to create per collection interval.
115 + - name: metrics.grace_period_secs
116 + description: Grace period in seconds. After the last data point, the plugin waits this long before gap-filling.
117 + default_value: 60
118 + required: false
119 + - name: metrics.expiry_duration_secs
120 + description: Expiry duration in seconds. Charts with no data for this long are removed.
121 + default_value: 900
122 + required: false
123 + - name: metrics.max_new_charts_per_request
124 + description: Maximum number of new charts that can be created per gRPC request. Limits cardinality explosion from high-cardinality label combinations.
125 default_value: 100
126 required: false
127 - name: logs.journal_dir
@@ -139,10 +165,35 @@ modules:
165 endpoint:
166 path: "127.0.0.1:4317"
167 metrics:
142 - chart_configs_dir: otel.d/v1/metrics/
143 - buffer_samples: 10
168 + chart_configs_dir: /etc/netdata/otel.d/v1/metrics/
169 + interval_secs: 10
170 + grace_period_secs: 60
171 + expiry_duration_secs: 900
172 + max_new_charts_per_request: 100
173 logs:
174 journal_dir: /var/log/netdata/otel-journals
175 + - name: Metric mapping file
176 + description: |
177 + Place YAML files like this in `/etc/netdata/otel.d/v1/metrics/` to control how
178 + OTLP metrics are mapped to Netdata charts. This example maps metrics from the
179 + OpenTelemetry Collector hostmetrics receiver.
180 + config: |
181 + metrics:
182 + "system.network.connections":
183 + - instrumentation_scope:
184 + name: .*hostmetricsreceiver.*networkscraper$
185 + dimension_attribute_key: state
186 +
187 + "system.cpu.utilization":
188 + - instrumentation_scope:
189 + name: .*hostmetricsreceiver.*cpuscraper$
190 + dimension_attribute_key: state
191 +
192 + "system.memory.usage":
193 + - instrumentation_scope:
194 + name: .*hostmetricsreceiver.*memoryscraper$
195 + dimension_attribute_key: state
196 + interval_secs: 5
197 - name: TLS-enabled configuration
198 description: Listen with TLS enabled for secure connections.
199 config: |
@@ -151,8 +202,11 @@ modules:
202 tls_cert_path: /etc/netdata/ssl/cert.pem
203 tls_key_path: /etc/netdata/ssl/key.pem
204 metrics:
154 - chart_configs_dir: otel.d/v1/metrics/
155 - buffer_samples: 10
205 + chart_configs_dir: /etc/netdata/otel.d/v1/metrics/
206 + interval_secs: 10
207 + grace_period_secs: 60
208 + expiry_duration_secs: 900
209 + max_new_charts_per_request: 100
210 logs:
211 journal_dir: /var/log/netdata/otel-journals
212 troubleshooting:
src/crates/netdata-otel/otel-plugin/src/aggregation.rs new
+484
@@ -0,0 +1,484 @@
1 +#![allow(dead_code)]
2 +
3 +//! Aggregation logic for mapping OpenTelemetry's event-based metrics to Netdata's
4 +//! fixed-interval collection model.
5 +//!
6 +//! Each aggregator is a state machine that:
7 +//! - Accepts data points via `ingest()`
8 +//! - Produces a value for the current slot via `finalize_slot()`
9 +//! - Provides gap-fill values when no data arrives via `gap_fill()`
10 +
11 +/// Trait for metric aggregators that map OpenTelemetry data points to Netdata values.
12 +///
13 +/// Aggregators maintain state across collection intervals and handle the conversion
14 +/// from OpenTelemetry's event-based model to Netdata's fixed-interval model.
15 +pub trait Aggregator {
16 + /// Ingest a data point for the current pending slot.
17 + ///
18 + /// # Arguments
19 + /// * `value` - The numeric value from the data point
20 + /// * `timestamp_ns` - The `time_unix_nano` field (when the measurement became current)
21 + /// * `start_time_ns` - The `start_time_unix_nano` field (start of observation interval)
22 + fn ingest(&mut self, value: f64, timestamp_ns: u64, start_time_ns: u64);
23 +
24 + /// Finalize the current slot and return the value to emit to Netdata.
25 + ///
26 + /// This is called when the slot's grace period has expired and we need to
27 + /// produce a final value. After this call, internal per-slot accumulators
28 + /// should be reset, but cross-slot state (like previous cumulative values)
29 + /// should be preserved.
30 + ///
31 + /// Returns `None` if no value can be produced (e.g., first observation for cumulative).
32 + fn finalize_slot(&mut self) -> Option<f64>;
33 +
34 + /// Return the value to use when no data arrived for a slot (gap filling).
35 + ///
36 + /// This is called when a slot is finalized but no data points were ingested.
37 + fn gap_fill(&self) -> f64;
38 +
39 + /// Reset all state. Called when the dimension is being re-initialized.
40 + fn reset(&mut self);
41 +}
42 +
43 +/// Aggregator for Gauge metrics.
44 +///
45 +/// Gauges represent instantaneous values with no defined aggregation semantics.
46 +/// When multiple values arrive within a slot, we keep the last one (by timestamp).
47 +/// Gap filling repeats the last observed value.
48 +#[derive(Debug, Default)]
49 +pub struct GaugeAggregator {
50 + /// The last value seen in the current slot (with its timestamp for ordering)
51 + pending: Option<PendingValue>,
52 + /// The last emitted value (for gap filling)
53 + last_emitted: Option<f64>,
54 +}
55 +
56 +#[derive(Debug, Clone, Copy)]
57 +struct PendingValue {
58 + value: f64,
59 + timestamp_ns: u64,
60 +}
61 +
62 +impl GaugeAggregator {
63 + pub fn new() -> Self {
64 + Self::default()
65 + }
66 +}
67 +
68 +impl Aggregator for GaugeAggregator {
69 + fn ingest(&mut self, value: f64, timestamp_ns: u64, _start_time_ns: u64) {
70 + // Keep the value with the latest timestamp
71 + match &self.pending {
72 + Some(pending) if timestamp_ns <= pending.timestamp_ns => {
73 + // Ignore older or equal timestamp
74 + }
75 + _ => {
76 + self.pending = Some(PendingValue {
77 + value,
78 + timestamp_ns,
79 + });
80 + }
81 + }
82 + }
83 +
84 + fn finalize_slot(&mut self) -> Option<f64> {
85 + let value = self.pending.take().map(|p| p.value);
86 + if let Some(v) = value {
87 + self.last_emitted = Some(v);
88 + }
89 + value
90 + }
91 +
92 + fn gap_fill(&self) -> f64 {
93 + self.last_emitted.unwrap_or(0.0)
94 + }
95 +
96 + fn reset(&mut self) {
97 + self.pending = None;
98 + self.last_emitted = None;
99 + }
100 +}
101 +
102 +/// Aggregator for Sum metrics with Delta temporality.
103 +///
104 +/// Delta sums report the change since the last report. When multiple deltas
105 +/// arrive within a slot, we sum them (addition is the decomposable aggregate).
106 +/// Gap filling returns 0 (no change occurred).
107 +#[derive(Debug, Default)]
108 +pub struct DeltaSumAggregator {
109 + /// Accumulated delta for the current slot
110 + accumulated: f64,
111 + /// Whether we've received any data for the current slot
112 + has_data: bool,
113 +}
114 +
115 +impl DeltaSumAggregator {
116 + pub fn new() -> Self {
117 + Self::default()
118 + }
119 +}
120 +
121 +impl Aggregator for DeltaSumAggregator {
122 + fn ingest(&mut self, value: f64, _timestamp_ns: u64, _start_time_ns: u64) {
123 + self.accumulated += value;
124 + self.has_data = true;
125 + }
126 +
127 + fn finalize_slot(&mut self) -> Option<f64> {
128 + if self.has_data {
129 + let value = self.accumulated;
130 + self.accumulated = 0.0;
131 + self.has_data = false;
132 + Some(value)
133 + } else {
134 + None
135 + }
136 + }
137 +
138 + fn gap_fill(&self) -> f64 {
139 + 0.0
140 + }
141 +
142 + fn reset(&mut self) {
143 + self.accumulated = 0.0;
144 + self.has_data = false;
145 + }
146 +}
147 +
148 +/// Aggregator for Sum metrics with Cumulative temporality.
149 +///
150 +/// Cumulative sums report the total since a fixed start time. We convert to
151 +/// deltas by tracking the previous cumulative value and computing the difference.
152 +///
153 +/// Restart detection uses `start_time_unix_nano` - when it changes, we know
154 +/// the counter has reset and we cannot compute a meaningful delta across
155 +/// the boundary.
156 +#[derive(Debug, Default)]
157 +pub struct CumulativeSumAggregator {
158 + /// State from the previous finalized slot
159 + previous: Option<CumulativeState>,
160 + /// Pending data for the current slot (last value by timestamp)
161 + pending: Option<CumulativePending>,
162 + /// The last emitted delta (for gap filling)
163 + last_emitted_delta: Option<f64>,
164 +}
165 +
166 +#[derive(Debug, Clone, Copy)]
167 +struct CumulativeState {
168 + /// The cumulative value at the end of the last slot
169 + value: f64,
170 + /// The start_time_unix_nano from that observation
171 + start_time_ns: u64,
172 +}
173 +
174 +#[derive(Debug, Clone, Copy)]
175 +struct CumulativePending {
176 + /// The last cumulative value seen in this slot
177 + value: f64,
178 + /// Timestamp of that observation (for keeping "last by timestamp")
179 + timestamp_ns: u64,
180 + /// The start_time_unix_nano from that observation
181 + start_time_ns: u64,
182 +}
183 +
184 +impl CumulativeSumAggregator {
185 + pub fn new() -> Self {
186 + Self::default()
187 + }
188 +
189 + /// Check if a restart occurred between the previous state and the pending data.
190 + fn is_restart(&self, pending: &CumulativePending) -> bool {
191 + match &self.previous {
192 + Some(prev) => prev.start_time_ns != pending.start_time_ns,
193 + None => false, // No previous state, so not a restart
194 + }
195 + }
196 +}
197 +
198 +impl Aggregator for CumulativeSumAggregator {
199 + fn ingest(&mut self, value: f64, timestamp_ns: u64, start_time_ns: u64) {
200 + // Keep the value with the latest timestamp within the slot
201 + match &self.pending {
202 + Some(pending) if timestamp_ns <= pending.timestamp_ns => {
203 + // Ignore older or equal timestamp
204 + }
205 + _ => {
206 + self.pending = Some(CumulativePending {
207 + value,
208 + timestamp_ns,
209 + start_time_ns,
210 + });
211 + }
212 + }
213 + }
214 +
215 + fn finalize_slot(&mut self) -> Option<f64> {
216 + let pending = self.pending.take()?;
217 +
218 + let delta = if self.is_restart(&pending) {
219 + // Restart detected - we can't compute a meaningful delta
220 + // Update state to track the new sequence
221 + self.previous = Some(CumulativeState {
222 + value: pending.value,
223 + start_time_ns: pending.start_time_ns,
224 + });
225 + // Return 0 for the restart slot (no contribution)
226 + Some(0.0)
227 + } else if let Some(prev) = &self.previous {
228 + // Normal case: compute delta from previous cumulative value
229 + let delta = pending.value - prev.value;
230 + self.previous = Some(CumulativeState {
231 + value: pending.value,
232 + start_time_ns: pending.start_time_ns,
233 + });
234 + Some(delta)
235 + } else {
236 + // First observation - establish baseline, can't compute delta yet
237 + self.previous = Some(CumulativeState {
238 + value: pending.value,
239 + start_time_ns: pending.start_time_ns,
240 + });
241 + // Return None to indicate no value for this slot
242 + None
243 + };
244 +
245 + if let Some(d) = delta {
246 + self.last_emitted_delta = Some(d);
247 + }
248 +
249 + delta
250 + }
251 +
252 + fn gap_fill(&self) -> f64 {
253 + // No new cumulative value means no change in delta
254 + 0.0
255 + }
256 +
257 + fn reset(&mut self) {
258 + self.previous = None;
259 + self.pending = None;
260 + self.last_emitted_delta = None;
261 + }
262 +}
263 +
264 +#[cfg(test)]
265 +mod tests {
266 + use super::*;
267 +
268 + mod gauge {
269 + use super::*;
270 +
271 + #[test]
272 + fn keeps_last_value_by_timestamp() {
273 + let mut agg = GaugeAggregator::new();
274 +
275 + // Ingest multiple values with different timestamps
276 + agg.ingest(10.0, 1000, 0);
277 + agg.ingest(30.0, 3000, 0); // Latest timestamp
278 + agg.ingest(20.0, 2000, 0); // Earlier timestamp, should be ignored
279 +
280 + assert_eq!(agg.finalize_slot(), Some(30.0));
281 + }
282 +
283 + #[test]
284 + fn returns_none_when_no_data() {
285 + let mut agg = GaugeAggregator::new();
286 + assert_eq!(agg.finalize_slot(), None);
287 + }
288 +
289 + #[test]
290 + fn gap_fill_returns_last_emitted() {
291 + let mut agg = GaugeAggregator::new();
292 +
293 + agg.ingest(42.0, 1000, 0);
294 + agg.finalize_slot();
295 +
296 + // Now gap fill should return 42.0
297 + assert_eq!(agg.gap_fill(), 42.0);
298 + }
299 +
300 + #[test]
301 + fn gap_fill_returns_zero_when_never_emitted() {
302 + let agg = GaugeAggregator::new();
303 + assert_eq!(agg.gap_fill(), 0.0);
304 + }
305 +
306 + #[test]
307 + fn reset_clears_state() {
308 + let mut agg = GaugeAggregator::new();
309 + agg.ingest(42.0, 1000, 0);
310 + agg.finalize_slot();
311 +
312 + agg.reset();
313 +
314 + assert_eq!(agg.finalize_slot(), None);
315 + assert_eq!(agg.gap_fill(), 0.0);
316 + }
317 + }
318 +
319 + mod delta_sum {
320 + use super::*;
321 +
322 + #[test]
323 + fn sums_multiple_deltas() {
324 + let mut agg = DeltaSumAggregator::new();
325 +
326 + agg.ingest(10.0, 1000, 0);
327 + agg.ingest(20.0, 2000, 1000);
328 + agg.ingest(5.0, 3000, 2000);
329 +
330 + assert_eq!(agg.finalize_slot(), Some(35.0));
331 + }
332 +
333 + #[test]
334 + fn returns_none_when_no_data() {
335 + let mut agg = DeltaSumAggregator::new();
336 + assert_eq!(agg.finalize_slot(), None);
337 + }
338 +
339 + #[test]
340 + fn gap_fill_returns_zero() {
341 + let mut agg = DeltaSumAggregator::new();
342 + agg.ingest(100.0, 1000, 0);
343 + agg.finalize_slot();
344 +
345 + assert_eq!(agg.gap_fill(), 0.0);
346 + }
347 +
348 + #[test]
349 + fn resets_accumulator_after_finalize() {
350 + let mut agg = DeltaSumAggregator::new();
351 +
352 + agg.ingest(10.0, 1000, 0);
353 + assert_eq!(agg.finalize_slot(), Some(10.0));
354 +
355 + agg.ingest(5.0, 2000, 1000);
356 + assert_eq!(agg.finalize_slot(), Some(5.0));
357 + }
358 +
359 + #[test]
360 + fn handles_negative_deltas() {
361 + let mut agg = DeltaSumAggregator::new();
362 +
363 + agg.ingest(10.0, 1000, 0);
364 + agg.ingest(-3.0, 2000, 1000);
365 +
366 + assert_eq!(agg.finalize_slot(), Some(7.0));
367 + }
368 + }
369 +
370 + mod cumulative_sum {
371 + use super::*;
372 +
373 + const START_TIME: u64 = 1_000_000_000;
374 +
375 + #[test]
376 + fn first_observation_returns_none() {
377 + let mut agg = CumulativeSumAggregator::new();
378 +
379 + agg.ingest(100.0, 1000, START_TIME);
380 +
381 + // First observation establishes baseline, no delta yet
382 + assert_eq!(agg.finalize_slot(), None);
383 + }
384 +
385 + #[test]
386 + fn computes_delta_from_previous() {
387 + let mut agg = CumulativeSumAggregator::new();
388 +
389 + // First slot: establish baseline
390 + agg.ingest(100.0, 1000, START_TIME);
391 + agg.finalize_slot();
392 +
393 + // Second slot: should compute delta
394 + agg.ingest(150.0, 2000, START_TIME);
395 + assert_eq!(agg.finalize_slot(), Some(50.0));
396 +
397 + // Third slot: another delta
398 + agg.ingest(160.0, 3000, START_TIME);
399 + assert_eq!(agg.finalize_slot(), Some(10.0));
400 + }
401 +
402 + #[test]
403 + fn detects_restart_via_start_time_change() {
404 + let mut agg = CumulativeSumAggregator::new();
405 +
406 + // Establish baseline
407 + agg.ingest(100.0, 1000, START_TIME);
408 + agg.finalize_slot();
409 +
410 + agg.ingest(150.0, 2000, START_TIME);
411 + agg.finalize_slot();
412 +
413 + // Restart: start_time changes, value resets
414 + let new_start_time = START_TIME + 1_000_000;
415 + agg.ingest(20.0, 3000, new_start_time);
416 +
417 + // Should return 0 for restart slot
418 + assert_eq!(agg.finalize_slot(), Some(0.0));
419 +
420 + // Next slot should compute delta from new baseline
421 + agg.ingest(30.0, 4000, new_start_time);
422 + assert_eq!(agg.finalize_slot(), Some(10.0));
423 + }
424 +
425 + #[test]
426 + fn keeps_last_value_by_timestamp_in_slot() {
427 + let mut agg = CumulativeSumAggregator::new();
428 +
429 + // Establish baseline
430 + agg.ingest(100.0, 1000, START_TIME);
431 + agg.finalize_slot();
432 +
433 + // Multiple values in one slot - should use latest by timestamp
434 + agg.ingest(150.0, 2000, START_TIME);
435 + agg.ingest(200.0, 4000, START_TIME); // Latest timestamp
436 + agg.ingest(175.0, 3000, START_TIME); // Earlier, should be ignored
437 +
438 + assert_eq!(agg.finalize_slot(), Some(100.0)); // 200 - 100
439 + }
440 +
441 + #[test]
442 + fn gap_fill_returns_zero() {
443 + let mut agg = CumulativeSumAggregator::new();
444 +
445 + agg.ingest(100.0, 1000, START_TIME);
446 + agg.finalize_slot();
447 +
448 + agg.ingest(150.0, 2000, START_TIME);
449 + agg.finalize_slot();
450 +
451 + // No change in cumulative value = no delta
452 + assert_eq!(agg.gap_fill(), 0.0);
453 + }
454 +
455 + #[test]
456 + fn returns_none_when_no_data_in_slot() {
457 + let mut agg = CumulativeSumAggregator::new();
458 + assert_eq!(agg.finalize_slot(), None);
459 +
460 + // Even after establishing baseline, empty slot returns None
461 + agg.ingest(100.0, 1000, START_TIME);
462 + agg.finalize_slot();
463 +
464 + assert_eq!(agg.finalize_slot(), None);
465 + }
466 +
467 + #[test]
468 + fn reset_clears_all_state() {
469 + let mut agg = CumulativeSumAggregator::new();
470 +
471 + agg.ingest(100.0, 1000, START_TIME);
472 + agg.finalize_slot();
473 +
474 + agg.ingest(150.0, 2000, START_TIME);
475 + agg.finalize_slot();
476 +
477 + agg.reset();
478 +
479 + // After reset, next observation is treated as first
480 + agg.ingest(50.0, 3000, START_TIME);
481 + assert_eq!(agg.finalize_slot(), None); // First observation again
482 + }
483 + }
484 +}
src/crates/netdata-otel/otel-plugin/src/chart.rs new
+1630
@@ -0,0 +1,1630 @@
1 +//! Chart management for Netdata metrics.
2 +//!
3 +//! A `Chart` manages dimensions and slot-based aggregation, mapping OpenTelemetry's
4 +//! event-based metrics to Netdata's fixed-interval collection model.
5 +
6 +use std::collections::HashMap;
7 +use std::time::{Duration, Instant};
8 +
9 +use opentelemetry_proto::tonic::metrics::v1::AggregationTemporality;
10 +
11 +use crate::aggregation::{
12 + Aggregator, CumulativeSumAggregator, DeltaSumAggregator, GaugeAggregator,
13 +};
14 +use crate::iter::MetricDataKind;
15 +use crate::output::{ChartDefinition, ChartType, DimensionValue, write_data_slot};
16 +
17 +/// A dimension with its name, aggregator, and slot state.
18 +struct Dimension<A: Aggregator> {
19 + // The name of the dimension.
20 + name: String,
21 + // The aggregator that ingests values of the dimension.
22 + aggregator: A,
23 + /// Whether this dimension has received data in the current slot.
24 + has_data_in_slot: bool,
25 +}
26 +
27 +impl<A: Aggregator + Default> Dimension<A> {
28 + fn new(name: String) -> Self {
29 + Self {
30 + name,
31 + aggregator: A::default(),
32 + has_data_in_slot: false,
33 + }
34 + }
35 +}
36 +
37 +/// Configuration for chart timing.
38 +#[derive(Debug, Clone, Copy)]
39 +pub struct ChartConfig {
40 + /// Collection interval in seconds.
41 + pub collection_interval: u64,
42 + /// How long to wait for data before gap-filling on a tick with no data.
43 + pub grace_period: Duration,
44 + /// Duration after which a chart with no new data stops emitting.
45 + pub expiry_duration: Duration,
46 +}
47 +
48 +impl Default for ChartConfig {
49 + fn default() -> Self {
50 + Self {
51 + collection_interval: 10,
52 + grace_period: Duration::from_secs(60),
53 + expiry_duration: Duration::from_secs(900),
54 + }
55 + }
56 +}
57 +
58 +/// The type of aggregation used by a chart.
59 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60 +pub enum ChartAggregationType {
61 + Gauge,
62 + DeltaSum,
63 + CumulativeSum,
64 +}
65 +
66 +impl ChartAggregationType {
67 + /// Determine the aggregation type from metric metadata.
68 + pub fn from_metric(
69 + data_kind: MetricDataKind,
70 + temporality: Option<AggregationTemporality>,
71 + is_monotonic: Option<bool>,
72 + ) -> Option<Self> {
73 + match data_kind {
74 + MetricDataKind::Gauge => Some(ChartAggregationType::Gauge),
75 + MetricDataKind::Sum => match temporality {
76 + Some(AggregationTemporality::Delta) => Some(ChartAggregationType::DeltaSum),
77 + Some(AggregationTemporality::Cumulative) => {
78 + if is_monotonic == Some(false) {
79 + // Non-monotonic cumulative sum: treat as gauge (absolute value)
80 + Some(ChartAggregationType::Gauge)
81 + } else {
82 + // Monotonic (or unspecified) cumulative sum: compute deltas
83 + Some(ChartAggregationType::CumulativeSum)
84 + }
85 + }
86 + _ => None, // Unspecified temporality
87 + },
88 + // Histograms, ExponentialHistograms, and Summaries not supported yet
89 + _ => None,
90 + }
91 + }
92 +}
93 +
94 +/// Tracks whether a chart's definition has been emitted to Netdata.
95 +enum DefinitionState {
96 + /// No definition yet.
97 + Unset,
98 + /// Definition needs to be emitted (new chart or new dimensions added).
99 + Pending(ChartDefinition),
100 + /// Definition has been emitted and is up to date.
101 + Emitted(ChartDefinition),
102 +}
103 +
104 +impl DefinitionState {
105 + fn as_ref(&self) -> Option<&ChartDefinition> {
106 + match self {
107 + Self::Unset => None,
108 + Self::Pending(def) | Self::Emitted(def) => Some(def),
109 + }
110 + }
111 +
112 + fn as_mut(&mut self) -> Option<&mut ChartDefinition> {
113 + match self {
114 + Self::Unset => None,
115 + Self::Pending(def) | Self::Emitted(def) => Some(def),
116 + }
117 + }
118 +
119 + /// Transition `Emitted` → `Pending` (no-op for other states).
120 + fn mark_pending(&mut self) {
121 + let prev = std::mem::replace(self, Self::Unset);
122 + *self = match prev {
123 + Self::Emitted(def) => Self::Pending(def),
124 + other => other,
125 + };
126 + }
127 +
128 + /// Transition `Pending` → `Emitted` (no-op for other states).
129 + fn mark_emitted(&mut self) {
130 + let prev = std::mem::replace(self, Self::Unset);
131 + *self = match prev {
132 + Self::Pending(def) => Self::Emitted(def),
133 + other => other,
134 + };
135 + }
136 +}
137 +
138 +/// A Netdata chart that manages dimensions and tick-driven aggregation.
139 +pub struct Chart {
140 + /// The chart name used in Netdata protocol commands.
141 + chart_name: String,
142 + /// The Netdata chart type (line, heatmap, etc.).
143 + chart_type: ChartType,
144 + /// Collection interval in seconds.
145 + update_every: u64,
146 + /// Duration after which a chart with no new data stops emitting.
147 + expiry_duration: Duration,
148 + /// How long to wait for data before gap-filling on a tick with no data.
149 + grace_period: Duration,
150 + /// The currently active slot timestamp (if any).
151 + active_slot: Option<u64>,
152 + /// The quantized slot of the last successful emission.
153 + last_emission_slot: Option<u64>,
154 + /// When the chart last received data (for expiry).
155 + last_ingest_instant: Option<Instant>,
156 + /// Per-dimension aggregator storage.
157 + dimensions: DimensionStore,
158 + /// The chart definition and its emission state.
159 + definition: DefinitionState,
160 + /// Scratch buffer for finalized dimension values.
161 + dim_values: Vec<DimensionValue>,
162 +}
163 +
164 +/// Type-erased dimension storage for different aggregator types.
165 +enum DimensionStore {
166 + Gauge(HashMap<String, Dimension<GaugeAggregator>>),
167 + DeltaSum(HashMap<String, Dimension<DeltaSumAggregator>>),
168 + CumulativeSum(HashMap<String, Dimension<CumulativeSumAggregator>>),
169 +}
170 +
171 +impl DimensionStore {
172 + fn len(&self) -> usize {
173 + match self {
174 + Self::Gauge(dims) => dims.len(),
175 + Self::DeltaSum(dims) => dims.len(),
176 + Self::CumulativeSum(dims) => dims.len(),
177 + }
178 + }
179 +}
180 +
181 +impl Chart {
182 + /// Create a new chart with the given name, aggregation type, and chart type.
183 + pub fn new(
184 + name: &str,
185 + aggregation_type: ChartAggregationType,
186 + chart_type: ChartType,
187 + config: ChartConfig,
188 + ) -> Self {
189 + let dimensions = match aggregation_type {
190 + ChartAggregationType::Gauge => DimensionStore::Gauge(HashMap::new()),
191 + ChartAggregationType::DeltaSum => DimensionStore::DeltaSum(HashMap::new()),
192 + ChartAggregationType::CumulativeSum => DimensionStore::CumulativeSum(HashMap::new()),
193 + };
194 +
195 + Self {
196 + chart_name: name.to_string(),
197 + chart_type,
198 + update_every: config.collection_interval,
199 + expiry_duration: config.expiry_duration,
200 + grace_period: config.grace_period,
201 + active_slot: None,
202 + last_emission_slot: None,
203 + last_ingest_instant: None,
204 + dimensions,
205 + definition: DefinitionState::Unset,
206 + dim_values: Vec::new(),
207 + }
208 + }
209 +
210 + /// Create a chart from metric metadata.
211 + ///
212 + /// Returns `None` if the metric type is not supported.
213 + pub fn from_metric(
214 + name: &str,
215 + data_kind: MetricDataKind,
216 + temporality: Option<AggregationTemporality>,
217 + is_monotonic: Option<bool>,
218 + config: ChartConfig,
219 + ) -> Option<Self> {
220 + let aggregation_type =
221 + ChartAggregationType::from_metric(data_kind, temporality, is_monotonic)?;
222 + Some(Self::new(name, aggregation_type, ChartType::Line, config))
223 + }
224 +
225 + /// Compute the slot timestamp for a given nanosecond timestamp.
226 + fn slot_for_timestamp(&self, timestamp_ns: u64) -> u64 {
227 + let timestamp_secs = timestamp_ns / 1_000_000_000;
228 + (timestamp_secs / self.update_every) * self.update_every
229 + }
230 +
231 + /// Ingest a data point into a dimension's aggregator.
232 + pub fn ingest(
233 + &mut self,
234 + dimension_name: &str,
235 + value: f64,
236 + timestamp_ns: u64,
237 + start_time_ns: u64,
238 + ) {
239 + // Update last data time.
240 + self.last_ingest_instant = Some(Instant::now());
241 +
242 + let new_slot = self.slot_for_timestamp(timestamp_ns);
243 +
244 + // Figure out how to handle the data slot:
245 + // - active_slot is None: set it to data slot
246 + // - new_slot < active_slot: drop it
247 + // - new_slot = active_slot: update aggregator with value
248 + // - new_slot > active_slot: flush the aggregator and set active_slot = data_slot
249 +
250 + match self.active_slot {
251 + None => {
252 + self.active_slot = Some(new_slot);
253 + }
254 + Some(active_slot) if new_slot < active_slot => {
255 + // Data for a previous slot — drop it.
256 + return;
257 + }
258 + Some(active_slot) if new_slot > active_slot => {
259 + // Data for a newer slot — finalize aggregator per-slot state
260 + // so it resets properly, then advance the active slot.
261 + self.dimensions.finalize_into(&mut self.dim_values);
262 + self.active_slot = Some(new_slot);
263 + }
264 + Some(_) => {
265 + // Data for the current active slot.
266 + }
267 + }
268 +
269 + // Ingest into the dimension's aggregator.
270 + let new_dimension =
271 + self.dimensions
272 + .ingest(dimension_name, value, timestamp_ns, start_time_ns);
273 +
274 + // If a new dimension was added, update the definition and mark it
275 + // for re-emission.
276 + if new_dimension {
277 + if let Some(def) = self.definition.as_mut() {
278 + def.dimensions.push(dimension_name.to_string());
279 + }
280 + self.definition.mark_pending();
281 + }
282 + }
283 +
284 + pub fn len(&self) -> usize {
285 + self.dimensions.len()
286 + }
287 +
288 + /// Finalize the current slot and write output into `buf`.
289 + ///
290 + /// Three emission scenarios:
291 + /// 1. **Data present**: emit gap-filled catchup slots for missed intervals, then the data slot.
292 + /// 2. **No data, within grace period**: emit nothing (wait for late data).
293 + /// 3. **No data, grace expired**: emit one gap-filled slot per tick (drain oldest first).
294 + pub fn emit(&mut self, slot_timestamp: u64, buf: &mut String) {
295 + // Chart must have received data at some point.
296 + let Some(last_ingest_instant) = self.last_ingest_instant else {
297 + return;
298 + };
299 +
300 + // Check if the chart has expired (no data for too long).
301 + if last_ingest_instant.elapsed() >= self.expiry_duration {
302 + return;
303 + }
304 +
305 + // Slot boundary self-regulation: only emit once per interval boundary.
306 + let current_slot = (slot_timestamp / self.update_every) * self.update_every;
307 + if let Some(last_emission_slot) = self.last_emission_slot {
308 + if current_slot <= last_emission_slot {
309 + return;
310 + }
311 + }
312 +
313 + if self.dimensions.has_data() {
314 + // Data present — emit definition if needed, catchup slots, then data slot.
315 + self.emit_definition_if_needed(buf);
316 +
317 + // Emit gap-filled catchup slots for any missed intervals between
318 + // last emission and current.
319 + if let Some(last) = self.last_emission_slot {
320 + let mut catchup_slot = last + self.update_every;
321 +
322 + while catchup_slot < current_slot {
323 + self.dimensions.gap_fill_into(&mut self.dim_values);
324 +
325 + write_data_slot(
326 + buf,
327 + &self.chart_name,
328 + self.update_every,
329 + catchup_slot,
330 + &self.dim_values,
331 + )
332 + .expect("infallible string write");
333 +
334 + catchup_slot += self.update_every;
335 + }
336 + }
337 +
338 + // Finalize and emit the data slot.
339 + self.dimensions.finalize_into(&mut self.dim_values);
340 +
341 + write_data_slot(
342 + buf,
343 + &self.chart_name,
344 + self.update_every,
345 + current_slot,
346 + &self.dim_values,
347 + )
348 + .expect("infallible string write");
349 +
350 + self.last_emission_slot = Some(current_slot);
351 + } else if last_ingest_instant.elapsed() < self.grace_period {
352 + // No data, within grace period — skip this tick.
353 + } else {
354 + // No data, grace period expired — gap-fill and emit one slot.
355 + let Some(last) = self.last_emission_slot else {
356 + return;
357 + };
358 +
359 + self.emit_definition_if_needed(buf);
360 +
361 + let fill_slot = last + self.update_every;
362 + self.dimensions.finalize_into(&mut self.dim_values);
363 + write_data_slot(
364 + buf,
365 + &self.chart_name,
366 + self.update_every,
367 + fill_slot,
368 + &self.dim_values,
369 + )
370 + .expect("infallible string write");
371 +
372 + self.last_emission_slot = Some(fill_slot);
373 + }
374 + }
375 +
376 + /// Write the chart definition into `buf` if pending, then mark as emitted.
377 + fn emit_definition_if_needed(&mut self, buf: &mut String) {
378 + if !self.needs_definition() {
379 + return;
380 + }
381 +
382 + // Sort dimensions numerically for heatmap charts so Netdata
383 + // renders buckets in ascending order.
384 + if matches!(self.chart_type, ChartType::Heatmap) {
385 + if let Some(def) = self.definition.as_mut() {
386 + def.sort_dimensions_numerically();
387 + }
388 + }
389 +
390 + use std::fmt::Write;
391 + write!(
392 + buf,
393 + "{}",
394 + self.definition.as_ref().expect("definition must be set")
395 + )
396 + .expect("infallible string write");
397 +
398 + self.definition.mark_emitted();
399 + }
400 +
401 + /// Initialize the chart definition from metric metadata.
402 + ///
403 + /// Caller should check [`has_definition()`](Self::has_definition) first
404 + /// to avoid unnecessary allocations.
405 + pub fn init_definition(
406 + &mut self,
407 + metric_name: &str,
408 + title: &str,
409 + units: &str,
410 + labels: Vec<(String, String)>,
411 + ) {
412 + debug_assert!(matches!(self.definition, DefinitionState::Unset));
413 +
414 + self.definition = DefinitionState::Pending(ChartDefinition {
415 + chart_name: self.chart_name.clone(),
416 + title: title.to_string(),
417 + units: units.to_string(),
418 + family: metric_name.replace('.', "/"),
419 + context: format!("otel.{}", metric_name),
420 + chart_type: self.chart_type,
421 + update_every: self.update_every,
422 + labels,
423 + dimensions: Vec::new(),
424 + });
425 + }
426 +
427 + /// Whether a definition has been set.
428 + pub fn has_definition(&self) -> bool {
429 + !matches!(self.definition, DefinitionState::Unset)
430 + }
431 +
432 + /// Returns `true` if the chart needs its definition (re-)emitted.
433 + fn needs_definition(&self) -> bool {
434 + matches!(self.definition, DefinitionState::Pending(_))
435 + }
436 +
437 + /// Whether the chart has expired (no data for longer than the expiry duration).
438 + pub fn is_expired(&self) -> bool {
439 + match self.last_ingest_instant {
440 + Some(instant) => instant.elapsed() >= self.expiry_duration,
441 + None => false,
442 + }
443 + }
444 +
445 + /// Get a reference to the chart definition (test only).
446 + #[cfg(test)]
447 + fn definition(&self) -> Option<&ChartDefinition> {
448 + self.definition.as_ref()
449 + }
450 +
451 + /// Access finalized dimension values (for testing).
452 + #[cfg(test)]
453 + pub(crate) fn dim_values(&self) -> &[DimensionValue] {
454 + &self.dim_values
455 + }
456 +}
457 +
458 +impl DimensionStore {
459 + /// Check whether any dimension has pending data in the current slot.
460 + fn has_data(&self) -> bool {
461 + match self {
462 + Self::Gauge(dims) => Self::any_has_data(dims),
463 + Self::DeltaSum(dims) => Self::any_has_data(dims),
464 + Self::CumulativeSum(dims) => Self::any_has_data(dims),
465 + }
466 + }
467 +
468 + fn any_has_data<A: Aggregator>(dims: &HashMap<String, Dimension<A>>) -> bool {
469 + dims.values().any(|dim| dim.has_data_in_slot)
470 + }
471 +
472 + /// Ingest a value into a dimension's aggregator, creating the dimension if needed.
473 + /// Returns `true` if a new dimension was created.
474 + fn ingest(&mut self, name: &str, value: f64, timestamp_ns: u64, start_time_ns: u64) -> bool {
475 + match self {
476 + Self::Gauge(dims) => Self::ingest_into(dims, name, value, timestamp_ns, start_time_ns),
477 + Self::DeltaSum(dims) => {
478 + Self::ingest_into(dims, name, value, timestamp_ns, start_time_ns)
479 + }
480 + Self::CumulativeSum(dims) => {
481 + Self::ingest_into(dims, name, value, timestamp_ns, start_time_ns)
482 + }
483 + }
484 + }
485 +
486 + fn ingest_into<A: Aggregator + Default>(
487 + dims: &mut HashMap<String, Dimension<A>>,
488 + name: &str,
489 + value: f64,
490 + timestamp_ns: u64,
491 + start_time_ns: u64,
492 + ) -> bool {
493 + let new_dimension = !dims.contains_key(name);
494 + let dim = dims
495 + .entry(name.to_string())
496 + .or_insert_with(|| Dimension::new(name.to_string()));
497 + dim.aggregator.ingest(value, timestamp_ns, start_time_ns);
498 + dim.has_data_in_slot = true;
499 + new_dimension
500 + }
501 +
502 + /// Finalize all dimensions into the provided buffer.
503 + fn finalize_into(&mut self, out: &mut Vec<DimensionValue>) {
504 + out.clear();
505 +
506 + match self {
507 + Self::Gauge(dims) => Self::finalize_dims(dims, out),
508 + Self::DeltaSum(dims) => Self::finalize_dims(dims, out),
509 + Self::CumulativeSum(dims) => Self::finalize_dims(dims, out),
510 + }
511 + }
512 +
513 + /// Gap-fill all dimensions into the provided buffer.
514 + fn gap_fill_into(&self, out: &mut Vec<DimensionValue>) {
515 + out.clear();
516 +
517 + match self {
518 + Self::Gauge(dims) => Self::gap_fill_dims(dims, out),
519 + Self::DeltaSum(dims) => Self::gap_fill_dims(dims, out),
520 + Self::CumulativeSum(dims) => Self::gap_fill_dims(dims, out),
521 + }
522 + }
523 +
524 + fn finalize_dims<A: Aggregator>(
525 + dims: &mut HashMap<String, Dimension<A>>,
526 + out: &mut Vec<DimensionValue>,
527 + ) {
528 + out.reserve(dims.len());
529 +
530 + for dim in dims.values_mut() {
531 + let value = if dim.has_data_in_slot {
532 + dim.aggregator.finalize_slot()
533 + } else {
534 + Some(dim.aggregator.gap_fill())
535 + };
536 +
537 + out.push(DimensionValue {
538 + name: dim.name.clone(),
539 + value,
540 + });
541 +
542 + dim.has_data_in_slot = false;
543 + }
544 + }
545 +
546 + fn gap_fill_dims<A: Aggregator>(
547 + dims: &HashMap<String, Dimension<A>>,
548 + out: &mut Vec<DimensionValue>,
549 + ) {
550 + out.reserve(dims.len());
551 +
552 + for dim in dims.values() {
553 + out.push(DimensionValue {
554 + name: dim.name.clone(),
555 + value: Some(dim.aggregator.gap_fill()),
556 + });
557 + }
558 + }
559 +}
560 +
561 +#[cfg(test)]
562 +mod tests {
563 + use super::*;
564 +
565 + fn ns(secs: u64) -> u64 {
566 + secs * 1_000_000_000
567 + }
568 +
569 + fn ms(millis: u64) -> u64 {
570 + millis * 1_000_000
571 + }
572 +
573 + fn test_config() -> ChartConfig {
574 + ChartConfig {
575 + collection_interval: 1,
576 + expiry_duration: Duration::from_secs(300),
577 + grace_period: Duration::ZERO,
578 + }
579 + }
580 +
581 + fn gauge_chart() -> Chart {
582 + Chart::new(
583 + "test",
584 + ChartAggregationType::Gauge,
585 + ChartType::Line,
586 + test_config(),
587 + )
588 + }
589 +
590 + fn delta_sum_chart() -> Chart {
591 + Chart::new(
592 + "test",
593 + ChartAggregationType::DeltaSum,
594 + ChartType::Line,
595 + test_config(),
596 + )
597 + }
598 +
599 + fn cumulative_sum_chart() -> Chart {
600 + Chart::new(
601 + "test",
602 + ChartAggregationType::CumulativeSum,
603 + ChartType::Line,
604 + test_config(),
605 + )
606 + }
607 +
608 + /// Helper to find a dimension value by name in the chart's dim_values.
609 + fn find_dim<'a>(chart: &'a Chart, name: &str) -> &'a DimensionValue {
610 + chart.dim_values().iter().find(|d| d.name == name).unwrap()
611 + }
612 +
613 + /// Count how many SET lines are in the buf.
614 + fn count_sets(buf: &str) -> usize {
615 + buf.lines().filter(|line| line.starts_with("SET ")).count()
616 + }
617 +
618 + /// Count how many BEGIN lines are in the buf.
619 + fn count_begins(buf: &str) -> usize {
620 + buf.lines()
621 + .filter(|line| line.starts_with("BEGIN "))
622 + .count()
623 + }
624 +
625 + mod chart_creation {
626 + use super::*;
627 +
628 + #[test]
629 + fn creates_gauge_chart() {
630 + let chart = Chart::from_metric(
631 + "test",
632 + MetricDataKind::Gauge,
633 + None,
634 + None,
635 + ChartConfig::default(),
636 + );
637 + assert!(chart.is_some());
638 + }
639 +
640 + #[test]
641 + fn creates_delta_sum_chart() {
642 + let chart = Chart::from_metric(
643 + "test",
644 + MetricDataKind::Sum,
645 + Some(AggregationTemporality::Delta),
646 + None,
647 + ChartConfig::default(),
648 + );
649 + assert!(chart.is_some());
650 + }
651 +
652 + #[test]
653 + fn creates_cumulative_sum_chart() {
654 + let chart = Chart::from_metric(
655 + "test",
656 + MetricDataKind::Sum,
657 + Some(AggregationTemporality::Cumulative),
658 + None,
659 + ChartConfig::default(),
660 + );
661 + assert!(chart.is_some());
662 + }
663 +
664 + #[test]
665 + fn rejects_unsupported_types() {
666 + let chart = Chart::from_metric(
667 + "test",
668 + MetricDataKind::Histogram,
669 + None,
670 + None,
671 + ChartConfig::default(),
672 + );
673 + assert!(chart.is_none());
674 + }
675 +
676 + #[test]
677 + fn non_monotonic_cumulative_sum_uses_gauge_aggregation() {
678 + let chart = Chart::from_metric(
679 + "test",
680 + MetricDataKind::Sum,
681 + Some(AggregationTemporality::Cumulative),
682 + Some(false),
683 + ChartConfig::default(),
684 + );
685 + let chart = chart.expect("should create chart for non-monotonic cumulative sum");
686 + assert!(matches!(chart.dimensions, DimensionStore::Gauge(_)));
687 + }
688 +
689 + #[test]
690 + fn non_monotonic_cumulative_sum_behaves_as_gauge() {
691 + let mut chart = Chart::from_metric(
692 + "test",
693 + MetricDataKind::Sum,
694 + Some(AggregationTemporality::Cumulative),
695 + Some(false),
696 + test_config(),
697 + )
698 + .unwrap();
699 +
700 + // Ingest values — should keep last by timestamp (gauge behavior).
701 + chart.ingest("dim1", 42.0, ns(1), 0);
702 + chart.ingest("dim1", 50.0, ns(3), 0); // Latest
703 + chart.ingest("dim1", 45.0, ns(2), 0);
704 +
705 + let mut buf = String::new();
706 + chart.emit(1, &mut buf);
707 + assert_eq!(chart.dim_values()[0].value, Some(50.0));
708 +
709 + // Gap fill: repeats last value (gauge behavior, not 0).
710 + buf.clear();
711 + chart.emit(2, &mut buf);
712 + assert!(!buf.is_empty());
713 + assert_eq!(chart.dim_values()[0].value, Some(50.0));
714 + }
715 +
716 + #[test]
717 + fn monotonic_cumulative_sum_still_computes_deltas() {
718 + let chart = Chart::from_metric(
719 + "test",
720 + MetricDataKind::Sum,
721 + Some(AggregationTemporality::Cumulative),
722 + Some(true),
723 + ChartConfig::default(),
724 + );
725 + let chart = chart.expect("should create chart for monotonic cumulative sum");
726 + assert!(matches!(chart.dimensions, DimensionStore::CumulativeSum(_)));
727 + }
728 + }
729 +
730 + mod tick_driven_emission {
731 + use super::*;
732 +
733 + #[test]
734 + fn tick_without_data_emits_nothing() {
735 + let mut chart = gauge_chart();
736 + let mut buf = String::new();
737 + chart.emit(1, &mut buf);
738 + assert!(buf.is_empty());
739 + }
740 +
741 + #[test]
742 + fn ingest_then_tick_produces_update() {
743 + let mut chart = gauge_chart();
744 +
745 + chart.ingest("dim1", 42.0, ns(5), 0);
746 + let mut buf = String::new();
747 + chart.emit(1, &mut buf);
748 + assert!(!buf.is_empty());
749 +
750 + assert_eq!(chart.dim_values().len(), 1);
751 + assert_eq!(chart.dim_values()[0].name, "dim1");
752 + assert_eq!(chart.dim_values()[0].value, Some(42.0));
753 + assert!(buf.contains("END 2\n")); // slot 1 + interval 1
754 + }
755 +
756 + #[test]
757 + fn tick_after_expiry_emits_nothing() {
758 + let mut chart = Chart::new(
759 + "test",
760 + ChartAggregationType::Gauge,
761 + ChartType::Line,
762 + ChartConfig {
763 + collection_interval: 1,
764 + expiry_duration: Duration::ZERO,
765 + grace_period: Duration::ZERO,
766 + },
767 + );
768 +
769 + chart.ingest("dim1", 42.0, ns(5), 0);
770 +
771 + // With zero expiry, the chart is immediately expired.
772 + let mut buf = String::new();
773 + chart.emit(1, &mut buf);
774 + assert!(buf.is_empty());
775 + }
776 +
777 + #[test]
778 + fn consecutive_ticks_gap_fill() {
779 + let mut chart = gauge_chart();
780 +
781 + // Ingest data, then tick to finalize.
782 + chart.ingest("dim1", 42.0, ns(5), 0);
783 + let mut buf = String::new();
784 + chart.emit(1, &mut buf);
785 + assert!(!buf.is_empty());
786 + assert_eq!(chart.dim_values()[0].value, Some(42.0));
787 +
788 + // Second tick with no new data and zero grace: gap-fills by
789 + // repeating the last gauge value.
790 + buf.clear();
791 + chart.emit(2, &mut buf);
792 + assert!(!buf.is_empty());
793 + assert!(buf.contains("END 3\n")); // slot 2 + interval 1
794 + assert_eq!(chart.dim_values()[0].value, Some(42.0));
795 + }
796 +
797 + #[test]
798 + fn tick_sets_slot_timestamp_from_caller() {
799 + let mut chart = gauge_chart();
800 +
801 + chart.ingest("dim1", 1.0, ns(1), 0);
802 + let mut buf = String::new();
803 + chart.emit(1000, &mut buf);
804 + assert!(buf.contains("END 1001\n")); // slot 1000 + interval 1
805 +
806 + // Second tick with no data → gap slot at 1001.
807 + buf.clear();
808 + chart.emit(1001, &mut buf);
809 + assert!(buf.contains("END 1002\n")); // slot 1001 + interval 1
810 + }
811 +
812 + #[test]
813 + fn tick_skips_when_no_data_within_grace() {
814 + let mut chart = Chart::new(
815 + "test",
816 + ChartAggregationType::Gauge,
817 + ChartType::Line,
818 + ChartConfig {
819 + collection_interval: 1,
820 + expiry_duration: Duration::from_secs(300),
821 + grace_period: Duration::from_secs(5),
822 + },
823 + );
824 +
825 + // Ingest data and tick to emit.
826 + chart.ingest("dim1", 42.0, ns(5), 0);
827 + let mut buf = String::new();
828 + chart.emit(1, &mut buf);
829 + assert!(!buf.is_empty());
830 +
831 + // Tick again with no new data — grace period is still active,
832 + // so the tick should skip.
833 + buf.clear();
834 + chart.emit(2, &mut buf);
835 + assert!(buf.is_empty());
836 + }
837 +
838 + #[test]
839 + fn tick_gap_fills_after_grace_expires() {
840 + let mut chart = Chart::new(
841 + "test",
842 + ChartAggregationType::Gauge,
843 + ChartType::Line,
844 + ChartConfig {
845 + collection_interval: 1,
846 + expiry_duration: Duration::from_secs(300),
847 + grace_period: Duration::ZERO,
848 + },
849 + );
850 +
851 + // Ingest data and tick to emit.
852 + chart.ingest("dim1", 42.0, ns(5), 0);
853 + let mut buf = String::new();
854 + chart.emit(1, &mut buf);
855 + assert!(!buf.is_empty());
856 + assert_eq!(chart.dim_values()[0].value, Some(42.0));
857 +
858 + // Tick with no new data and zero grace period — gap-fill repeats
859 + // the last gauge value.
860 + buf.clear();
861 + chart.emit(2, &mut buf);
862 + assert!(!buf.is_empty());
863 + assert!(buf.contains("END 3\n")); // slot 2 + interval 1
864 + assert_eq!(chart.dim_values()[0].value, Some(42.0));
865 + }
866 +
867 + #[test]
868 + fn tick_respects_interval_boundary() {
869 + let mut chart = Chart::new(
870 + "test",
871 + ChartAggregationType::Gauge,
872 + ChartType::Line,
873 + ChartConfig {
874 + collection_interval: 10,
875 + expiry_duration: Duration::from_secs(300),
876 + grace_period: Duration::ZERO,
877 + },
878 + );
879 +
880 + // Ingest data so the chart is active.
881 + chart.ingest("dim1", 42.0, ns(5), 0);
882 +
883 + // Tick at t=5: slot boundary = 0, first emission.
884 + let mut buf = String::new();
885 + chart.emit(5, &mut buf);
886 + assert!(!buf.is_empty());
887 +
888 + // Tick at t=9: same slot boundary (0), should not emit.
889 + chart.ingest("dim1", 43.0, ns(9), 0);
890 + buf.clear();
891 + chart.emit(9, &mut buf);
892 + assert!(buf.is_empty());
893 +
894 + // Tick at t=10: new slot boundary (10), should emit.
895 + chart.ingest("dim1", 44.0, ns(10), 0);
896 + buf.clear();
897 + chart.emit(10, &mut buf);
898 + assert!(!buf.is_empty());
899 + assert_eq!(chart.dim_values()[0].value, Some(44.0));
900 +
901 + // Tick at t=11: same slot boundary (10), should not emit.
902 + chart.ingest("dim1", 45.0, ns(11), 0);
903 + buf.clear();
904 + chart.emit(11, &mut buf);
905 + assert!(buf.is_empty());
906 +
907 + // Tick at t=20: new slot boundary (20), should emit.
908 + chart.ingest("dim1", 46.0, ns(20), 0);
909 + buf.clear();
910 + chart.emit(20, &mut buf);
911 + assert!(!buf.is_empty());
912 + assert_eq!(chart.dim_values()[0].value, Some(46.0));
913 + }
914 +
915 + #[test]
916 + fn delta_sum_tick_after_expiry_emits_nothing() {
917 + let mut chart = Chart::new(
918 + "test",
919 + ChartAggregationType::DeltaSum,
920 + ChartType::Line,
921 + ChartConfig {
922 + collection_interval: 1,
923 + expiry_duration: Duration::ZERO,
924 + grace_period: Duration::ZERO,
925 + },
926 + );
927 +
928 + chart.ingest("dim1", 10.0, ns(5), 0);
929 +
930 + let mut buf = String::new();
931 + chart.emit(1, &mut buf);
932 + assert!(buf.is_empty());
933 + }
934 +
935 + #[test]
936 + fn delta_sum_tick_skips_when_no_data_within_grace() {
937 + let mut chart = Chart::new(
938 + "test",
939 + ChartAggregationType::DeltaSum,
940 + ChartType::Line,
941 + ChartConfig {
942 + collection_interval: 1,
943 + expiry_duration: Duration::from_secs(300),
944 + grace_period: Duration::from_secs(5),
945 + },
946 + );
947 +
948 + chart.ingest("dim1", 10.0, ns(5), 0);
949 + let mut buf = String::new();
950 + chart.emit(1, &mut buf);
951 + assert!(!buf.is_empty());
952 +
953 + // Tick again with no new data — grace period is still active.
954 + buf.clear();
955 + chart.emit(2, &mut buf);
956 + assert!(buf.is_empty());
957 + }
958 +
959 + #[test]
960 + fn cumulative_sum_tick_after_expiry_emits_nothing() {
961 + let mut chart = Chart::new(
962 + "test",
963 + ChartAggregationType::CumulativeSum,
964 + ChartType::Line,
965 + ChartConfig {
966 + collection_interval: 1,
967 + expiry_duration: Duration::ZERO,
968 + grace_period: Duration::ZERO,
969 + },
970 + );
971 +
972 + chart.ingest("dim1", 100.0, ns(5), 1_000_000_000);
973 +
974 + let mut buf = String::new();
975 + chart.emit(1, &mut buf);
976 + assert!(buf.is_empty());
977 + }
978 +
979 + #[test]
980 + fn cumulative_sum_tick_skips_when_no_data_within_grace() {
981 + let mut chart = Chart::new(
982 + "test",
983 + ChartAggregationType::CumulativeSum,
984 + ChartType::Line,
985 + ChartConfig {
986 + collection_interval: 1,
987 + expiry_duration: Duration::from_secs(300),
988 + grace_period: Duration::from_secs(5),
989 + },
990 + );
991 +
992 + chart.ingest("dim1", 100.0, ns(5), 1_000_000_000);
993 + let mut buf = String::new();
994 + chart.emit(1, &mut buf);
995 + assert!(!buf.is_empty());
996 +
997 + // Tick again with no new data — grace period is still active.
998 + buf.clear();
999 + chart.emit(2, &mut buf);
1000 + assert!(buf.is_empty());
1001 + }
1002 + }
1003 +
1004 + mod gap_fill_emission {
1005 + use super::*;
1006 +
1007 + #[test]
1008 + fn tick_emits_catchup_slots_before_data() {
1009 + let mut chart = Chart::new(
1010 + "test",
1011 + ChartAggregationType::Gauge,
1012 + ChartType::Line,
1013 + ChartConfig {
1014 + collection_interval: 10,
1015 + expiry_duration: Duration::from_secs(300),
1016 + grace_period: Duration::ZERO,
1017 + },
1018 + );
1019 +
1020 + // Data at slot 0.
1021 + chart.ingest("dim1", 1.0, ns(5), 0);
1022 + let mut buf = String::new();
1023 + chart.emit(0, &mut buf);
1024 + assert!(!buf.is_empty());
1025 +
1026 + // Data at slot 30 — should produce gap-filled catchup slots at
1027 + // 10 and 20 (repeating gauge value 1.0), then data at 30.
1028 + chart.ingest("dim1", 2.0, ns(30), 0);
1029 + buf.clear();
1030 + chart.emit(30, &mut buf);
1031 + assert!(!buf.is_empty());
1032 +
1033 + // 2 catchup slots + 1 data slot = 3 BEGIN lines.
1034 + assert_eq!(count_begins(&buf), 3);
1035 +
1036 + // All 3 slots have SET lines (gap-filled catchup + real data).
1037 + assert_eq!(count_sets(&buf), 3);
1038 +
1039 + // Verify END timestamps (slot + interval 10).
1040 + assert!(buf.contains("END 20\n"));
1041 + assert!(buf.contains("END 30\n"));
1042 + assert!(buf.contains("END 40\n"));
1043 +
1044 + // The data slot at 30 must have the NEW value (2.0), not a
1045 + // gap-fill. This verifies that catchup slots don't consume
1046 + // the pending data.
1047 + assert_eq!(chart.dim_values()[0].value, Some(2.0));
1048 + }
1049 +
1050 + #[test]
1051 + fn tick_drains_one_fill_per_tick() {
1052 + let mut chart = gauge_chart();
1053 +
1054 + // Ingest and emit at slot 1.
1055 + chart.ingest("dim1", 1.0, ns(5), 0);
1056 + let mut buf = String::new();
1057 + chart.emit(1, &mut buf);
1058 + assert!(!buf.is_empty());
1059 +
1060 + // Grace = ZERO, so each subsequent tick with no data emits one
1061 + // gap-filled slot (repeating the last gauge value).
1062 + buf.clear();
1063 + chart.emit(5, &mut buf);
1064 + assert_eq!(count_begins(&buf), 1);
1065 + assert_eq!(count_sets(&buf), 1);
1066 + assert!(buf.contains("END 3\n")); // slot 2 + interval 1
1067 + assert_eq!(chart.dim_values()[0].value, Some(1.0));
1068 +
1069 + buf.clear();
1070 + chart.emit(5, &mut buf);
1071 + assert_eq!(count_begins(&buf), 1);
1072 + assert!(buf.contains("END 4\n")); // slot 3 + interval 1
1073 + assert_eq!(chart.dim_values()[0].value, Some(1.0));
1074 +
1075 + buf.clear();
1076 + chart.emit(5, &mut buf);
1077 + assert!(buf.contains("END 5\n")); // slot 4 + interval 1
1078 + assert_eq!(chart.dim_values()[0].value, Some(1.0));
1079 + }
1080 +
1081 + #[test]
1082 + fn tick_first_emission_no_preceding_catchup() {
1083 + let mut chart = gauge_chart();
1084 +
1085 + // First data ever — no catchup slots should precede it.
1086 + chart.ingest("dim1", 1.0, ns(100), 0);
1087 + let mut buf = String::new();
1088 + chart.emit(100, &mut buf);
1089 +
1090 + assert_eq!(count_begins(&buf), 1);
1091 + assert_eq!(count_sets(&buf), 1);
1092 + assert!(buf.contains("END 101\n")); // slot 100 + interval 1
1093 + }
1094 +
1095 + #[test]
1096 + fn delta_sum_gap_fills_with_zero() {
1097 + let mut chart = delta_sum_chart();
1098 +
1099 + chart.ingest("dim1", 10.0, ns(1), 0);
1100 + let mut buf = String::new();
1101 + chart.emit(1, &mut buf);
1102 + assert_eq!(chart.dim_values()[0].value, Some(10.0));
1103 +
1104 + // No new data — gap-fill emits 0 for delta sums.
1105 + buf.clear();
1106 + chart.emit(2, &mut buf);
1107 + assert!(!buf.is_empty());
1108 + assert_eq!(count_sets(&buf), 1);
1109 + assert_eq!(chart.dim_values()[0].value, Some(0.0));
1110 + }
1111 +
1112 + #[test]
1113 + fn gauge_catchup_repeats_last_value() {
1114 + let mut chart = Chart::new(
1115 + "test",
1116 + ChartAggregationType::Gauge,
1117 + ChartType::Line,
1118 + ChartConfig {
1119 + collection_interval: 10,
1120 + expiry_duration: Duration::from_secs(300),
1121 + grace_period: Duration::ZERO,
1122 + },
1123 + );
1124 +
1125 + // Emit at slot 0 with value 42.0.
1126 + chart.ingest("dim1", 42.0, ns(5), 0);
1127 + let mut buf = String::new();
1128 + chart.emit(0, &mut buf);
1129 +
1130 + // Data at slot 20 — catchup at slot 10 should repeat 42.0.
1131 + chart.ingest("dim1", 99.0, ns(20), 0);
1132 + buf.clear();
1133 + chart.emit(20, &mut buf);
1134 +
1135 + // 1 catchup + 1 data = 2 BEGIN/SET/END blocks.
1136 + assert_eq!(count_begins(&buf), 2);
1137 + assert_eq!(count_sets(&buf), 2);
1138 + assert!(buf.contains("END 20\n")); // catchup slot 10 + interval 10
1139 + assert!(buf.contains("END 30\n")); // data slot 20 + interval 10
1140 + }
1141 +
1142 + #[test]
1143 + fn delta_sum_catchup_emits_zero() {
1144 + let mut chart = Chart::new(
1145 + "test",
1146 + ChartAggregationType::DeltaSum,
1147 + ChartType::Line,
1148 + ChartConfig {
1149 + collection_interval: 10,
1150 + expiry_duration: Duration::from_secs(300),
1151 + grace_period: Duration::ZERO,
1152 + },
1153 + );
1154 +
1155 + // Emit at slot 0 with delta 10.
1156 + chart.ingest("dim1", 10.0, ns(5), 0);
1157 + let mut buf = String::new();
1158 + chart.emit(0, &mut buf);
1159 + assert_eq!(chart.dim_values()[0].value, Some(10.0));
1160 +
1161 + // Data at slot 20 — catchup at slot 10 should emit 0.
1162 + chart.ingest("dim1", 5.0, ns(20), ns(10));
1163 + buf.clear();
1164 + chart.emit(20, &mut buf);
1165 +
1166 + // 1 catchup + 1 data = 2 BEGIN/SET/END blocks.
1167 + assert_eq!(count_begins(&buf), 2);
1168 + assert_eq!(count_sets(&buf), 2);
1169 + assert!(buf.contains("END 20\n")); // catchup slot 10 + interval 10
1170 + assert!(buf.contains("END 30\n")); // data slot 20 + interval 10
1171 +
1172 + // Data slot has the new delta value.
1173 + assert_eq!(chart.dim_values()[0].value, Some(5.0));
1174 + }
1175 +
1176 + #[test]
1177 + fn cumulative_sum_catchup_emits_zero() {
1178 + let mut chart = Chart::new(
1179 + "test",
1180 + ChartAggregationType::CumulativeSum,
1181 + ChartType::Line,
1182 + ChartConfig {
1183 + collection_interval: 10,
1184 + expiry_duration: Duration::from_secs(300),
1185 + grace_period: Duration::ZERO,
1186 + },
1187 + );
1188 +
1189 + const START_TIME: u64 = 1_000_000_000;
1190 +
1191 + // Slot 0: baseline (first slot returns None for cumulative sum).
1192 + chart.ingest("dim1", 100.0, ns(5), START_TIME);
1193 + let mut buf = String::new();
1194 + chart.emit(0, &mut buf);
1195 + assert_eq!(chart.dim_values()[0].value, None);
1196 +
1197 + // Data at slot 20 — catchup at slot 10 should gap-fill with 0.
1198 + chart.ingest("dim1", 150.0, ns(20), START_TIME);
1199 + buf.clear();
1200 + chart.emit(20, &mut buf);
1201 +
1202 + // 1 catchup + 1 data = 2 BEGIN/SET/END blocks.
1203 + assert_eq!(count_begins(&buf), 2);
1204 + assert!(buf.contains("END 20\n")); // catchup slot 10 + interval 10
1205 + assert!(buf.contains("END 30\n")); // data slot 20 + interval 10
1206 +
1207 + // Data slot: delta = 150 - 100 = 50.
1208 + assert_eq!(chart.dim_values()[0].value, Some(50.0));
1209 + }
1210 + }
1211 +
1212 + mod slot_tracking {
1213 + use super::*;
1214 +
1215 + #[test]
1216 + fn drops_data_for_previous_slot() {
1217 + let mut chart = gauge_chart();
1218 +
1219 + // Active slot becomes 1.
1220 + chart.ingest("dim1", 50.0, ns(1), 0);
1221 +
1222 + // Data for slot 0 — should be dropped.
1223 + chart.ingest("dim1", 42.0, ns(0), 0);
1224 +
1225 + let mut buf = String::new();
1226 + chart.emit(1, &mut buf);
1227 + assert_eq!(chart.dim_values()[0].value, Some(50.0));
1228 + }
1229 +
1230 + #[test]
1231 + fn delta_sum_slot_transition_resets_accumulator() {
1232 + let mut chart = delta_sum_chart();
1233 +
1234 + // Slot 0: accumulate delta=10.
1235 + chart.ingest("dim1", 10.0, ns(0), 0);
1236 +
1237 + // Slot 1: transition resets per-slot state; accumulate delta=5.
1238 + chart.ingest("dim1", 5.0, ns(1), ns(0));
1239 +
1240 + // Tick should see only the slot-1 delta (10 was finalized on transition).
1241 + let mut buf = String::new();
1242 + chart.emit(1, &mut buf);
1243 + assert_eq!(chart.dim_values()[0].value, Some(5.0));
1244 + }
1245 +
1246 + #[test]
1247 + fn cumulative_sum_slot_transition_advances_baseline() {
1248 + let mut chart = cumulative_sum_chart();
1249 +
1250 + const START_TIME: u64 = 1_000_000_000;
1251 +
1252 + // Slot 0: baseline cumulative=100.
1253 + chart.ingest("dim1", 100.0, ns(0), START_TIME);
1254 +
1255 + // Slot 1: transition finalizes slot 0 (promoting 100 to previous),
1256 + // then ingest cumulative=150.
1257 + chart.ingest("dim1", 150.0, ns(1), START_TIME);
1258 +
1259 + // Tick: delta should be 150 - 100 = 50.
1260 + let mut buf = String::new();
1261 + chart.emit(1, &mut buf);
1262 + assert_eq!(chart.dim_values()[0].value, Some(50.0));
1263 + }
1264 +
1265 + #[test]
1266 + fn cumulative_sum_restart_across_slot_transition() {
1267 + let mut chart = cumulative_sum_chart();
1268 +
1269 + const START_TIME: u64 = 1_000_000_000;
1270 +
1271 + // Slot 0: baseline.
1272 + chart.ingest("dim1", 100.0, ns(0), START_TIME);
1273 +
1274 + // Slot 1: normal delta.
1275 + chart.ingest("dim1", 150.0, ns(1), START_TIME);
1276 +
1277 + // Slot 2: restart (new start_time).
1278 + let new_start = START_TIME + 1_000_000;
1279 + chart.ingest("dim1", 20.0, ns(2), new_start);
1280 +
1281 + // Tick: restart slot should report 0.
1282 + let mut buf = String::new();
1283 + chart.emit(2, &mut buf);
1284 + assert_eq!(chart.dim_values()[0].value, Some(0.0));
1285 + }
1286 +
1287 + #[test]
1288 + fn multi_slot_ingest_then_tick() {
1289 + let mut chart = gauge_chart();
1290 +
1291 + // Data spanning three slots arrives before tick fires.
1292 + chart.ingest("dim1", 10.0, ns(0), 0);
1293 + chart.ingest("dim1", 20.0, ns(1), 0);
1294 + chart.ingest("dim1", 30.0, ns(2), 0);
1295 +
1296 + // Tick sees only the last slot's value (slot transitions
1297 + // finalized the earlier ones).
1298 + let mut buf = String::new();
1299 + chart.emit(2, &mut buf);
1300 + assert_eq!(chart.dim_values()[0].value, Some(30.0));
1301 + }
1302 +
1303 + #[test]
1304 + fn gap_fill_across_slot_transition() {
1305 + let mut chart = gauge_chart();
1306 +
1307 + // Slot 0: both dimensions.
1308 + chart.ingest("dim1", 10.0, ns(0), 0);
1309 + chart.ingest("dim2", 20.0, ns(0), 0);
1310 +
1311 + // Slot 1: only dim1 — triggers slot transition which finalizes
1312 + // dim2 via gap_fill, establishing its last_emitted value.
1313 + chart.ingest("dim1", 15.0, ns(1), 0);
1314 +
1315 + // Tick: dim1 has slot-1 data, dim2 should gap-fill.
1316 + let mut buf = String::new();
1317 + chart.emit(1, &mut buf);
1318 + assert_eq!(find_dim(&chart, "dim1").value, Some(15.0));
1319 + assert_eq!(find_dim(&chart, "dim2").value, Some(20.0));
1320 + }
1321 + }
1322 +
1323 + mod gauge_aggregation {
1324 + use super::*;
1325 +
1326 + #[test]
1327 + fn keeps_last_value_by_timestamp() {
1328 + let mut chart = gauge_chart();
1329 +
1330 + chart.ingest("dim1", 10.0, ns(1), 0);
1331 + chart.ingest("dim1", 30.0, ns(3), 0); // Latest
1332 + chart.ingest("dim1", 20.0, ns(2), 0);
1333 +
1334 + let mut buf = String::new();
1335 + chart.emit(1, &mut buf);
1336 + assert_eq!(chart.dim_values()[0].value, Some(30.0));
1337 + }
1338 +
1339 + #[test]
1340 + fn gap_fills_missing_dimension() {
1341 + let mut chart = gauge_chart();
1342 +
1343 + // Both dimensions get data.
1344 + chart.ingest("dim1", 10.0, ns(5), 0);
1345 + chart.ingest("dim2", 20.0, ns(5), 0);
1346 +
1347 + // Tick finalizes both.
1348 + let mut buf = String::new();
1349 + chart.emit(1, &mut buf);
1350 + assert_eq!(find_dim(&chart, "dim1").value, Some(10.0));
1351 + assert_eq!(find_dim(&chart, "dim2").value, Some(20.0));
1352 +
1353 + // Only dim1 gets new data.
1354 + chart.ingest("dim1", 15.0, ns(6), 0);
1355 +
1356 + // Tick: dim2 should be gap-filled with previous value.
1357 + buf.clear();
1358 + chart.emit(2, &mut buf);
1359 + assert_eq!(find_dim(&chart, "dim1").value, Some(15.0));
1360 + assert_eq!(find_dim(&chart, "dim2").value, Some(20.0));
1361 + }
1362 +
1363 + #[test]
1364 + fn out_of_order_timestamps_keeps_latest() {
1365 + let mut chart = gauge_chart();
1366 +
1367 + chart.ingest("dim1", 20.0, ns(2), 0);
1368 + chart.ingest("dim1", 30.0, ns(3), 0); // Latest
1369 + chart.ingest("dim1", 10.0, ns(1), 0);
1370 +
1371 + let mut buf = String::new();
1372 + chart.emit(1, &mut buf);
1373 + assert_eq!(chart.dim_values()[0].value, Some(30.0));
1374 + }
1375 +
1376 + #[test]
1377 + fn multi_dimension_gap_fill() {
1378 + let mut chart = gauge_chart();
1379 +
1380 + // All three dimensions get data.
1381 + chart.ingest("dim1", 100.0, ns(5), 0);
1382 + chart.ingest("dim2", 200.0, ns(5), 0);
1383 + chart.ingest("dim3", 300.0, ns(5), 0);
1384 +
1385 + let mut buf = String::new();
1386 + chart.emit(1, &mut buf);
1387 + assert_eq!(chart.dim_values().len(), 3);
1388 + assert_eq!(find_dim(&chart, "dim1").value, Some(100.0));
1389 + assert_eq!(find_dim(&chart, "dim2").value, Some(200.0));
1390 + assert_eq!(find_dim(&chart, "dim3").value, Some(300.0));
1391 +
1392 + // Only dim1 gets new data.
1393 + chart.ingest("dim1", 110.0, ns(6), 0);
1394 +
1395 + buf.clear();
1396 + chart.emit(2, &mut buf);
1397 + assert_eq!(find_dim(&chart, "dim1").value, Some(110.0));
1398 + assert_eq!(find_dim(&chart, "dim2").value, Some(200.0)); // gap-fill
1399 + assert_eq!(find_dim(&chart, "dim3").value, Some(300.0)); // gap-fill
1400 + }
1401 + }
1402 +
1403 + mod delta_sum_aggregation {
1404 + use super::*;
1405 +
1406 + #[test]
1407 + fn sums_deltas() {
1408 + let mut chart = delta_sum_chart();
1409 +
1410 + // All within the same slot (same second).
1411 + chart.ingest("dim1", 10.0, ms(100), 0);
1412 + chart.ingest("dim1", 20.0, ms(200), ms(100));
1413 + chart.ingest("dim1", 5.0, ms(300), ms(200));
1414 +
1415 + let mut buf = String::new();
1416 + chart.emit(1, &mut buf);
1417 + assert_eq!(chart.dim_values()[0].value, Some(35.0));
1418 + }
1419 +
1420 + #[test]
1421 + fn accumulates_correctly_with_multiple_ingests() {
1422 + let mut chart = delta_sum_chart();
1423 +
1424 + // All within the same slot (same second).
1425 + chart.ingest("dim1", 5.0, ms(100), 0);
1426 + chart.ingest("dim1", 10.0, ms(200), ms(100));
1427 + chart.ingest("dim1", 15.0, ms(300), ms(200));
1428 + chart.ingest("dim1", 20.0, ms(400), ms(300));
1429 +
1430 + let mut buf = String::new();
1431 + chart.emit(1, &mut buf);
1432 + assert_eq!(chart.dim_values()[0].value, Some(50.0));
1433 + }
1434 +
1435 + #[test]
1436 + fn no_data_gap_fills_with_zero() {
1437 + let mut chart = delta_sum_chart();
1438 +
1439 + chart.ingest("dim1", 10.0, ns(1), 0);
1440 + let mut buf = String::new();
1441 + chart.emit(1, &mut buf);
1442 + assert_eq!(chart.dim_values()[0].value, Some(10.0));
1443 +
1444 + // No new data — gap-fill emits 0 for delta sums.
1445 + buf.clear();
1446 + chart.emit(2, &mut buf);
1447 + assert!(!buf.is_empty());
1448 + assert_eq!(count_sets(&buf), 1);
1449 + assert_eq!(chart.dim_values()[0].value, Some(0.0));
1450 + }
1451 +
1452 + #[test]
1453 + fn non_monotonic_delta_sum_accumulates() {
1454 + // Non-monotonic delta sums behave identically to monotonic:
1455 + // deltas are accumulated within a slot.
1456 + let mut chart = Chart::from_metric(
1457 + "test",
1458 + MetricDataKind::Sum,
1459 + Some(AggregationTemporality::Delta),
1460 + Some(false),
1461 + test_config(),
1462 + )
1463 + .unwrap();
1464 +
1465 + // Accumulate deltas, including a negative one.
1466 + chart.ingest("dim1", 10.0, ms(100), 0);
1467 + chart.ingest("dim1", -3.0, ms(200), ms(100));
1468 + chart.ingest("dim1", 5.0, ms(300), ms(200));
1469 +
1470 + let mut buf = String::new();
1471 + chart.emit(1, &mut buf);
1472 + assert_eq!(chart.dim_values()[0].value, Some(12.0));
1473 + }
1474 + }
1475 +
1476 + mod cumulative_sum_aggregation {
1477 + use super::*;
1478 +
1479 + const START_TIME: u64 = 1_000_000_000;
1480 +
1481 + #[test]
1482 + fn first_slot_returns_none() {
1483 + let mut chart = cumulative_sum_chart();
1484 +
1485 + chart.ingest("dim1", 100.0, ns(5), START_TIME);
1486 + let mut buf = String::new();
1487 + chart.emit(1, &mut buf);
1488 + assert_eq!(chart.dim_values()[0].value, None);
1489 + }
1490 +
1491 + #[test]
1492 + fn computes_deltas_across_ticks() {
1493 + let mut chart = cumulative_sum_chart();
1494 +
1495 + // First tick: baseline, no delta.
1496 + chart.ingest("dim1", 100.0, ns(5), START_TIME);
1497 + let mut buf = String::new();
1498 + chart.emit(1, &mut buf);
1499 + assert_eq!(chart.dim_values()[0].value, None);
1500 +
1501 + // Second tick: delta = 150 - 100 = 50.
1502 + chart.ingest("dim1", 150.0, ns(6), START_TIME);
1503 + buf.clear();
1504 + chart.emit(2, &mut buf);
1505 + assert_eq!(chart.dim_values()[0].value, Some(50.0));
1506 + }
1507 +
1508 + #[test]
1509 + fn detects_restart() {
1510 + let mut chart = cumulative_sum_chart();
1511 +
1512 + // Establish baseline.
1513 + chart.ingest("dim1", 100.0, ns(5), START_TIME);
1514 + let mut buf = String::new();
1515 + chart.emit(1, &mut buf);
1516 +
1517 + // Normal delta.
1518 + chart.ingest("dim1", 150.0, ns(6), START_TIME);
1519 + buf.clear();
1520 + chart.emit(2, &mut buf);
1521 + assert_eq!(chart.dim_values()[0].value, Some(50.0));
1522 +
1523 + // Restart: new start_time.
1524 + let new_start = START_TIME + 1_000_000;
1525 + chart.ingest("dim1", 20.0, ns(7), new_start);
1526 + buf.clear();
1527 + chart.emit(3, &mut buf);
1528 + assert_eq!(chart.dim_values()[0].value, Some(0.0));
1529 + }
1530 + }
1531 +
1532 + mod definition {
1533 + use super::*;
1534 +
1535 + #[test]
1536 + fn new_dimension_invalidates_definition() {
1537 + let mut chart = gauge_chart();
1538 +
1539 + chart.init_definition("metric", "title", "units", vec![]);
1540 + // Emit definition to mark as emitted.
1541 + let mut buf = String::new();
1542 + chart.emit_definition_if_needed(&mut buf);
1543 + assert!(!chart.needs_definition());
1544 +
1545 + // Ingest a new dimension.
1546 + chart.ingest("dim1", 1.0, ns(1), 0);
1547 + assert!(chart.needs_definition());
1548 + }
1549 +
1550 + #[test]
1551 + fn definition_tracks_dimensions() {
1552 + let mut chart = gauge_chart();
1553 +
1554 + chart.init_definition("metric", "title", "units", vec![]);
1555 +
1556 + chart.ingest("dim1", 1.0, ns(1), 0);
1557 + chart.ingest("dim2", 2.0, ns(1), 0);
1558 +
1559 + let def = chart.definition().unwrap();
1560 + assert_eq!(def.dimensions.len(), 2);
1561 + assert!(def.dimensions.contains(&"dim1".to_string()));
1562 + assert!(def.dimensions.contains(&"dim2".to_string()));
1563 + }
1564 +
1565 + #[test]
1566 + fn tick_definition_includes_store_first() {
1567 + let mut chart = gauge_chart();
1568 +
1569 + chart.init_definition("metric", "title", "units", vec![]);
1570 + chart.ingest("dim1", 1.0, ns(1), 0);
1571 +
1572 + let mut buf = String::new();
1573 + chart.emit(1, &mut buf);
1574 +
1575 + // The CHART line should include 'store_first'.
1576 + let chart_line = buf.lines().find(|l| l.starts_with("CHART ")).unwrap();
1577 + assert!(
1578 + chart_line.contains("'store_first'"),
1579 + "CHART line missing store_first: {}",
1580 + chart_line
1581 + );
1582 + }
1583 +
1584 + #[test]
1585 + fn line_chart_emits_line_type() {
1586 + let mut chart = Chart::new(
1587 + "test",
1588 + ChartAggregationType::Gauge,
1589 + ChartType::Line,
1590 + test_config(),
1591 + );
1592 +
1593 + chart.init_definition("metric", "title", "units", vec![]);
1594 + chart.ingest("dim1", 1.0, ns(1), 0);
1595 +
1596 + let mut buf = String::new();
1597 + chart.emit(1, &mut buf);
1598 +
1599 + let chart_line = buf.lines().find(|l| l.starts_with("CHART ")).unwrap();
1600 + assert!(
1601 + chart_line.contains(" line "),
1602 + "CHART line should contain 'line': {}",
1603 + chart_line
1604 + );
1605 + }
1606 +
1607 + #[test]
1608 + fn heatmap_chart_emits_heatmap_type() {
1609 + let mut chart = Chart::new(
1610 + "test",
1611 + ChartAggregationType::DeltaSum,
1612 + ChartType::Heatmap,
1613 + test_config(),
1614 + );
1615 +
1616 + chart.init_definition("metric", "title", "units", vec![]);
1617 + chart.ingest("dim1", 1.0, ns(1), 0);
1618 +
1619 + let mut buf = String::new();
1620 + chart.emit(1, &mut buf);
1621 +
1622 + let chart_line = buf.lines().find(|l| l.starts_with("CHART ")).unwrap();
1623 + assert!(
1624 + chart_line.contains(" heatmap "),
1625 + "CHART line should contain 'heatmap': {}",
1626 + chart_line
1627 + );
1628 + }
1629 + }
1630 +}
src/crates/netdata-otel/otel-plugin/src/chart_config.rs
+409 -75
@@ -1,76 +1,109 @@
1 +//! Configuration types and management for metric processing.
2 +
3 +use std::collections::HashMap;
4 +use std::sync::Arc;
5 +use std::time::Duration;
6 +
7 use anyhow::{Context, Result};
8 +use opentelemetry_proto::tonic::common::v1::InstrumentationScope;
9 use regex::Regex;
10 use serde::{Deserialize, Serialize};
4 -use serde_json::{Map as JsonMap, Value as JsonValue};
5 -use std::fs;
6 -use std::path::Path;
11
8 -#[derive(Debug, Clone, Serialize, Deserialize)]
9 -pub struct SelectCriteria {
10 - #[serde(with = "serde_regex", skip_serializing_if = "Option::is_none", default)]
11 - pub instrumentation_scope_name: Option<Regex>,
12 +use crate::chart::ChartConfig;
13 +use crate::iter::MetricRef;
14
15 +/// Pattern matching for instrumentation scope fields
16 +#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17 +pub struct InstrumentationScopePattern {
18 #[serde(with = "serde_regex", skip_serializing_if = "Option::is_none", default)]
14 - pub instrumentation_scope_version: Option<Regex>,
15 -
16 - #[serde(with = "serde_regex")]
17 - pub metric_name: Regex,
18 -}
19 + pub name: Option<Regex>,
20
20 -#[derive(Debug, Clone, Serialize, Deserialize)]
21 -pub struct ExtractPattern {
22 - #[serde(skip_serializing_if = "Option::is_none")]
23 - pub chart_instance_pattern: Option<String>,
24 -
25 - #[serde(skip_serializing_if = "Option::is_none")]
26 - pub dimension_name: Option<String>,
21 + #[serde(with = "serde_regex", skip_serializing_if = "Option::is_none", default)]
22 + pub version: Option<Regex>,
23 }
24
29 -#[derive(Debug, Clone, Serialize, Deserialize)]
30 -pub struct ChartConfig {
31 - pub select: SelectCriteria,
32 - pub extract: ExtractPattern,
33 -}
25 +impl InstrumentationScopePattern {
26 + /// Check if this pattern matches the given instrumentation scope
27 + pub fn matches(&self, scope: Option<&InstrumentationScope>) -> bool {
28 + let scope = match scope {
29 + Some(s) => s,
30 + None => return self.name.is_none() && self.version.is_none(),
31 + };
32
35 -impl ChartConfig {
36 - pub fn matches(&self, json_map: &JsonMap<String, JsonValue>) -> bool {
37 - if let Some(scope_regex) = &self.select.instrumentation_scope_name {
38 - if let Some(JsonValue::String(scope_name)) = json_map.get("scope.name") {
39 - if !scope_regex.is_match(scope_name) {
40 - return false;
41 - }
42 - } else {
33 + if let Some(r) = &self.name {
34 + if !r.is_match(&scope.name) {
35 return false;
36 }
37 }
38
47 - if let Some(version_regex) = &self.select.instrumentation_scope_version {
48 - if let Some(JsonValue::String(scope_version)) = json_map.get("scope.version") {
49 - if !version_regex.is_match(scope_version) {
50 - return false;
51 - }
52 - } else {
39 + if let Some(r) = &self.version {
40 + if !r.is_match(&scope.version) {
41 return false;
42 }
43 }
44
57 - if let Some(JsonValue::String(metric_name)) = json_map.get("metric.name") {
58 - self.select.metric_name.is_match(metric_name)
59 - } else {
60 - false
45 + true
46 + }
47 +}
48 +
49 +/// Individual configuration for a metric under specific instrumentation scope
50 +#[derive(Debug, Clone, Serialize, Deserialize)]
51 +pub struct MetricConfig {
52 + #[serde(skip_serializing_if = "Option::is_none", default)]
53 + pub instrumentation_scope: Option<InstrumentationScopePattern>,
54 +
55 + /// The attribute key in DataPoint attributes whose value becomes the dimension name
56 + #[serde(skip_serializing_if = "Option::is_none", default)]
57 + pub dimension_attribute_key: Option<String>,
58 +
59 + /// Per-metric collection interval override (seconds).
60 + #[serde(skip_serializing_if = "Option::is_none", default)]
61 + pub interval_secs: Option<u64>,
62 +
63 + /// Per-metric grace period override (seconds).
64 + #[serde(skip_serializing_if = "Option::is_none", default)]
65 + pub grace_period_secs: Option<u64>,
66 +}
67 +
68 +impl MetricConfig {
69 + /// Check if this config matches the given instrumentation scope
70 + pub fn matches_scope(&self, scope: Option<&InstrumentationScope>) -> bool {
71 + match &self.instrumentation_scope {
72 + Some(pattern) => pattern.matches(scope),
73 + None => true, // No pattern means match any scope
74 }
75 }
76 }
77
78 +/// Type alias for the config storage: metric name -> list of Arc-wrapped configs
79 +pub type ConfigMap = HashMap<String, Vec<Arc<MetricConfig>>>;
80 +
81 +/// Root configuration structure for YAML deserialization of per-metric mapping files.
82 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
66 -pub struct ChartConfigs {
67 - configs: Vec<ChartConfig>,
83 +#[serde(deny_unknown_fields)]
84 +pub struct MetricConfigs {
85 + /// Map from exact metric name to list of configurations
86 + #[serde(default)]
87 + pub metrics: ConfigMap,
88 +}
89 +
90 +/// Old-format configuration structure (for detection only).
91 +#[derive(Deserialize)]
92 +struct OldChartConfigs {
93 + #[allow(dead_code)]
94 + configs: Vec<serde_yaml::Value>,
95 }
96
97 #[derive(Debug, Default, Clone)]
98 pub struct ChartConfigManager {
72 - stock: ChartConfigs,
73 - user: ChartConfigs,
99 + /// Stock configs wrapped in Arc for cheap cloning
100 + stock: Arc<ConfigMap>,
101 + /// User configs wrapped in Arc for cheap cloning
102 + user: Arc<ConfigMap>,
103 + /// Global timing defaults (from otel.yaml)
104 + defaults: ChartConfig,
105 + /// Whether expiry was explicitly set in the global defaults.
106 + expiry_explicit: bool,
107 }
108
109 impl ChartConfigManager {
@@ -80,35 +113,132 @@ impl ChartConfigManager {
113 manager
114 }
115
83 - pub fn find_matching_config(
84 - &self,
85 - json_map: &JsonMap<String, JsonValue>,
86 - ) -> Option<&ChartConfig> {
87 - // Chaining-order is important. We want to priority user configurations
88 - // and fall back to stock configurations if they are missing.
89 - self.user
90 - .configs
91 - .iter()
92 - .chain(self.stock.configs.iter())
93 - .find(|config| config.matches(json_map))
116 + /// Set global timing defaults from plugin configuration (otel.yaml).
117 + ///
118 + /// When `interval_secs` is set but `grace_period_secs` is not, the grace
119 + /// period auto-derives as `5 * interval`. When the resulting grace exceeds
120 + /// expiry and expiry was not explicitly set, expiry is bumped to match.
121 + pub fn set_defaults(
122 + &mut self,
123 + interval_secs: Option<u64>,
124 + grace_period_secs: Option<u64>,
125 + expiry_duration_secs: Option<u64>,
126 + ) {
127 + if let Some(interval) = interval_secs {
128 + self.defaults.collection_interval = interval;
129 + self.defaults.grace_period = Duration::from_secs(5 * interval);
130 + }
131 + if let Some(grace) = grace_period_secs {
132 + self.defaults.grace_period = Duration::from_secs(grace);
133 + }
134 + if let Some(expiry) = expiry_duration_secs {
135 + self.defaults.expiry_duration = Duration::from_secs(expiry);
136 + self.expiry_explicit = true;
137 + }
138 +
139 + // Auto-bump expiry when grace exceeds it and expiry wasn't explicit.
140 + if !self.expiry_explicit && self.defaults.grace_period > self.defaults.expiry_duration {
141 + self.defaults.expiry_duration = self.defaults.grace_period;
142 + }
143 + }
144 +
145 + /// Resolve a `ChartConfig` by layering: global defaults -> per-metric overrides.
146 + ///
147 + /// When a per-metric config sets `interval_secs` but not `grace_period_secs`,
148 + /// the grace period derives from the per-metric interval (`5 * interval`),
149 + /// not from the global grace default.
150 + ///
151 + /// When the auto-derived grace exceeds expiry and expiry was not explicitly
152 + /// set, expiry is bumped to match grace so that a simple `interval_secs`
153 + /// override doesn't silently fall back to defaults.
154 + ///
155 + /// The resolved config must satisfy `0 < interval <= 3600` and
156 + /// `interval < grace <= expiry`. If violated, the hardcoded defaults
157 + /// are used and a warning is logged.
158 + pub fn resolve_chart_config(&self, metric_config: Option<&MetricConfig>) -> ChartConfig {
159 + let mut cfg = self.defaults;
160 + let expiry_explicit = self.expiry_explicit;
161 +
162 + // Per-metric overrides
163 + if let Some(mc) = metric_config {
164 + if let Some(interval) = mc.interval_secs {
165 + cfg.collection_interval = interval;
166 + // Re-derive grace period from the per-metric interval,
167 + // unless the per-metric config also sets it explicitly.
168 + if mc.grace_period_secs.is_none() {
169 + cfg.grace_period = Duration::from_secs(5 * interval);
170 + }
171 + }
172 + if let Some(grace) = mc.grace_period_secs {
173 + cfg.grace_period = Duration::from_secs(grace);
174 + }
175 + }
176 +
177 + // If grace was auto-derived and exceeds expiry, bump expiry to match
178 + // — but only if expiry was never explicitly configured.
179 + if !expiry_explicit && cfg.grace_period > cfg.expiry_duration {
180 + cfg.expiry_duration = cfg.grace_period;
181 + }
182 +
183 + // Validate: 0 < interval <= MAX_UPDATE_EVERY, interval < grace <= expiry
184 + const MAX_UPDATE_EVERY: u64 = 3600;
185 + let interval = Duration::from_secs(cfg.collection_interval);
186 + if cfg.collection_interval == 0
187 + || cfg.collection_interval > MAX_UPDATE_EVERY
188 + || interval >= cfg.grace_period
189 + || cfg.grace_period > cfg.expiry_duration
190 + {
191 + tracing::warn!(
192 + "invalid chart timing config: interval={}s, grace={}s, expiry={}s \
193 + (must satisfy 0 < interval <= {}s and interval < grace <= expiry) - \
194 + falling back to defaults",
195 + cfg.collection_interval,
196 + cfg.grace_period.as_secs(),
197 + cfg.expiry_duration.as_secs(),
198 + MAX_UPDATE_EVERY,
199 + );
200 + return ChartConfig::default();
201 + }
202 +
203 + cfg
204 + }
205 +
206 + /// Find matching config for a metric. Returns Arc<MetricConfig> for zero-copy access.
207 + pub fn find_matching_config(&self, m: &MetricRef<'_>) -> Option<Arc<MetricConfig>> {
208 + let scope = m.scope_metrics.scope.as_ref();
209 +
210 + // Check user configs first (priority)
211 + if let Some(configs) = self.user.get(&m.metric.name) {
212 + if let Some(cfg) = configs.iter().find(|c| c.matches_scope(scope)) {
213 + return Some(Arc::clone(cfg));
214 + }
215 + }
216 +
217 + // Fall back to stock configs
218 + if let Some(configs) = self.stock.get(&m.metric.name) {
219 + if let Some(cfg) = configs.iter().find(|c| c.matches_scope(scope)) {
220 + return Some(Arc::clone(cfg));
221 + }
222 + }
223 +
224 + None
225 }
226
227 fn load_stock_config(&mut self) {
228 const DEFAULT_CONFIGS_YAML: &str =
229 include_str!("../configs/otel.d/v1/metrics/hostmetrics-receiver.yaml");
230
100 - match serde_yaml::from_str::<ChartConfigs>(DEFAULT_CONFIGS_YAML) {
231 + match serde_yaml::from_str::<MetricConfigs>(DEFAULT_CONFIGS_YAML) {
232 Ok(configs) => {
102 - self.stock = configs;
233 + self.stock = Arc::new(configs.metrics);
234 }
235 Err(e) => {
105 - eprintln!("Failed to parse default configs YAML: {}", e);
236 + tracing::warn!("failed to parse default configs YAML: {}", e);
237 }
238 }
239 }
240
110 - pub fn load_user_configs<P: AsRef<Path>>(&mut self, config_dir: P) -> Result<()> {
111 - // check dir
241 + pub fn load_user_configs<P: AsRef<std::path::Path>>(&mut self, config_dir: P) -> Result<()> {
242 let config_path = config_dir.as_ref();
243 if !config_path.exists() {
244 return Err(anyhow::anyhow!(
@@ -123,11 +253,11 @@ impl ChartConfigManager {
253 ));
254 }
255
126 - // collect the yaml files
256 + // Collect YAML files sorted alphabetically
257 let mut config_files: Vec<_> = std::fs::read_dir(config_path)
258 .with_context(|| {
259 format!(
130 - "Failed to read chart config directory: {}",
260 + "failed to read chart config directory: {}",
261 config_path.display()
262 )
263 })?
@@ -148,24 +278,228 @@ impl ChartConfigManager {
278 .collect();
279 config_files.sort();
280
151 - // deserialize them
152 - self.user = ChartConfigs::default();
281 + let mut accumulated = ConfigMap::new();
282 +
283 for path in config_files {
154 - match fs::read_to_string(&path) {
155 - Ok(contents) => match serde_yaml::from_str::<ChartConfigs>(&contents) {
156 - Ok(chart_configs) => {
157 - self.user.configs.extend(chart_configs.configs);
284 + let contents = match std::fs::read_to_string(&path) {
285 + Ok(c) => c,
286 + Err(e) => {
287 + tracing::error!("failed to read file {}: {}", path.display(), e);
288 + continue;
289 + }
290 + };
291 +
292 + // Try new format first
293 + match serde_yaml::from_str::<MetricConfigs>(&contents) {
294 + Ok(metric_configs) => {
295 + for (metric_name, configs) in metric_configs.metrics {
296 + accumulated.entry(metric_name).or_default().extend(configs);
297 }
159 - Err(e) => {
160 - eprintln!("Failed to parse YAML file {}: {}", path.display(), e);
298 + }
299 + Err(new_format_err) => {
300 + // Try to detect old format
301 + if serde_yaml::from_str::<OldChartConfigs>(&contents).is_ok() {
302 + tracing::error!(
303 + "Old metrics configuration format detected in {}. \
304 + Ignoring file; falling back to stock configuration. \
305 + Please migrate to the new format.",
306 + path.display()
307 + );
308 + } else {
309 + tracing::error!(
310 + "failed to parse config file {}: {}",
311 + path.display(),
312 + new_format_err
313 + );
314 }
162 - },
163 - Err(e) => {
164 - eprintln!("Failed to read file {}: {}", path.display(), e);
315 }
316 }
317 }
318
319 + self.user = Arc::new(accumulated);
320 Ok(())
321 }
322 }
323 +
324 +#[cfg(test)]
325 +mod tests {
326 + use super::*;
327 + use crate::chart::ChartConfig;
328 +
329 + fn defaults() -> ChartConfig {
330 + ChartConfig::default()
331 + }
332 +
333 + /// Helper: create a manager with specific global defaults.
334 + fn manager_with_defaults(
335 + interval: Option<u64>,
336 + grace: Option<u64>,
337 + expiry: Option<u64>,
338 + ) -> ChartConfigManager {
339 + let mut m = ChartConfigManager::default();
340 + m.set_defaults(interval, grace, expiry);
341 + m
342 + }
343 +
344 + /// Helper: create a MetricConfig with only timing overrides.
345 + fn metric_config(interval: Option<u64>, grace: Option<u64>) -> MetricConfig {
346 + MetricConfig {
347 + instrumentation_scope: None,
348 + dimension_attribute_key: None,
349 + interval_secs: interval,
350 + grace_period_secs: grace,
351 + }
352 + }
353 +
354 + mod timing_validation {
355 + use super::*;
356 +
357 + #[test]
358 + fn valid_defaults_are_accepted() {
359 + let m = ChartConfigManager::default();
360 + let cfg = m.resolve_chart_config(None);
361 + // Default: interval=10, grace=50, expiry=900
362 + assert_eq!(cfg.collection_interval, defaults().collection_interval);
363 + assert_eq!(cfg.grace_period, defaults().grace_period);
364 + assert_eq!(cfg.expiry_duration, defaults().expiry_duration);
365 + }
366 +
367 + #[test]
368 + fn grace_equal_to_expiry_is_valid() {
369 + let m = manager_with_defaults(Some(10), Some(100), Some(100));
370 + let cfg = m.resolve_chart_config(None);
371 + assert_eq!(cfg.collection_interval, 10);
372 + assert_eq!(cfg.grace_period, Duration::from_secs(100));
373 + assert_eq!(cfg.expiry_duration, Duration::from_secs(100));
374 + }
375 +
376 + #[test]
377 + fn grace_greater_than_expiry_falls_back_to_defaults() {
378 + let m = manager_with_defaults(Some(10), Some(200), Some(100));
379 + let cfg = m.resolve_chart_config(None);
380 + assert_eq!(cfg.collection_interval, defaults().collection_interval);
381 + assert_eq!(cfg.grace_period, defaults().grace_period);
382 + assert_eq!(cfg.expiry_duration, defaults().expiry_duration);
383 + }
384 +
385 + #[test]
386 + fn interval_equal_to_grace_falls_back_to_defaults() {
387 + let m = manager_with_defaults(Some(50), Some(50), Some(900));
388 + let cfg = m.resolve_chart_config(None);
389 + assert_eq!(cfg.collection_interval, defaults().collection_interval);
390 + }
391 +
392 + #[test]
393 + fn interval_greater_than_grace_falls_back_to_defaults() {
394 + let m = manager_with_defaults(Some(100), Some(50), Some(900));
395 + let cfg = m.resolve_chart_config(None);
396 + assert_eq!(cfg.collection_interval, defaults().collection_interval);
397 + }
398 +
399 + #[test]
400 + fn per_metric_interval_exceeding_global_grace() {
401 + // Global defaults set grace=20, expiry=900.
402 + // Per-metric sets interval=25, which auto-derives grace=125.
403 + // That's valid (25 < 125 <= 900), so it should be accepted.
404 + let m = manager_with_defaults(None, Some(20), Some(900));
405 + let mc = metric_config(Some(25), None);
406 + let cfg = m.resolve_chart_config(Some(&mc));
407 + // grace is re-derived as 5*25=125
408 + assert_eq!(cfg.collection_interval, 25);
409 + assert_eq!(cfg.grace_period, Duration::from_secs(125));
410 + }
411 +
412 + #[test]
413 + fn per_metric_grace_less_than_interval_falls_back_to_defaults() {
414 + let m = ChartConfigManager::default();
415 + let mc = metric_config(Some(100), Some(50));
416 + let cfg = m.resolve_chart_config(Some(&mc));
417 + assert_eq!(cfg.collection_interval, defaults().collection_interval);
418 + }
419 +
420 + #[test]
421 + fn per_metric_grace_exceeding_expiry_falls_back_to_defaults() {
422 + let m = manager_with_defaults(None, None, Some(100));
423 + let mc = metric_config(Some(10), Some(200));
424 + let cfg = m.resolve_chart_config(Some(&mc));
425 + assert_eq!(cfg.collection_interval, defaults().collection_interval);
426 + }
427 +
428 + #[test]
429 + fn zero_interval_falls_back_to_defaults() {
430 + let m = manager_with_defaults(Some(0), Some(50), Some(900));
431 + let cfg = m.resolve_chart_config(None);
432 + assert_eq!(cfg.collection_interval, defaults().collection_interval);
433 + }
434 +
435 + #[test]
436 + fn zero_expiry_falls_back_to_defaults() {
437 + let m = manager_with_defaults(Some(10), Some(0), Some(0));
438 + let cfg = m.resolve_chart_config(None);
439 + assert_eq!(cfg.collection_interval, defaults().collection_interval);
440 + }
441 +
442 + #[test]
443 + fn large_interval_auto_derives_grace_and_bumps_expiry() {
444 + // Global sets only interval=200. Grace auto-derives to 1000,
445 + // which exceeds the default expiry of 900. Since expiry was
446 + // never explicitly set, it should be bumped to 1000.
447 + let m = manager_with_defaults(Some(200), None, None);
448 + let cfg = m.resolve_chart_config(None);
449 + assert_eq!(cfg.collection_interval, 200);
450 + assert_eq!(cfg.grace_period, Duration::from_secs(1000));
451 + assert_eq!(cfg.expiry_duration, Duration::from_secs(1000));
452 + }
453 +
454 + #[test]
455 + fn per_metric_large_interval_bumps_expiry() {
456 + // No global overrides. Per-metric sets interval=200.
457 + // Grace auto-derives to 1000, default expiry is 900.
458 + // Expiry not explicit → bumped to 1000.
459 + let m = ChartConfigManager::default();
460 + let mc = metric_config(Some(200), None);
461 + let cfg = m.resolve_chart_config(Some(&mc));
462 + assert_eq!(cfg.collection_interval, 200);
463 + assert_eq!(cfg.grace_period, Duration::from_secs(1000));
464 + assert_eq!(cfg.expiry_duration, Duration::from_secs(1000));
465 + }
466 +
467 + #[test]
468 + fn explicit_expiry_not_bumped_when_grace_exceeds_it() {
469 + // Global explicitly sets expiry=100. Per-metric sets
470 + // grace=200 which exceeds it. Since expiry is explicit,
471 + // it should NOT be bumped — validation fails → fallback.
472 + let m = manager_with_defaults(None, None, Some(100));
473 + let mc = metric_config(Some(10), Some(200));
474 + let cfg = m.resolve_chart_config(Some(&mc));
475 + assert_eq!(cfg.collection_interval, defaults().collection_interval);
476 + }
477 +
478 + #[test]
479 + fn interval_at_max_update_every_is_accepted() {
480 + let m = manager_with_defaults(Some(3600), None, None);
481 + let cfg = m.resolve_chart_config(None);
482 + assert_eq!(cfg.collection_interval, 3600);
483 + assert_eq!(cfg.grace_period, Duration::from_secs(18000));
484 + assert_eq!(cfg.expiry_duration, Duration::from_secs(18000));
485 + }
486 +
487 + #[test]
488 + fn interval_exceeding_max_update_every_falls_back_to_defaults() {
489 + let m = manager_with_defaults(Some(3601), None, None);
490 + let cfg = m.resolve_chart_config(None);
491 + assert_eq!(cfg.collection_interval, defaults().collection_interval);
492 + }
493 +
494 + #[test]
495 + fn interval_30_auto_derives_correctly() {
496 + // Interval=30 → grace=150, default expiry=900.
497 + // All valid: 30 < 150 <= 900.
498 + let m = manager_with_defaults(Some(30), None, None);
499 + let cfg = m.resolve_chart_config(None);
500 + assert_eq!(cfg.collection_interval, 30);
501 + assert_eq!(cfg.grace_period, Duration::from_secs(150));
502 + assert_eq!(cfg.expiry_duration, Duration::from_secs(900));
503 + }
504 + }
505 +}
src/crates/netdata-otel/otel-plugin/src/flattened_point.rs deleted
-174
@@ -1,174 +0,0 @@
1 -use serde_json::{Map as JsonMap, Value as JsonValue};
2 -use std::hash::{Hash, Hasher};
3 -
4 -use crate::regex_cache::RegexCache;
5 -
6 -#[derive(Default, Debug)]
7 -pub struct FlattenedPoint {
8 - pub attributes: JsonMap<String, JsonValue>,
9 -
10 - pub nd_instance_name: String,
11 - pub nd_dimension_name: String,
12 -
13 - pub metric_name: String,
14 - pub metric_description: String,
15 - pub metric_unit: String,
16 - pub metric_type: String,
17 -
18 - pub metric_time_unix_nano: u64,
19 - pub metric_value: f64,
20 -
21 - pub metric_is_monotonic: Option<bool>,
22 -}
23 -
24 -use crate::chart_config::ChartConfig;
25 -
26 -impl FlattenedPoint {
27 - pub fn new(
28 - mut json_map: JsonMap<String, JsonValue>,
29 - chart_config: Option<&ChartConfig>,
30 - regex_cache: &RegexCache,
31 - ) -> Option<Self> {
32 - let Some(JsonValue::String(metric_name)) = json_map.remove("metric.name") else {
33 - debug_assert!(false, "metric.name missing from json map");
34 - return None;
35 - };
36 -
37 - let Some(JsonValue::String(metric_description)) = json_map.remove("metric.description")
38 - else {
39 - debug_assert!(false, "metric.description missing from json map");
40 - return None;
41 - };
42 -
43 - let Some(JsonValue::String(metric_unit)) = json_map.remove("metric.unit") else {
44 - debug_assert!(false, "metric.unit missing from json map");
45 - return None;
46 - };
47 -
48 - let Some(JsonValue::String(metric_type)) = json_map.remove("metric.type") else {
49 - debug_assert!(false, "metric.type missing from json map");
50 - return None;
51 - };
52 -
53 - // Ignore start_time_unix for the time being.
54 - json_map.remove("metric.start_time_unix_nano");
55 -
56 - let Some(metric_time_unix_nano) = json_map
57 - .remove("metric.time_unix_nano")
58 - .and_then(|v| v.as_u64())
59 - else {
60 - debug_assert!(false, "metric.time_unix_nano missing from json map");
61 - return None;
62 - };
63 -
64 - let Some(metric_value) = json_map.remove("metric.value").and_then(|v| v.as_f64()) else {
65 - debug_assert!(false, "metric.value missing from json map");
66 - return None;
67 - };
68 -
69 - let metric_is_monotonic = json_map
70 - .remove("metric.is_monotonic")
71 - .and_then(|v| v.as_bool());
72 -
73 - if let Some(config) = chart_config {
74 - if let Some(chart_instance_pattern) = &config.extract.chart_instance_pattern {
75 - if !json_map.contains_key("metric.attributes._nd_chart_instance") {
76 - json_map.insert(
77 - "metric.attributes._nd_chart_instance".to_string(),
78 - JsonValue::String(chart_instance_pattern.clone()),
79 - );
80 - }
81 - }
82 -
83 - if let Some(dimension_name) = &config.extract.dimension_name {
84 - if !json_map.contains_key("metric.attributes._nd_dimension") {
85 - json_map.insert(
86 - "metric.attributes._nd_dimension".to_string(),
87 - JsonValue::String(dimension_name.clone()),
88 - );
89 - }
90 - }
91 - }
92 -
93 - let nd_dimension_name = {
94 - let nd_dimension_key = json_map
95 - .remove("metric.attributes._nd_dimension")
96 - .and_then(|v| v.as_str().map(String::from));
97 -
98 - if let Some(key) = nd_dimension_key {
99 - match json_map.remove(&key) {
100 - Some(JsonValue::String(s)) => s.clone(),
101 - Some(JsonValue::Number(n)) => n.to_string(),
102 - Some(JsonValue::Bool(b)) => b.to_string(),
103 - Some(value) => {
104 - eprintln!(
105 - "Only strings/number/bool values can be used for dimension name >>>{:#?}<<<",
106 - value
107 - );
108 - return None;
109 - }
110 - _ => {
111 - eprintln!(
112 - "Dimension key >>>{:?}<<< not found in flattened representation.",
113 - key
114 - );
115 - return None;
116 - }
117 - }
118 - } else {
119 - String::from("value")
120 - }
121 - };
122 -
123 - let nd_instance_name = {
124 - let nd_chart_instance = json_map
125 - .remove("metric.attributes._nd_chart_instance")
126 - .and_then(|v| v.as_str().map(String::from))
127 - .and_then(|s| regex_cache.get(&s).ok());
128 -
129 - let mut matched_values = vec![metric_name.clone()];
130 - if let Some(pattern) = nd_chart_instance {
131 - for (key, value) in &json_map {
132 - if pattern.is_match(key) {
133 - let value_str = match value {
134 - JsonValue::String(s) => s.clone(),
135 - JsonValue::Number(n) => n.to_string(),
136 - JsonValue::Bool(b) => b.to_string(),
137 - JsonValue::Null => "null".to_string(),
138 - _ => serde_json::to_string(value).unwrap_or_default(),
139 - };
140 - matched_values.push(value_str);
141 - }
142 - }
143 - }
144 -
145 - let name = matched_values.join(".");
146 -
147 - let hash = {
148 - use std::hash::DefaultHasher;
149 -
150 - let mut state = DefaultHasher::new();
151 - name.hash(&mut state);
152 - json_map.hash(&mut state);
153 - metric_unit.hash(&mut state);
154 - metric_type.hash(&mut state);
155 - state.finish()
156 - };
157 -
158 - format!("{name}.{hash:016x}")
159 - };
160 -
161 - Some(Self {
162 - attributes: json_map,
163 - nd_instance_name,
164 - nd_dimension_name,
165 - metric_name,
166 - metric_description: metric_description.replace('\'', "\""),
167 - metric_unit,
168 - metric_type,
169 - metric_time_unix_nano,
170 - metric_value,
171 - metric_is_monotonic,
172 - })
173 - }
174 -}
src/crates/netdata-otel/otel-plugin/src/iter.rs new
+393
@@ -0,0 +1,393 @@
1 +//! Iteration types for traversing OTLP metrics and data points.
2 +
3 +use std::hash::{Hash, Hasher};
4 +use std::sync::Arc;
5 +
6 +use opentelemetry_proto::tonic::collector::metrics::v1::ExportMetricsServiceRequest;
7 +use opentelemetry_proto::tonic::metrics::v1::{
8 + AggregationTemporality, Metric, ResourceMetrics, ScopeMetrics, metric,
9 +};
10 +use twox_hash::XxHash64;
11 +
12 +use serde_json::{Map as JsonMap, Value as JsonValue};
13 +
14 +use crate::chart_config::{ChartConfigManager, MetricConfig};
15 +use crate::otel::{self, DataPointIterExt, DataPointRef, MetricIdentityHash};
16 +
17 +/// Convert a JSON value to a string suitable for a Netdata label value.
18 +/// Returns `None` for null, arrays, and objects (which are not meaningful as labels).
19 +fn json_value_to_string(v: JsonValue) -> Option<String> {
20 + match v {
21 + JsonValue::String(s) => Some(s),
22 + JsonValue::Number(n) => Some(n.to_string()),
23 + JsonValue::Bool(b) => Some(b.to_string()),
24 + JsonValue::Null | JsonValue::Array(_) | JsonValue::Object(_) => None,
25 + }
26 +}
27 +
28 +/// Hierarchical hasher for computing metric identity hashes.
29 +///
30 +/// Maintains a stack of hasher states to efficiently compute hashes
31 +/// at different levels of the OTLP hierarchy (resource -> scope -> metric).
32 +#[derive(Clone)]
33 +pub struct MetricIdentityHasher {
34 + current: XxHash64,
35 + stack: Vec<XxHash64>,
36 +}
37 +
38 +impl MetricIdentityHasher {
39 + pub fn new() -> Self {
40 + Self {
41 + current: XxHash64::default(),
42 + stack: Vec::new(),
43 + }
44 + }
45 +
46 + pub fn identity_hash<T: MetricIdentityHash>(&mut self, v: &T) {
47 + v.identity_hash(&mut self.current);
48 + }
49 +
50 + pub fn hash<T: Hash>(&mut self, v: &T) {
51 + v.hash(&mut self.current);
52 + }
53 +
54 + /// Save the current hasher state onto the stack
55 + pub fn push(&mut self) {
56 + self.stack.push(self.current.clone());
57 + }
58 +
59 + /// Restore the most recently saved state, discarding current progress
60 + pub fn pop(&mut self) {
61 + self.current = self.stack.pop().expect("pop called without matching push");
62 + }
63 +
64 + /// Return a clone of the current hasher state.
65 + pub fn snapshot(&self) -> XxHash64 {
66 + self.current.clone()
67 + }
68 +}
69 +
70 +impl Default for MetricIdentityHasher {
71 + fn default() -> Self {
72 + Self::new()
73 + }
74 +}
75 +
76 +impl Hasher for MetricIdentityHasher {
77 + fn finish(&self) -> u64 {
78 + self.current.finish()
79 + }
80 +
81 + fn write(&mut self, bytes: &[u8]) {
82 + self.current.write(bytes);
83 + }
84 +}
85 +
86 +/// A reference to a metric along with its containing scope and resource,
87 +/// plus a captured hasher state and optional matched configuration.
88 +pub struct MetricRef<'a> {
89 + pub resource_metrics: &'a ResourceMetrics,
90 + pub scope_metrics: &'a ScopeMetrics,
91 + pub metric: &'a Metric,
92 + /// Hasher state with resource + scope + metric identity already hashed in.
93 + /// Clone this and continue hashing to incorporate data-point-level fields.
94 + pub hasher_state: XxHash64,
95 + pub config: Option<Arc<MetricConfig>>,
96 +}
97 +
98 +/// Simplified metric data kind for quick pattern matching.
99 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100 +pub enum MetricDataKind {
101 + Gauge,
102 + Sum,
103 + Histogram,
104 + ExponentialHistogram,
105 + Summary,
106 +}
107 +
108 +impl From<&metric::Data> for MetricDataKind {
109 + fn from(data: &metric::Data) -> Self {
110 + match data {
111 + metric::Data::Gauge(_) => MetricDataKind::Gauge,
112 + metric::Data::Sum(_) => MetricDataKind::Sum,
113 + metric::Data::Histogram(_) => MetricDataKind::Histogram,
114 + metric::Data::ExponentialHistogram(_) => MetricDataKind::ExponentialHistogram,
115 + metric::Data::Summary(_) => MetricDataKind::Summary,
116 + }
117 + }
118 +}
119 +
120 +impl MetricRef<'_> {
121 + /// Returns the aggregation temporality for Sum, Histogram, and ExponentialHistogram.
122 + /// Returns None for Gauge and Summary.
123 + pub fn aggregation_temporality(&self) -> Option<AggregationTemporality> {
124 + match &self.metric.data {
125 + Some(metric::Data::Sum(s)) => {
126 + AggregationTemporality::try_from(s.aggregation_temporality).ok()
127 + }
128 + Some(metric::Data::Histogram(h)) => {
129 + AggregationTemporality::try_from(h.aggregation_temporality).ok()
130 + }
131 + Some(metric::Data::ExponentialHistogram(eh)) => {
132 + AggregationTemporality::try_from(eh.aggregation_temporality).ok()
133 + }
134 + _ => None,
135 + }
136 + }
137 +
138 + /// Returns the metric data kind (for pattern matching on the metric type).
139 + pub fn data_kind(&self) -> Option<MetricDataKind> {
140 + self.metric.data.as_ref().map(MetricDataKind::from)
141 + }
142 +
143 + /// Returns `is_monotonic` for Sum metrics. Returns `None` for non-Sum types.
144 + pub fn is_monotonic(&self) -> Option<bool> {
145 + match &self.metric.data {
146 + Some(metric::Data::Sum(s)) => Some(s.is_monotonic),
147 + _ => None,
148 + }
149 + }
150 +}
151 +
152 +/// A data point along with its full context: metric, scope, resource, config, and pre-computed values.
153 +pub struct DataPointContext<'a> {
154 + pub metric_ref: MetricRef<'a>,
155 + pub datapoint_ref: DataPointRef<'a>,
156 +}
157 +
158 +impl DataPointContext<'_> {
159 + /// Get the dimension attribute key from the config, if configured.
160 + fn dimension_attr_key(&self) -> Option<&str> {
161 + self.metric_ref
162 + .config
163 + .as_ref()
164 + .and_then(|c| c.dimension_attribute_key.as_deref())
165 + }
166 +
167 + /// Get the dimension name from the data point's attributes.
168 + /// Uses the configured dimension_attr_key to look up the attribute value,
169 + /// or returns "value" if not configured or not found.
170 + pub fn dimension_name(&self) -> &str {
171 + self.datapoint_ref.dimension_name(self.dimension_attr_key())
172 + }
173 +
174 + /// Compute a single hash that uniquely identifies which chart this data point
175 + /// belongs to: resource + scope + metric identity + data point attributes
176 + /// (excluding the dimension attribute).
177 + pub fn chart_hash(&self) -> u64 {
178 + let mut hasher = self.metric_ref.hasher_state.clone();
179 + self.datapoint_ref
180 + .hash_attributes(&mut hasher, self.dimension_attr_key());
181 + hasher.finish()
182 + }
183 +
184 + /// Returns the aggregation temporality for this data point's metric.
185 + /// Returns None for Gauge and Summary metrics.
186 + pub fn aggregation_temporality(&self) -> Option<AggregationTemporality> {
187 + self.metric_ref.aggregation_temporality()
188 + }
189 +
190 + /// Returns the metric data kind.
191 + pub fn data_kind(&self) -> Option<MetricDataKind> {
192 + self.metric_ref.data_kind()
193 + }
194 +
195 + /// Returns `is_monotonic` for Sum metrics. Returns `None` for non-Sum types.
196 + pub fn is_monotonic(&self) -> Option<bool> {
197 + self.metric_ref.is_monotonic()
198 + }
199 +
200 + /// Collect all chart labels from resource attributes, instrumentation scope
201 + /// name/version/attributes, and data point attributes (excluding the dimension
202 + /// attribute).
203 + ///
204 + /// Uses `flatten_otel` for consistent key naming:
205 + /// - `resource.attributes.{key}` for resource attributes
206 + /// - `scope.name`, `scope.version`, `scope.attributes.{key}` for scope info
207 + /// - Data point attributes are unprefixed (excluding the dimension attribute)
208 + pub fn chart_labels(&self) -> Vec<(String, String)> {
209 + let mut jm = JsonMap::new();
210 +
211 + // Resource attributes
212 + if let Some(resource) = &self.metric_ref.resource_metrics.resource {
213 + flatten_otel::json_from_resource(&mut jm, resource);
214 + }
215 +
216 + // Instrumentation scope name, version, and attributes
217 + if let Some(scope) = &self.metric_ref.scope_metrics.scope {
218 + flatten_otel::json_from_instrumentation_scope(&mut jm, scope);
219 + }
220 +
221 + // Data point attributes (excluding the dimension attribute)
222 + let exclude = self.dimension_attr_key();
223 + let dp_attrs: Vec<_> = self
224 + .datapoint_ref
225 + .attributes()
226 + .iter()
227 + .filter(|kv| exclude.is_none_or(|k| k != kv.key))
228 + .cloned()
229 + .collect();
230 + for (key, value) in flatten_otel::json_from_key_value_list(&dp_attrs) {
231 + jm.insert(key, value);
232 + }
233 +
234 + jm.into_iter()
235 + .filter_map(|(k, v)| Some((k, json_value_to_string(v)?)))
236 + .collect()
237 + }
238 +}
239 +
240 +/// Iterator over all data points in an `ExportMetricsServiceRequest`.
241 +///
242 +/// Yields `DataPointContext` items containing references to the data point and its
243 +/// full context (metric, scope, resource), along with pre-computed values like
244 +/// the metric identity hash and dimension attribute key.
245 +pub struct DataPointIter<'a> {
246 + request: &'a ExportMetricsServiceRequest,
247 + ccm: &'a ChartConfigManager,
248 + hasher: MetricIdentityHasher,
249 + rm_idx: usize,
250 + sm_idx: usize,
251 + m_idx: usize,
252 + // Cached metric-level data (to avoid re-computing for each data point)
253 + current_metric: Option<CurrentMetricContext<'a>>,
254 + depth: u8,
255 + finished: bool,
256 +}
257 +
258 +/// Cached context for the current metric being iterated.
259 +struct CurrentMetricContext<'a> {
260 + metric_ref: MetricRef<'a>,
261 + dp_iter: otel::DataPointIter<'a>,
262 +}
263 +
264 +impl<'a> DataPointIter<'a> {
265 + pub fn new(request: &'a ExportMetricsServiceRequest, ccm: &'a ChartConfigManager) -> Self {
266 + Self {
267 + request,
268 + ccm,
269 + hasher: MetricIdentityHasher::new(),
270 + rm_idx: 0,
271 + sm_idx: 0,
272 + m_idx: 0,
273 + current_metric: None,
274 + depth: 0,
275 + finished: false,
276 + }
277 + }
278 +}
279 +
280 +impl<'a> Iterator for DataPointIter<'a> {
281 + type Item = DataPointContext<'a>;
282 +
283 + fn next(&mut self) -> Option<Self::Item> {
284 + if self.finished {
285 + return None;
286 + }
287 +
288 + loop {
289 + // If we have a current metric, try to yield its next data point
290 + if let Some(ref mut ctx) = self.current_metric {
291 + if let Some(dp) = ctx.dp_iter.next() {
292 + return Some(DataPointContext {
293 + metric_ref: MetricRef {
294 + resource_metrics: ctx.metric_ref.resource_metrics,
295 + scope_metrics: ctx.metric_ref.scope_metrics,
296 + metric: ctx.metric_ref.metric,
297 + hasher_state: ctx.metric_ref.hasher_state.clone(),
298 + config: ctx.metric_ref.config.clone(),
299 + },
300 + datapoint_ref: dp,
301 + });
302 + } else {
303 + // No more data points in this metric
304 + self.current_metric = None;
305 + // Continue to find next metric
306 + }
307 + }
308 +
309 + // Find the next metric
310 + match self.depth {
311 + 0 => {
312 + // Enter a resource (push first to save parent state)
313 + if let Some(rm) = self.request.resource_metrics.get(self.rm_idx) {
314 + self.hasher.push();
315 + self.hasher.identity_hash(&rm.resource);
316 + self.hasher.hash(&rm.schema_url);
317 + self.depth = 1;
318 + self.sm_idx = 0;
319 + } else {
320 + self.finished = true;
321 + return None;
322 + }
323 + }
324 + 1 => {
325 + // Enter a scope (push first to save resource state)
326 + let rm = &self.request.resource_metrics[self.rm_idx];
327 + if let Some(sm) = rm.scope_metrics.get(self.sm_idx) {
328 + self.hasher.push();
329 + self.hasher.identity_hash(&sm.scope);
330 + self.hasher.hash(&sm.schema_url);
331 + self.depth = 2;
332 + self.m_idx = 0;
333 + } else {
334 + // No more scopes in this resource
335 + self.hasher.pop();
336 + self.depth = 0;
337 + self.rm_idx += 1;
338 + }
339 + }
340 + 2 => {
341 + // Enter a metric (push first to save scope state)
342 + let rm = &self.request.resource_metrics[self.rm_idx];
343 + let sm = &rm.scope_metrics[self.sm_idx];
344 + if let Some(m) = sm.metrics.get(self.m_idx) {
345 + self.hasher.push();
346 + self.hasher.identity_hash(m);
347 + let hasher_state = self.hasher.snapshot();
348 + self.hasher.pop();
349 +
350 + self.m_idx += 1;
351 +
352 + // Build a temporary MetricRef to find config
353 + let metric_ref = MetricRef {
354 + resource_metrics: rm,
355 + scope_metrics: sm,
356 + metric: m,
357 + hasher_state,
358 + config: None,
359 + };
360 + let config = self.ccm.find_matching_config(&metric_ref);
361 +
362 + // Cache the metric context for data point iteration
363 + let dp_iter = m.data_points();
364 + self.current_metric = Some(CurrentMetricContext {
365 + metric_ref: MetricRef {
366 + config,
367 + ..metric_ref
368 + },
369 + dp_iter,
370 + });
371 + // Loop back to yield data points
372 + } else {
373 + // No more metrics in this scope
374 + self.hasher.pop();
375 + self.depth = 1;
376 + self.sm_idx += 1;
377 + }
378 + }
379 + _ => unreachable!(),
380 + }
381 + }
382 + }
383 +}
384 +
385 +pub trait DataPointContextIterExt {
386 + fn datapoint_iter<'a>(&'a self, ccm: &'a ChartConfigManager) -> DataPointIter<'a>;
387 +}
388 +
389 +impl DataPointContextIterExt for ExportMetricsServiceRequest {
390 + fn datapoint_iter<'a>(&'a self, ccm: &'a ChartConfigManager) -> DataPointIter<'a> {
391 + DataPointIter::new(self, ccm)
392 + }
393 +}
src/crates/netdata-otel/otel-plugin/src/lib.rs
+88 -21
@@ -1,18 +1,23 @@
1 //! otel-plugin library - can be called from multi-call binaries or standalone
2
3 +use std::sync::Arc;
4 +use std::time::{SystemTime, UNIX_EPOCH};
5 +
6 use anyhow::{Context, Result};
7 use opentelemetry_proto::tonic::collector::{
8 logs::v1::logs_service_server::LogsServiceServer,
9 metrics::v1::metrics_service_server::MetricsServiceServer,
10 };
11 use rt::PluginRuntime;
12 +use tokio::sync::RwLock;
13 use tonic::transport::{Identity, Server, ServerTlsConfig};
14
15 +mod aggregation;
16 +mod chart;
17 mod chart_config;
12 -mod flattened_point;
13 -mod netdata_chart;
14 -mod regex_cache;
15 -mod samples_table;
18 +mod iter;
19 +mod otel;
20 +mod output;
21
22 mod plugin_config;
23 use crate::plugin_config::PluginConfig;
@@ -21,7 +26,8 @@ mod logs_service;
26 use crate::logs_service::NetdataLogsService;
27
28 mod metrics_service;
24 -use crate::metrics_service::NetdataMetricsService;
29 +use crate::chart_config::ChartConfigManager;
30 +use crate::metrics_service::{ChartManager, NetdataMetricsService};
31
32 /// Entry point for otel-plugin - can be called from multi-call binary
33 ///
@@ -42,7 +48,7 @@ async fn async_run(_args: Vec<String>) -> i32 {
48 match run_internal().await {
49 Ok(()) => 0,
50 Err(e) => {
45 - eprintln!("Error: {:#}", e);
51 + tracing::error!("{:#}", e);
52 1
53 }
54 }
@@ -64,21 +70,81 @@ async fn run_internal() -> Result<()> {
70 }
71
72 // 4. Load configuration
67 - let config = PluginConfig::new().context("Failed to initialize plugin configuration")?;
68 -
69 - // 5. Create gRPC services
70 - let metrics_service =
71 - NetdataMetricsService::new(config.clone()).context("Failed to create metrics service")?;
73 + let config = PluginConfig::new().context("failed to initialize plugin configuration")?;
74 +
75 + // 5. Set up new metrics pipeline
76 + let mut ccm = ChartConfigManager::with_default_configs();
77 + ccm.set_defaults(
78 + config.metrics.interval_secs,
79 + config.metrics.grace_period_secs,
80 + config.metrics.expiry_duration_secs,
81 + );
82 + if let Some(chart_configs_dir) = &config.metrics.chart_configs_dir {
83 + if let Err(e) = ccm.load_user_configs(chart_configs_dir) {
84 + tracing::error!(
85 + "failed to load chart configs from {}: {:#} - using stock configs",
86 + chart_configs_dir,
87 + e
88 + );
89 + }
90 + }
91 + let effective_defaults = ccm.resolve_chart_config(None);
92 + tracing::info!(
93 + "metrics default timing: interval={}s, grace={}s, expiry={}s",
94 + effective_defaults.collection_interval,
95 + effective_defaults.grace_period.as_secs(),
96 + effective_defaults.expiry_duration.as_secs(),
97 + );
98 +
99 + let ccm = Arc::new(RwLock::new(ccm));
100 + let chart_manager = Arc::new(RwLock::new(ChartManager::new()));
101 + let metrics_service = NetdataMetricsService::new(
102 + Arc::clone(&ccm),
103 + Arc::clone(&chart_manager),
104 + config.metrics.max_new_charts_per_request,
105 + );
106 +
107 + // 6. Create logs service (unchanged)
108 let logs_service =
73 - NetdataLogsService::new(config.clone()).context("Failed to create logs service")?;
109 + NetdataLogsService::new(config.clone()).context("failed to create logs service")?;
110 +
111 + // 7. Tick loop for periodic metric emission
112 + let writer_for_tick = Arc::clone(&writer);
113 + let tick_handle = tokio::spawn(async move {
114 + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
115 + let mut buf = String::new();
116 +
117 + loop {
118 + interval.tick().await;
119 +
120 + let slot_timestamp = SystemTime::now()
121 + .duration_since(UNIX_EPOCH)
122 + .expect("system clock before UNIX epoch")
123 + .as_secs();
124 +
125 + buf.clear();
126 + {
127 + let mut manager = chart_manager.write().await;
128 + manager.emit(slot_timestamp, &mut buf);
129 + }
130 +
131 + if !buf.is_empty() {
132 + let mut w = writer_for_tick.lock().await;
133 + if let Err(e) = w.write_raw(buf.as_bytes()).await {
134 + tracing::error!("failed to write chart data: {}", e);
135 + break;
136 + }
137 + }
138 + }
139 + });
140
75 - // 7. Parse gRPC endpoint address
141 + // 8. Parse gRPC endpoint address
142 let addr =
143 config.endpoint.path.parse().with_context(|| {
78 - format!("Failed to parse endpoint address: {}", config.endpoint.path)
144 + format!("failed to parse endpoint address: {}", config.endpoint.path)
145 })?;
146
81 - // 8. Build gRPC server (with TLS if configured)
147 + // 9. Build gRPC server (with TLS if configured)
148 let mut server_builder = Server::builder();
149
150 if let (Some(cert_path), Some(key_path)) = (
@@ -86,23 +152,23 @@ async fn run_internal() -> Result<()> {
152 &config.endpoint.tls_key_path,
153 ) {
154 let cert = std::fs::read(cert_path)
89 - .with_context(|| format!("Failed to read TLS certificate from: {}", cert_path))?;
155 + .with_context(|| format!("failed to read TLS certificate from: {}", cert_path))?;
156 let key = std::fs::read(key_path)
91 - .with_context(|| format!("Failed to read TLS private key from: {}", key_path))?;
157 + .with_context(|| format!("failed to read TLS private key from: {}", key_path))?;
158 let identity = Identity::from_pem(cert, key);
159
160 let mut tls_config_builder = ServerTlsConfig::new().identity(identity);
161
162 if let Some(ref ca_cert_path) = config.endpoint.tls_ca_cert_path {
163 let ca_cert = std::fs::read(ca_cert_path)
98 - .with_context(|| format!("Failed to read CA certificate from: {}", ca_cert_path))?;
164 + .with_context(|| format!("failed to read CA certificate from: {}", ca_cert_path))?;
165 tls_config_builder =
166 tls_config_builder.client_ca_root(tonic::transport::Certificate::from_pem(ca_cert));
167 }
168
169 server_builder = server_builder
170 .tls_config(tls_config_builder)
105 - .context("Failed to configure TLS")?;
171 + .context("failed to configure TLS")?;
172 } else {
173 eprintln!(
174 "TLS disabled, using insecure connection on endpoint: {}",
@@ -110,7 +176,7 @@ async fn run_internal() -> Result<()> {
176 );
177 }
178
113 - // 9. Build gRPC server future
179 + // 10. Build gRPC server future
180 let grpc_server = server_builder
181 .add_service(
182 MetricsServiceServer::new(metrics_service)
@@ -122,7 +188,7 @@ async fn run_internal() -> Result<()> {
188 )
189 .serve(addr);
190
125 - // 10. Run gRPC server and plugin runtime concurrently
191 + // 11. Run gRPC server and plugin runtime concurrently
192 tokio::select! {
193 result = grpc_server => {
194 result.with_context(|| format!("gRPC server error on {}", config.endpoint.path))?;
@@ -132,5 +198,6 @@ async fn run_internal() -> Result<()> {
198 }
199 }
200
201 + tick_handle.abort();
202 Ok(())
203 }
src/crates/netdata-otel/otel-plugin/src/metrics_service.rs
+547 -100
@@ -1,141 +1,588 @@
1 -use anyhow::{Context, Result};
2 -use flatten_otel::flatten_metrics_request;
1 +//! gRPC service implementation for OTLP metrics ingestion.
2 +
3 +use std::collections::HashMap;
4 +use std::fmt::Write;
5 +use std::sync::Arc;
6 +
7 use opentelemetry_proto::tonic::collector::metrics::v1::{
8 ExportMetricsServiceRequest, ExportMetricsServiceResponse,
9 metrics_service_server::MetricsService,
10 };
7 -use std::collections::HashMap;
8 -use std::sync::Arc;
11 use tokio::sync::RwLock;
12 use tonic::{Request, Response, Status};
13
14 +use opentelemetry_proto::tonic::metrics::v1::{
15 + AggregationTemporality, HistogramDataPoint, SummaryDataPoint,
16 +};
17 +
18 +use crate::chart::{Chart, ChartAggregationType, ChartConfig};
19 use crate::chart_config::ChartConfigManager;
13 -use crate::flattened_point::FlattenedPoint;
14 -use crate::netdata_chart::NetdataChart;
15 -use crate::plugin_config::PluginConfig;
16 -use crate::regex_cache::RegexCache;
20 +use crate::iter::{DataPointContext, DataPointContextIterExt};
21 +use crate::otel::{self, DataPointRef};
22 +use crate::output::ChartType;
23
18 -pub struct NetdataMetricsService {
19 - regex_cache: RegexCache,
20 - charts: Arc<RwLock<HashMap<String, NetdataChart>>>,
21 - config: Arc<PluginConfig>,
22 - chart_config_manager: ChartConfigManager,
23 - call_count: std::sync::atomic::AtomicU64,
24 +/// Manages all charts for the service.
25 +pub struct ChartManager {
26 + charts: HashMap<String, Chart>,
27 }
28
26 -impl NetdataMetricsService {
27 - pub fn new(config: PluginConfig) -> Result<Self> {
28 - let mut chart_config_manager = ChartConfigManager::with_default_configs();
29 -
30 - // Load user chart configs if directory is specified
31 - if let Some(chart_configs_dir) = &config.metrics.chart_configs_dir {
32 - chart_config_manager
33 - .load_user_configs(chart_configs_dir)
34 - .with_context(|| {
35 - format!(
36 - "Failed to load chart configs from directory: {}",
37 - chart_configs_dir
38 - )
39 - })?;
40 - }
41 -
42 - Ok(Self {
43 - regex_cache: RegexCache::default(),
44 - charts: Arc::default(),
45 - config: Arc::new(config),
46 - chart_config_manager,
47 - call_count: std::sync::atomic::AtomicU64::new(0),
48 - })
29 +impl ChartManager {
30 + pub fn new() -> Self {
31 + Self {
32 + charts: HashMap::new(),
33 + }
34 }
35
51 - async fn cleanup_stale_charts(&self, max_age: std::time::Duration) {
52 - let now = std::time::SystemTime::now();
36 + /// Get an existing chart by name.
37 + pub fn get(&mut self, chart_name: &str) -> Option<&mut Chart> {
38 + self.charts.get_mut(chart_name)
39 + }
40
54 - let mut guard = self.charts.write().await;
55 - guard.retain(|_, chart| {
56 - let Some(chart_time) = chart.last_collection_time() else {
57 - return true;
58 - };
41 + /// Create a chart from a data point context.
42 + /// Returns None if the metric type is not supported.
43 + pub fn create_chart(
44 + &mut self,
45 + chart_name: &str,
46 + dp: &crate::iter::DataPointContext<'_>,
47 + config: ChartConfig,
48 + ) -> Option<&mut Chart> {
49 + let data_kind = dp.data_kind()?;
50 + let temporality = dp.aggregation_temporality();
51 + let is_monotonic = dp.is_monotonic();
52 +
53 + let chart = Chart::from_metric(chart_name, data_kind, temporality, is_monotonic, config)?;
54
60 - now.duration_since(chart_time)
61 - .unwrap_or(std::time::Duration::ZERO)
62 - < max_age
55 + self.charts.insert(chart_name.to_string(), chart);
56 + self.charts.get_mut(chart_name)
57 + }
58 +
59 + /// Create a chart with an explicit aggregation type and chart type.
60 + ///
61 + /// Used for histogram/summary decomposition where the aggregation type is
62 + /// determined by the caller rather than inferred from metric metadata.
63 + pub fn create_typed_chart(
64 + &mut self,
65 + chart_name: &str,
66 + aggregation_type: ChartAggregationType,
67 + chart_type: ChartType,
68 + config: ChartConfig,
69 + ) -> &mut Chart {
70 + let chart = Chart::new(chart_name, aggregation_type, chart_type, config);
71 + self.charts.insert(chart_name.to_string(), chart);
72 + self.charts.get_mut(chart_name).unwrap()
73 + }
74 +
75 + /// Finalize all active charts for the given slot timestamp and write updates into `buf`.
76 + ///
77 + /// Expired charts (no data for longer than their expiry duration) are removed.
78 + pub fn emit(&mut self, slot_timestamp: u64, buf: &mut String) {
79 + self.charts.retain(|_, chart| {
80 + if chart.is_expired() {
81 + return false;
82 + }
83 +
84 + chart.emit(slot_timestamp, buf);
85 + true
86 });
87 }
88 +
89 + pub fn len(&self) -> usize {
90 + self.charts.len()
91 + }
92 +
93 + #[allow(dead_code)]
94 + pub fn is_empty(&self) -> bool {
95 + self.charts.is_empty()
96 + }
97 }
98
67 -#[tonic::async_trait]
68 -impl MetricsService for NetdataMetricsService {
69 - async fn export(
70 - &self,
71 - request: Request<ExportMetricsServiceRequest>,
72 - ) -> Result<Response<ExportMetricsServiceResponse>, Status> {
73 - let req = request.into_inner();
74 -
75 - let flattened_points = flatten_metrics_request(&req)
76 - .into_iter()
77 - .filter_map(|jm| {
78 - let cfg = self.chart_config_manager.find_matching_config(&jm);
79 - FlattenedPoint::new(jm, cfg, &self.regex_cache)
80 - })
81 - .collect::<Vec<_>>();
82 -
83 - if self.config.metrics.print_flattened {
84 - // Just print the flattened points
85 - for fp in &flattened_points {
86 - println!("{:#?}", fp);
99 +pub struct NetdataMetricsService {
100 + pub chart_config_manager: Arc<RwLock<ChartConfigManager>>,
101 + pub chart_manager: Arc<RwLock<ChartManager>>,
102 + pub max_new_charts_per_request: usize,
103 +}
104 +
105 +impl NetdataMetricsService {
106 + pub fn new(
107 + chart_config_manager: Arc<RwLock<ChartConfigManager>>,
108 + chart_manager: Arc<RwLock<ChartManager>>,
109 + max_new_charts_per_request: usize,
110 + ) -> Self {
111 + Self {
112 + chart_config_manager,
113 + chart_manager,
114 + max_new_charts_per_request,
115 + }
116 + }
117 +
118 + /// Get an existing typed chart, or create it if the new-charts budget allows.
119 + /// Returns None if the budget is exhausted.
120 + fn get_or_create_typed(
121 + chart_manager: &mut ChartManager,
122 + chart_name: &str,
123 + aggregation_type: ChartAggregationType,
124 + chart_type: ChartType,
125 + config: ChartConfig,
126 + new_charts: &mut usize,
127 + budget: usize,
128 + ) -> bool {
129 + if chart_manager.get(chart_name).is_some() {
130 + return true;
131 + }
132 + if *new_charts >= budget {
133 + return false;
134 + }
135 + chart_manager.create_typed_chart(chart_name, aggregation_type, chart_type, config);
136 + *new_charts += 1;
137 + true
138 + }
139 +
140 + fn process_histogram(
141 + chart_manager: &mut ChartManager,
142 + dp: &DataPointContext<'_>,
143 + hdp: &HistogramDataPoint,
144 + ccm: &ChartConfigManager,
145 + chart_name_buf: &mut String,
146 + new_charts: &mut usize,
147 + budget: usize,
148 + ) {
149 + // Skip NO_RECORDED_VALUE (flag bit 0).
150 + if hdp.flags & 1 != 0 {
151 + return;
152 + }
153 +
154 + // Skip empty or invalid bucket structure.
155 + if hdp.bucket_counts.is_empty() {
156 + return;
157 + }
158 + if hdp.bucket_counts.len() != hdp.explicit_bounds.len() + 1 {
159 + return;
160 + }
161 +
162 + let chart_hash = dp.chart_hash();
163 + let chart_config = ccm.resolve_chart_config(dp.metric_ref.config.as_deref());
164 + let temporality = dp.aggregation_temporality();
165 + let timestamp_ns = hdp.time_unix_nano;
166 + let start_time_ns = hdp.start_time_unix_nano;
167 +
168 + let count_agg = match temporality {
169 + Some(AggregationTemporality::Delta) => ChartAggregationType::DeltaSum,
170 + Some(AggregationTemporality::Cumulative) => ChartAggregationType::CumulativeSum,
171 + _ => return,
172 + };
173 +
174 + // ---- Bucket chart (heatmap) ----
175 + chart_name_buf.clear();
176 + let _ = write!(
177 + chart_name_buf,
178 + "{}.{}.bucket",
179 + dp.metric_ref.metric.name, chart_hash
180 + );
181 +
182 + if !Self::get_or_create_typed(
183 + chart_manager,
184 + chart_name_buf,
185 + count_agg,
186 + ChartType::Heatmap,
187 + chart_config,
188 + new_charts,
189 + budget,
190 + ) {
191 + return;
192 + }
193 + let bucket_chart = chart_manager.get(chart_name_buf).unwrap();
194 + if !bucket_chart.has_definition() {
195 + chart_name_buf.clear();
196 + let _ = write!(chart_name_buf, "{}.bucket", dp.metric_ref.metric.name);
197 + bucket_chart.init_definition(
198 + chart_name_buf,
199 + &dp.metric_ref.metric.description,
200 + "events",
201 + dp.chart_labels(),
202 + );
203 + }
204 +
205 + let mut dim_buf = String::new();
206 + for (i, &count) in hdp.bucket_counts.iter().enumerate() {
207 + dim_buf.clear();
208 + if i < hdp.explicit_bounds.len() {
209 + let _ = write!(dim_buf, "{}", hdp.explicit_bounds[i]);
210 + } else {
211 + dim_buf.push_str("+Inf");
212 }
213 + bucket_chart.ingest(&dim_buf, count as f64, timestamp_ns, start_time_ns);
214 + }
215
89 - return Ok(Response::new(ExportMetricsServiceResponse {
90 - partial_success: None,
91 - }));
216 + // ---- Count chart (temporality-aware) ----
217 + chart_name_buf.clear();
218 + let _ = write!(
219 + chart_name_buf,
220 + "{}.{}.count",
221 + dp.metric_ref.metric.name, chart_hash
222 + );
223 +
224 + if !Self::get_or_create_typed(
225 + chart_manager,
226 + chart_name_buf,
227 + count_agg,
228 + ChartType::Line,
229 + chart_config,
230 + new_charts,
231 + budget,
232 + ) {
233 + return;
234 + }
235 + let count_chart = chart_manager.get(chart_name_buf).unwrap();
236 + if !count_chart.has_definition() {
237 + chart_name_buf.clear();
238 + let _ = write!(chart_name_buf, "{}.count", dp.metric_ref.metric.name);
239 + count_chart.init_definition(
240 + chart_name_buf,
241 + &dp.metric_ref.metric.description,
242 + "events",
243 + dp.chart_labels(),
244 + );
245 }
246 + count_chart.ingest("count", hdp.count as f64, timestamp_ns, start_time_ns);
247
94 - // ingest
95 - {
96 - let mut newly_created_charts = 0;
248 + // ---- Sum chart (temporality-aware) ----
249 + if let Some(sum) = hdp.sum {
250 + chart_name_buf.clear();
251 + let _ = write!(
252 + chart_name_buf,
253 + "{}.{}.sum",
254 + dp.metric_ref.metric.name, chart_hash
255 + );
256
98 - for fp in flattened_points.iter() {
99 - let mut guard = self.charts.write().await;
257 + if !Self::get_or_create_typed(
258 + chart_manager,
259 + chart_name_buf,
260 + count_agg,
261 + ChartType::Line,
262 + chart_config,
263 + new_charts,
264 + budget,
265 + ) {
266 + return;
267 + }
268 + let sum_chart = chart_manager.get(chart_name_buf).unwrap();
269 + if !sum_chart.has_definition() {
270 + chart_name_buf.clear();
271 + let _ = write!(chart_name_buf, "{}.sum", dp.metric_ref.metric.name);
272 + sum_chart.init_definition(
273 + chart_name_buf,
274 + &dp.metric_ref.metric.description,
275 + &dp.metric_ref.metric.unit,
276 + dp.chart_labels(),
277 + );
278 + }
279 + sum_chart.ingest("sum", sum, timestamp_ns, start_time_ns);
280 + }
281
101 - if let Some(netdata_chart) = guard.get_mut(&fp.nd_instance_name) {
102 - netdata_chart.ingest(fp);
103 - } else if newly_created_charts < self.config.metrics.throttle_charts {
104 - let mut netdata_chart =
105 - NetdataChart::from_flattened_point(fp, self.config.metrics.buffer_samples);
106 - netdata_chart.ingest(fp);
107 - guard.insert(fp.nd_instance_name.clone(), netdata_chart);
282 + // ---- Min/max chart (gauge, only if present) ----
283 + if hdp.min.is_some() || hdp.max.is_some() {
284 + chart_name_buf.clear();
285 + let _ = write!(
286 + chart_name_buf,
287 + "{}.{}.minmax",
288 + dp.metric_ref.metric.name, chart_hash
289 + );
290
109 - newly_created_charts += 1;
110 - }
291 + if !Self::get_or_create_typed(
292 + chart_manager,
293 + chart_name_buf,
294 + ChartAggregationType::Gauge,
295 + ChartType::Line,
296 + chart_config,
297 + new_charts,
298 + budget,
299 + ) {
300 + return;
301 + }
302 + let minmax_chart = chart_manager.get(chart_name_buf).unwrap();
303 + if !minmax_chart.has_definition() {
304 + chart_name_buf.clear();
305 + let _ = write!(chart_name_buf, "{}.minmax", dp.metric_ref.metric.name);
306 + minmax_chart.init_definition(
307 + chart_name_buf,
308 + &dp.metric_ref.metric.description,
309 + &dp.metric_ref.metric.unit,
310 + dp.chart_labels(),
311 + );
312 }
313 + if let Some(min) = hdp.min {
314 + minmax_chart.ingest("min", min, timestamp_ns, start_time_ns);
315 + }
316 + if let Some(max) = hdp.max {
317 + minmax_chart.ingest("max", max, timestamp_ns, start_time_ns);
318 + }
319 + }
320 + }
321 +
322 + fn process_summary(
323 + chart_manager: &mut ChartManager,
324 + dp: &DataPointContext<'_>,
325 + sdp: &SummaryDataPoint,
326 + ccm: &ChartConfigManager,
327 + chart_name_buf: &mut String,
328 + new_charts: &mut usize,
329 + budget: usize,
330 + ) {
331 + // Skip NO_RECORDED_VALUE (flag bit 0).
332 + if sdp.flags & 1 != 0 {
333 + return;
334 + }
335 +
336 + let chart_hash = dp.chart_hash();
337 + let chart_config = ccm.resolve_chart_config(dp.metric_ref.config.as_deref());
338 + let timestamp_ns = sdp.time_unix_nano;
339 + let start_time_ns = sdp.start_time_unix_nano;
340 +
341 + // Summaries are always cumulative.
342 + let cumulative = ChartAggregationType::CumulativeSum;
343 +
344 + // ---- Count chart ----
345 + chart_name_buf.clear();
346 + let _ = write!(
347 + chart_name_buf,
348 + "{}.{}.count",
349 + dp.metric_ref.metric.name, chart_hash
350 + );
351 +
352 + if !Self::get_or_create_typed(
353 + chart_manager,
354 + chart_name_buf,
355 + cumulative,
356 + ChartType::Line,
357 + chart_config,
358 + new_charts,
359 + budget,
360 + ) {
361 + return;
362 + }
363 + let count_chart = chart_manager.get(chart_name_buf).unwrap();
364 + if !count_chart.has_definition() {
365 + chart_name_buf.clear();
366 + let _ = write!(chart_name_buf, "{}.count", dp.metric_ref.metric.name);
367 + count_chart.init_definition(
368 + chart_name_buf,
369 + &dp.metric_ref.metric.description,
370 + "events",
371 + dp.chart_labels(),
372 + );
373 }
374 + count_chart.ingest("count", sdp.count as f64, timestamp_ns, start_time_ns);
375
114 - // process
115 - {
116 - let mut guard = self.charts.write().await;
117 - let mut output_buffer = String::new();
376 + // ---- Sum chart ----
377 + chart_name_buf.clear();
378 + let _ = write!(
379 + chart_name_buf,
380 + "{}.{}.sum",
381 + dp.metric_ref.metric.name, chart_hash
382 + );
383
119 - for netdata_chart in guard.values_mut() {
120 - netdata_chart.process(&mut output_buffer);
384 + if !Self::get_or_create_typed(
385 + chart_manager,
386 + chart_name_buf,
387 + cumulative,
388 + ChartType::Line,
389 + chart_config,
390 + new_charts,
391 + budget,
392 + ) {
393 + return;
394 + }
395 + let sum_chart = chart_manager.get(chart_name_buf).unwrap();
396 + if !sum_chart.has_definition() {
397 + chart_name_buf.clear();
398 + let _ = write!(chart_name_buf, "{}.sum", dp.metric_ref.metric.name);
399 + sum_chart.init_definition(
400 + chart_name_buf,
401 + &dp.metric_ref.metric.description,
402 + &dp.metric_ref.metric.unit,
403 + dp.chart_labels(),
404 + );
405 + }
406 + sum_chart.ingest("sum", sdp.sum, timestamp_ns, start_time_ns);
407 +
408 + // ---- Quantiles chart (only if quantile values are present) ----
409 + if !sdp.quantile_values.is_empty() {
410 + chart_name_buf.clear();
411 + let _ = write!(
412 + chart_name_buf,
413 + "{}.{}.quantiles",
414 + dp.metric_ref.metric.name, chart_hash
415 + );
416 +
417 + if !Self::get_or_create_typed(
418 + chart_manager,
419 + chart_name_buf,
420 + ChartAggregationType::Gauge,
421 + ChartType::Line,
422 + chart_config,
423 + new_charts,
424 + budget,
425 + ) {
426 + return;
427 + }
428 + let quantiles_chart = chart_manager.get(chart_name_buf).unwrap();
429 + if !quantiles_chart.has_definition() {
430 + chart_name_buf.clear();
431 + let _ = write!(chart_name_buf, "{}.quantiles", dp.metric_ref.metric.name);
432 + quantiles_chart.init_definition(
433 + chart_name_buf,
434 + &dp.metric_ref.metric.description,
435 + &dp.metric_ref.metric.unit,
436 + dp.chart_labels(),
437 + );
438 }
439
123 - // Write chart data to stdout
124 - print!("{}", output_buffer);
440 + let mut dim_buf = String::new();
441 + for qv in &sdp.quantile_values {
442 + dim_buf.clear();
443 + let _ = write!(dim_buf, "{}", qv.quantile);
444 + quantiles_chart.ingest(&dim_buf, qv.value, timestamp_ns, start_time_ns);
445 + }
446 }
447 + }
448 +
449 + async fn process_request(&self, req: &mut ExportMetricsServiceRequest) {
450 + otel::normalize_request(req);
451 +
452 + let ccm = self.chart_config_manager.read().await;
453 + let mut chart_manager = self.chart_manager.write().await;
454 + let mut chart_name_buf = String::with_capacity(128);
455 + let mut new_charts: usize = 0;
456 + let budget = self.max_new_charts_per_request;
457 +
458 + for dp in req.datapoint_iter(&ccm) {
459 + // Histogram decomposition: extract into bucket/stats/minmax charts.
460 + if let DataPointRef::Histogram(hdp) = &dp.datapoint_ref {
461 + Self::process_histogram(
462 + &mut chart_manager,
463 + &dp,
464 + hdp,
465 + &ccm,
466 + &mut chart_name_buf,
467 + &mut new_charts,
468 + budget,
469 + );
470 + continue;
471 + }
472 +
473 + // Summary decomposition: extract into count/sum/quantiles charts.
474 + if let DataPointRef::Summary(sdp) = &dp.datapoint_ref {
475 + Self::process_summary(
476 + &mut chart_manager,
477 + &dp,
478 + sdp,
479 + &ccm,
480 + &mut chart_name_buf,
481 + &mut new_charts,
482 + budget,
483 + );
484 + continue;
485 + }
486 +
487 + // Skip non-number data points (exponential histograms, etc.)
488 + let Some(value) = dp.datapoint_ref.value_as_f64() else {
489 + continue;
490 + };
491 +
492 + let dimension_name = dp.dimension_name();
493 + let chart_hash = dp.chart_hash();
494 + let timestamp_ns = dp.datapoint_ref.time_unix_nano();
495 + let start_time_ns = dp.datapoint_ref.start_time_unix_nano();
496 +
497 + // Build chart name
498 + chart_name_buf.clear();
499 + let _ = write!(
500 + &mut chart_name_buf,
501 + "{}.{}",
502 + dp.metric_ref.metric.name, chart_hash
503 + );
504 +
505 + // Resolve per-chart config
506 + let chart_config = ccm.resolve_chart_config(dp.metric_ref.config.as_deref());
507
127 - // cleanup stale charts
128 - {
129 - let prev_count = self
130 - .call_count
131 - .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
508 + // Try to get an existing chart first
509 + if let Some(chart) = chart_manager.get(&chart_name_buf) {
510 + if !chart.has_definition() {
511 + chart.init_definition(
512 + &dp.metric_ref.metric.name,
513 + &dp.metric_ref.metric.description,
514 + &dp.metric_ref.metric.unit,
515 + dp.chart_labels(),
516 + );
517 + }
518 + chart.ingest(dimension_name, value, timestamp_ns, start_time_ns);
519 + continue;
520 + }
521
133 - if prev_count % 60 == 0 {
134 - let one_hour = std::time::Duration::from_secs(3600);
135 - self.cleanup_stale_charts(one_hour).await;
522 + // Chart doesn't exist — check budget before creating
523 + if new_charts >= budget {
524 + continue;
525 }
526 +
527 + let Some(chart) = chart_manager.create_chart(&chart_name_buf, &dp, chart_config) else {
528 + // Unsupported metric type
529 + continue;
530 + };
531 + new_charts += 1;
532 +
533 + // Set chart definition from the first data point we see.
534 + chart.init_definition(
535 + &dp.metric_ref.metric.name,
536 + &dp.metric_ref.metric.description,
537 + &dp.metric_ref.metric.unit,
538 + dp.chart_labels(),
539 + );
540 +
541 + // Ingest the data point (accumulate only; emission happens on tick).
542 + chart.ingest(dimension_name, value, timestamp_ns, start_time_ns);
543 + }
544 +
545 + if new_charts >= budget {
546 + tracing::warn!(
547 + "new chart creation limit reached ({}) - some metrics were dropped",
548 + budget
549 + );
550 }
551
552 + let mut stored_dimensions = 0;
553 + for (_, chart) in chart_manager.charts.iter() {
554 + stored_dimensions += chart.len();
555 + }
556 +
557 + tracing::trace!(
558 + "charts: {}, dimensions: {}, new charts in request: {}",
559 + chart_manager.len(),
560 + stored_dimensions,
561 + new_charts
562 + );
563 + }
564 +}
565 +
566 +impl Default for NetdataMetricsService {
567 + fn default() -> Self {
568 + Self {
569 + chart_config_manager: Arc::new(RwLock::new(ChartConfigManager::with_default_configs())),
570 + chart_manager: Arc::new(RwLock::new(ChartManager::new())),
571 + max_new_charts_per_request: 100,
572 + }
573 + }
574 +}
575 +
576 +#[tonic::async_trait]
577 +impl MetricsService for NetdataMetricsService {
578 + async fn export(
579 + &self,
580 + request: Request<ExportMetricsServiceRequest>,
581 + ) -> Result<Response<ExportMetricsServiceResponse>, Status> {
582 + let mut req = request.into_inner();
583 +
584 + self.process_request(&mut req).await;
585 +
586 Ok(Response::new(ExportMetricsServiceResponse {
587 partial_success: None,
588 }))
src/crates/netdata-otel/otel-plugin/src/netdata_chart.rs deleted
-305
@@ -1,305 +0,0 @@
1 -use serde_json::{Map as JsonMap, Value as JsonValue};
2 -
3 -use crate::flattened_point::FlattenedPoint;
4 -use crate::samples_table::{CollectionInterval, SamplesTable};
5 -
6 -// Use a simple string buffer for chart protocol output
7 -pub type ChartOutputBuffer = String;
8 -
9 -#[derive(Debug, Default, Clone)]
10 -enum ChartState {
11 - #[default]
12 - Uninitialized,
13 - InGap,
14 - Initialized,
15 - Empty,
16 -}
17 -
18 -#[derive(Debug)]
19 -pub struct NetdataChart {
20 - chart_id: String,
21 - metric_name: String,
22 - metric_description: String,
23 - metric_unit: String,
24 - metric_type: String,
25 - is_monotonic: Option<bool>,
26 - attributes: JsonMap<String, JsonValue>,
27 -
28 - samples_table: SamplesTable,
29 - last_samples_table_interval: Option<CollectionInterval>,
30 - last_collection_interval: Option<CollectionInterval>,
31 - chart_state: ChartState,
32 - samples_threshold: usize,
33 -
34 - multiplier: i32,
35 - divisor: i32,
36 -}
37 -
38 -impl NetdataChart {
39 - pub fn from_flattened_point(fp: &FlattenedPoint, samples_threshold: usize) -> Self {
40 - Self {
41 - chart_id: fp.nd_instance_name.clone(),
42 - metric_name: fp.metric_name.clone(),
43 - metric_description: fp.metric_description.clone(),
44 - metric_unit: fp.metric_unit.clone(),
45 - metric_type: fp.metric_type.clone(),
46 - attributes: fp.attributes.clone(),
47 - is_monotonic: fp.metric_is_monotonic,
48 -
49 - samples_table: SamplesTable::default(),
50 - last_samples_table_interval: None,
51 - last_collection_interval: None,
52 - chart_state: ChartState::Uninitialized,
53 -
54 - samples_threshold,
55 -
56 - multiplier: 1,
57 - divisor: 1,
58 - }
59 - }
60 -
61 - fn is_histogram(&self) -> bool {
62 - self.metric_type == "histogram"
63 - }
64 -
65 - pub fn ingest(&mut self, fp: &FlattenedPoint) {
66 - let dimension_name = &fp.nd_dimension_name;
67 - let value = fp.metric_value;
68 - let unix_time = fp.metric_time_unix_nano;
69 -
70 - let new_dimension = self.samples_table.insert(dimension_name, unix_time, value);
71 -
72 - if new_dimension {
73 - self.chart_state = ChartState::Uninitialized;
74 - self.last_samples_table_interval = None;
75 - self.last_collection_interval = None;
76 - }
77 - }
78 -
79 - fn initialize(&mut self, buffer: &mut ChartOutputBuffer) -> bool {
80 - // Clean up stale samples if we have a previous interval
81 - if let Some(ci) = &self.last_samples_table_interval {
82 - self.samples_table.drop_stale_samples(ci);
83 - }
84 -
85 - // Check if we have enough samples to determine frequency
86 - if self.samples_table.total_samples() < self.samples_threshold {
87 - return false;
88 - }
89 -
90 - // Store the old interval before calculating the new one
91 - let old_lci = self.last_collection_interval;
92 -
93 - // Set up collection intervals
94 - self.last_samples_table_interval =
95 - self.samples_table
96 - .collection_interval()
97 - .map(|ci| CollectionInterval {
98 - end_time: ci.end_time - ci.update_every.get(),
99 - update_every: ci.update_every,
100 - });
101 -
102 - self.last_collection_interval = self
103 - .last_samples_table_interval
104 - .and_then(|ci| ci.aligned_interval());
105 -
106 - (self.multiplier, self.divisor) = self.samples_table.scaling_factors();
107 -
108 - // Check if we need to emit a chart definition
109 - if let Some(new_lci) = &self.last_collection_interval {
110 - if let Some(old_lci) = old_lci {
111 - if old_lci.update_every != new_lci.update_every {
112 - // Update every changed, emit the chart definition again
113 - self.emit_chart_definition(buffer);
114 - }
115 - } else {
116 - // No previous collection interval, we need to emit the
117 - // chart definition first
118 - self.emit_chart_definition(buffer);
119 - }
120 - }
121 -
122 - true
123 - }
124 -
125 - pub fn process(&mut self, buffer: &mut ChartOutputBuffer) {
126 - loop {
127 - match &self.chart_state {
128 - ChartState::Uninitialized | ChartState::InGap => {
129 - if !self.initialize(buffer) {
130 - return;
131 - }
132 -
133 - self.chart_state = ChartState::Initialized;
134 - }
135 - ChartState::Initialized => {
136 - self.chart_state = self.process_next_interval(buffer);
137 - }
138 - ChartState::Empty => {
139 - self.chart_state = ChartState::Initialized;
140 - return;
141 - }
142 - }
143 - }
144 - }
145 -
146 - fn emit_chart_definition(&self, buffer: &mut ChartOutputBuffer) {
147 - let ci = self.last_collection_interval.unwrap();
148 - let ue = ci.update_every;
149 -
150 - let type_id = &self.chart_id;
151 - let name = "";
152 - let title = &self.metric_description;
153 - let units = &self.metric_unit;
154 - let context = format!("otel.{}", &self.metric_name);
155 - let family = self.metric_name.replace('.', "/");
156 - let chart_type = if self.is_histogram() {
157 - "heatmap"
158 - } else {
159 - "line"
160 - };
161 - let priority = 1;
162 - let update_every = std::time::Duration::from_nanos(ue.get()).as_secs();
163 -
164 - // CHART command
165 - buffer.push_str(&format!(
166 - "CHART {type_id} '{name}' '{title}' '{units}' '{family}' '{context}' {chart_type} {priority} {update_every}\n"
167 - ));
168 -
169 - // CLABEL commands
170 - for (key, value) in self.attributes.iter() {
171 - let value_str = match value {
172 - JsonValue::String(s) => s.clone(),
173 - JsonValue::Number(n) => n.to_string(),
174 - JsonValue::Bool(b) => b.to_string(),
175 - _ => continue,
176 - };
177 -
178 - buffer.push_str(&format!("CLABEL '{key}' '{value_str}' 1\n"));
179 - }
180 - buffer.push_str("CLABEL_COMMIT\n");
181 -
182 - // Emit dimensions
183 - if self.is_histogram() {
184 - let mut dimension_names = self.samples_table.iter_dimensions().collect::<Vec<_>>();
185 -
186 - dimension_names.sort_by(|a, b| {
187 - let a_val = if *a == "+Inf" {
188 - f64::INFINITY
189 - } else {
190 - a.parse::<f64>().unwrap()
191 - };
192 - let b_val = if *b == "+Inf" {
193 - f64::INFINITY
194 - } else {
195 - b.parse::<f64>().unwrap()
196 - };
197 - a_val.partial_cmp(&b_val).unwrap()
198 - });
199 -
200 - for dimension_name in dimension_names {
201 - let algorithm = match self.is_monotonic {
202 - Some(true) => "incremental",
203 - _ => "absolute",
204 - };
205 - buffer.push_str(&format!(
206 - "DIMENSION {} {} {} 1 {}\n",
207 - dimension_name, dimension_name, algorithm, self.divisor
208 - ));
209 - }
210 - } else {
211 - for dimension_name in self.samples_table.iter_dimensions() {
212 - let algorithm = match self.is_monotonic {
213 - Some(true) => "incremental",
214 - _ => "absolute",
215 - };
216 - buffer.push_str(&format!(
217 - "DIMENSION {} {} {} 1 {}\n",
218 - dimension_name, dimension_name, algorithm, self.divisor
219 - ));
220 - }
221 - }
222 - }
223 -
224 - fn process_next_interval(&mut self, buffer: &mut ChartOutputBuffer) -> ChartState {
225 - let lsti = match &self.last_samples_table_interval {
226 - Some(interval) => interval,
227 - None => return ChartState::Empty,
228 - };
229 -
230 - let lci = match &self.last_collection_interval {
231 - Some(interval) => interval,
232 - None => return ChartState::Empty,
233 - };
234 -
235 - // Clean stale samples
236 - self.samples_table.drop_stale_samples(lsti);
237 - if self.samples_table.is_empty() {
238 - return ChartState::Empty;
239 - }
240 -
241 - // Check for gaps
242 - let have_gap = self
243 - .samples_table
244 - .iter_samples_buffers()
245 - .all(|sb| sb.first().is_none_or(|sp| lsti.is_in_gap(sp)));
246 -
247 - if have_gap {
248 - return ChartState::InGap;
249 - }
250 -
251 - // Collect samples to emit
252 - let mut samples_to_emit = Vec::new();
253 - for (dimension_name, sb) in &mut self.samples_table.iter_mut() {
254 - if let Some(sp) = sb.first() {
255 - if lsti.is_on_time(sp) {
256 - if let Some(sample) = sb.pop() {
257 - samples_to_emit.push((dimension_name.clone(), sample.value));
258 - }
259 - }
260 - }
261 - }
262 -
263 - // Emit data if we have samples
264 - if !samples_to_emit.is_empty() {
265 - self.emit_begin(buffer, lci.update_every.get());
266 - for (dimension_name, value) in samples_to_emit {
267 - self.emit_set(buffer, &dimension_name, value);
268 - }
269 - self.emit_end(buffer);
270 - }
271 -
272 - // Move to next interval
273 - self.last_samples_table_interval = Some(lsti.next_interval());
274 - self.last_collection_interval = Some(lci.next_interval());
275 -
276 - ChartState::Initialized
277 - }
278 -
279 - fn emit_begin(&self, buffer: &mut ChartOutputBuffer, update_every: u64) {
280 - let ue = std::time::Duration::from_nanos(update_every).as_micros() as u64;
281 - buffer.push_str(&format!("BEGIN {} {}\n", self.chart_id, ue));
282 - }
283 -
284 - fn emit_set(&self, buffer: &mut ChartOutputBuffer, dimension_name: &str, value: f64) {
285 - buffer.push_str(&format!(
286 - "SET {} {}\n",
287 - dimension_name,
288 - value * self.divisor as f64
289 - ));
290 - }
291 -
292 - fn emit_end(&self, buffer: &mut ChartOutputBuffer) {
293 - let collection_time = std::time::Duration::from_nanos(
294 - self.last_collection_interval.unwrap().collection_time(),
295 - )
296 - .as_secs();
297 - buffer.push_str(&format!("END {collection_time}\n"));
298 - }
299 -
300 - pub fn last_collection_time(&self) -> Option<std::time::SystemTime> {
301 - self.last_collection_interval.as_ref().map(|lci| {
302 - std::time::UNIX_EPOCH + std::time::Duration::from_nanos(lci.collection_time())
303 - })
304 - }
305 -}
src/crates/netdata-otel/otel-plugin/src/otel.rs new
+574
@@ -0,0 +1,574 @@
1 +//! OpenTelemetry protocol extensions for normalization, comparison, hashing, and data point iteration.
2 +
3 +use opentelemetry_proto::tonic::{
4 + collector::metrics::v1::ExportMetricsServiceRequest,
5 + common::v1::{
6 + AnyValue, ArrayValue, InstrumentationScope, KeyValue, KeyValueList, any_value::Value,
7 + },
8 + metrics::v1::{
9 + ExponentialHistogram, ExponentialHistogramDataPoint, Gauge, Histogram, HistogramDataPoint,
10 + Metric, NumberDataPoint, Sum, Summary, SummaryDataPoint, metric, number_data_point,
11 + },
12 + resource::v1::Resource,
13 +};
14 +use std::cmp::Ordering;
15 +use std::hash::{Hash, Hasher};
16 +
17 +/*
18 + * tag: compare
19 + */
20 +
21 +trait Compare {
22 + fn compare(&self, other: &Self) -> Ordering;
23 +}
24 +
25 +impl Compare for Value {
26 + fn compare(&self, other: &Self) -> Ordering {
27 + fn tag(value: &Value) -> u8 {
28 + match value {
29 + Value::StringValue(_) => 1,
30 + Value::BoolValue(_) => 2,
31 + Value::IntValue(_) => 3,
32 + Value::DoubleValue(_) => 4,
33 + Value::ArrayValue(_) => 5,
34 + Value::KvlistValue(_) => 6,
35 + Value::BytesValue(_) => 7,
36 + }
37 + }
38 +
39 + match tag(self).cmp(&tag(other)) {
40 + Ordering::Equal => match (self, other) {
41 + (Value::StringValue(a), Value::StringValue(b)) => a.cmp(b),
42 + (Value::BoolValue(a), Value::BoolValue(b)) => a.cmp(b),
43 + (Value::IntValue(a), Value::IntValue(b)) => a.cmp(b),
44 + (Value::DoubleValue(a), Value::DoubleValue(b)) => a.total_cmp(b),
45 + (Value::ArrayValue(a), Value::ArrayValue(b)) => a.compare(b),
46 + (Value::KvlistValue(a), Value::KvlistValue(b)) => a.compare(b),
47 + (Value::BytesValue(a), Value::BytesValue(b)) => a.cmp(b),
48 + _ => unreachable!("tags were equal"),
49 + },
50 + ord => ord,
51 + }
52 + }
53 +}
54 +
55 +impl Compare for AnyValue {
56 + fn compare(&self, other: &Self) -> Ordering {
57 + match (&self.value, &other.value) {
58 + (None, None) => Ordering::Equal,
59 + (None, Some(_)) => Ordering::Less,
60 + (Some(_), None) => Ordering::Greater,
61 + (Some(a), Some(b)) => a.compare(b),
62 + }
63 + }
64 +}
65 +
66 +impl<T: Compare> Compare for Vec<T> {
67 + fn compare(&self, other: &Self) -> Ordering {
68 + match self.len().cmp(&other.len()) {
69 + Ordering::Equal => {
70 + for (a, b) in self.iter().zip(other.iter()) {
71 + match a.compare(b) {
72 + Ordering::Equal => continue,
73 + ord => return ord,
74 + }
75 + }
76 + Ordering::Equal
77 + }
78 + ord => ord,
79 + }
80 + }
81 +}
82 +
83 +impl Compare for ArrayValue {
84 + fn compare(&self, other: &Self) -> Ordering {
85 + self.values.compare(&other.values)
86 + }
87 +}
88 +
89 +impl Compare for KeyValue {
90 + fn compare(&self, other: &Self) -> Ordering {
91 + match self.key.cmp(&other.key) {
92 + Ordering::Equal => self.value.compare(&other.value),
93 + ord => ord,
94 + }
95 + }
96 +}
97 +
98 +impl Compare for KeyValueList {
99 + fn compare(&self, other: &Self) -> Ordering {
100 + self.values.compare(&other.values)
101 + }
102 +}
103 +
104 +impl Compare for Option<AnyValue> {
105 + fn compare(&self, other: &Self) -> Ordering {
106 + match (self, other) {
107 + (None, None) => Ordering::Equal,
108 + (None, Some(_)) => Ordering::Less,
109 + (Some(_), None) => Ordering::Greater,
110 + (Some(a), Some(b)) => a.compare(b),
111 + }
112 + }
113 +}
114 +
115 +/*
116 + * tag: normalize
117 + */
118 +
119 +trait Normalize {
120 + fn normalize(&mut self);
121 +}
122 +
123 +impl Normalize for Value {
124 + fn normalize(&mut self) {
125 + match self {
126 + Value::KvlistValue(kv) => kv.normalize(),
127 + Value::ArrayValue(arr) => arr.normalize(),
128 + _ => {} // Primitive types don't need normalization
129 + }
130 + }
131 +}
132 +
133 +impl Normalize for AnyValue {
134 + fn normalize(&mut self) {
135 + if let Some(v) = &mut self.value {
136 + v.normalize();
137 + }
138 + }
139 +}
140 +
141 +impl Normalize for ArrayValue {
142 + fn normalize(&mut self) {
143 + // Normalize elements but don't sort - array order is meaningful
144 + for v in &mut self.values {
145 + v.normalize();
146 + }
147 + }
148 +}
149 +
150 +impl Normalize for KeyValue {
151 + fn normalize(&mut self) {
152 + if let Some(v) = &mut self.value {
153 + v.normalize();
154 + }
155 + }
156 +}
157 +
158 +impl Normalize for KeyValueList {
159 + fn normalize(&mut self) {
160 + for kv in &mut self.values {
161 + kv.normalize();
162 + }
163 + self.values.sort_by(|a, b| a.compare(b));
164 + }
165 +}
166 +
167 +impl Normalize for Resource {
168 + fn normalize(&mut self) {
169 + for kv in &mut self.attributes {
170 + kv.normalize();
171 + }
172 + self.attributes.sort_by(|a, b| a.compare(b));
173 + }
174 +}
175 +
176 +impl Normalize for InstrumentationScope {
177 + fn normalize(&mut self) {
178 + for kv in &mut self.attributes {
179 + kv.normalize();
180 + }
181 + self.attributes.sort_by(|a, b| a.compare(b));
182 + }
183 +}
184 +
185 +impl<T: Normalize> Normalize for Option<T> {
186 + fn normalize(&mut self) {
187 + if let Some(v) = self {
188 + v.normalize();
189 + }
190 + }
191 +}
192 +
193 +impl Normalize for NumberDataPoint {
194 + fn normalize(&mut self) {
195 + for kv in &mut self.attributes {
196 + kv.normalize();
197 + }
198 + self.attributes.sort_by(|a, b| a.compare(b));
199 + }
200 +}
201 +
202 +impl Normalize for HistogramDataPoint {
203 + fn normalize(&mut self) {
204 + for kv in &mut self.attributes {
205 + kv.normalize();
206 + }
207 + self.attributes.sort_by(|a, b| a.compare(b));
208 + }
209 +}
210 +
211 +impl Normalize for ExponentialHistogramDataPoint {
212 + fn normalize(&mut self) {
213 + for kv in &mut self.attributes {
214 + kv.normalize();
215 + }
216 + self.attributes.sort_by(|a, b| a.compare(b));
217 + }
218 +}
219 +
220 +impl Normalize for SummaryDataPoint {
221 + fn normalize(&mut self) {
222 + for kv in &mut self.attributes {
223 + kv.normalize();
224 + }
225 + self.attributes.sort_by(|a, b| a.compare(b));
226 + }
227 +}
228 +
229 +impl Normalize for Gauge {
230 + fn normalize(&mut self) {
231 + for dp in &mut self.data_points {
232 + dp.normalize();
233 + }
234 + }
235 +}
236 +
237 +impl Normalize for Sum {
238 + fn normalize(&mut self) {
239 + for dp in &mut self.data_points {
240 + dp.normalize();
241 + }
242 + }
243 +}
244 +
245 +impl Normalize for Histogram {
246 + fn normalize(&mut self) {
247 + for dp in &mut self.data_points {
248 + dp.normalize();
249 + }
250 + }
251 +}
252 +
253 +impl Normalize for ExponentialHistogram {
254 + fn normalize(&mut self) {
255 + for dp in &mut self.data_points {
256 + dp.normalize();
257 + }
258 + }
259 +}
260 +
261 +impl Normalize for Summary {
262 + fn normalize(&mut self) {
263 + for dp in &mut self.data_points {
264 + dp.normalize();
265 + }
266 + }
267 +}
268 +
269 +impl Normalize for metric::Data {
270 + fn normalize(&mut self) {
271 + match self {
272 + metric::Data::Gauge(g) => g.normalize(),
273 + metric::Data::Sum(s) => s.normalize(),
274 + metric::Data::Histogram(h) => h.normalize(),
275 + metric::Data::ExponentialHistogram(eh) => eh.normalize(),
276 + metric::Data::Summary(s) => s.normalize(),
277 + }
278 + }
279 +}
280 +
281 +impl Normalize for Metric {
282 + fn normalize(&mut self) {
283 + for kv in &mut self.metadata {
284 + kv.normalize();
285 + }
286 + self.metadata.sort_by(|a, b| a.compare(b));
287 + if let Some(data) = &mut self.data {
288 + data.normalize();
289 + }
290 + }
291 +}
292 +
293 +/// Normalize an ExportMetricsServiceRequest by recursively sorting all attributes
294 +pub fn normalize_request(request: &mut ExportMetricsServiceRequest) {
295 + for rm in &mut request.resource_metrics {
296 + rm.resource.normalize();
297 + for sm in &mut rm.scope_metrics {
298 + sm.scope.normalize();
299 + for m in &mut sm.metrics {
300 + m.normalize();
301 + }
302 + }
303 + }
304 +}
305 +
306 +/*
307 + * tag: hash
308 + */
309 +
310 +pub trait MetricIdentityHash {
311 + fn identity_hash<H: Hasher>(&self, state: &mut H);
312 +}
313 +
314 +impl MetricIdentityHash for Value {
315 + fn identity_hash<H: Hasher>(&self, state: &mut H) {
316 + // Hash the discriminant tag first (same tags as Compare)
317 + let tag: u8 = match self {
318 + Value::StringValue(_) => 1,
319 + Value::BoolValue(_) => 2,
320 + Value::IntValue(_) => 3,
321 + Value::DoubleValue(_) => 4,
322 + Value::ArrayValue(_) => 5,
323 + Value::KvlistValue(_) => 6,
324 + Value::BytesValue(_) => 7,
325 + };
326 + tag.hash(state);
327 +
328 + match self {
329 + Value::StringValue(v) => v.hash(state),
330 + Value::BoolValue(v) => v.hash(state),
331 + Value::IntValue(v) => v.hash(state),
332 + Value::DoubleValue(v) => v.to_bits().hash(state),
333 + Value::ArrayValue(v) => v.identity_hash(state),
334 + Value::KvlistValue(v) => v.identity_hash(state),
335 + Value::BytesValue(v) => v.hash(state),
336 + }
337 + }
338 +}
339 +
340 +impl MetricIdentityHash for AnyValue {
341 + fn identity_hash<H: Hasher>(&self, state: &mut H) {
342 + self.value.identity_hash(state);
343 + }
344 +}
345 +
346 +impl<T: MetricIdentityHash> MetricIdentityHash for Option<T> {
347 + fn identity_hash<H: Hasher>(&self, state: &mut H) {
348 + match self {
349 + None => 0u8.hash(state),
350 + Some(v) => {
351 + 1u8.hash(state);
352 + v.identity_hash(state);
353 + }
354 + }
355 + }
356 +}
357 +
358 +impl<T: MetricIdentityHash> MetricIdentityHash for Vec<T> {
359 + fn identity_hash<H: Hasher>(&self, state: &mut H) {
360 + self.len().hash(state);
361 + for item in self {
362 + item.identity_hash(state);
363 + }
364 + }
365 +}
366 +
367 +impl MetricIdentityHash for ArrayValue {
368 + fn identity_hash<H: Hasher>(&self, state: &mut H) {
369 + self.values.identity_hash(state);
370 + }
371 +}
372 +
373 +impl MetricIdentityHash for KeyValue {
374 + fn identity_hash<H: Hasher>(&self, state: &mut H) {
375 + self.key.hash(state);
376 + self.value.identity_hash(state);
377 + }
378 +}
379 +
380 +impl MetricIdentityHash for KeyValueList {
381 + fn identity_hash<H: Hasher>(&self, state: &mut H) {
382 + self.values.identity_hash(state);
383 + }
384 +}
385 +
386 +impl MetricIdentityHash for Resource {
387 + fn identity_hash<H: Hasher>(&self, state: &mut H) {
388 + self.attributes.identity_hash(state);
389 + state.write_u32(self.dropped_attributes_count);
390 + // ignore entity refs
391 + }
392 +}
393 +
394 +impl MetricIdentityHash for InstrumentationScope {
395 + fn identity_hash<H: Hasher>(&self, state: &mut H) {
396 + self.name.hash(state);
397 + self.version.hash(state);
398 + self.attributes.identity_hash(state);
399 + state.write_u32(self.dropped_attributes_count);
400 + }
401 +}
402 +
403 +impl MetricIdentityHash for Metric {
404 + fn identity_hash<H: Hasher>(&self, state: &mut H) {
405 + self.name.hash(state);
406 + self.description.hash(state);
407 + self.unit.hash(state);
408 +
409 + if let Some(data) = &self.data {
410 + std::mem::discriminant(data).hash(state);
411 + match data {
412 + metric::Data::Sum(s) => {
413 + state.write_i32(s.aggregation_temporality);
414 + state.write_u8(u8::from(s.is_monotonic));
415 + }
416 + metric::Data::Histogram(h) => {
417 + state.write_i32(h.aggregation_temporality);
418 + }
419 + metric::Data::ExponentialHistogram(eh) => {
420 + state.write_i32(eh.aggregation_temporality);
421 + }
422 + _ => {}
423 + }
424 + }
425 + }
426 +}
427 +
428 +/*
429 + * tag: datapoint
430 + */
431 +
432 +/// A reference to a data point, abstracting over the different data point types.
433 +pub enum DataPointRef<'a> {
434 + Number(&'a NumberDataPoint),
435 + Histogram(&'a HistogramDataPoint),
436 + ExponentialHistogram(&'a ExponentialHistogramDataPoint),
437 + Summary(&'a SummaryDataPoint),
438 +}
439 +
440 +impl<'a> DataPointRef<'a> {
441 + /// Get the attributes of this data point.
442 + pub fn attributes(&self) -> &[KeyValue] {
443 + match self {
444 + DataPointRef::Number(dp) => &dp.attributes,
445 + DataPointRef::Histogram(dp) => &dp.attributes,
446 + DataPointRef::ExponentialHistogram(dp) => &dp.attributes,
447 + DataPointRef::Summary(dp) => &dp.attributes,
448 + }
449 + }
450 +
451 + /// Get the value of a specific attribute by key.
452 + pub fn get_attribute(&self, key: &str) -> Option<&KeyValue> {
453 + self.attributes().iter().find(|kv| kv.key == key)
454 + }
455 +
456 + /// Get the dimension name based on the dimension attribute key.
457 + /// If the key is provided and found, returns the string value of that attribute.
458 + /// Otherwise returns "value".
459 + pub fn dimension_name(&self, dimension_attr_key: Option<&str>) -> &str {
460 + let Some(key) = dimension_attr_key else {
461 + return "value";
462 + };
463 +
464 + self.get_attribute(key)
465 + .and_then(|kv| kv.value.as_ref())
466 + .and_then(|v| v.value.as_ref())
467 + .and_then(|v| match v {
468 + Value::StringValue(s) => Some(s.as_str()),
469 + _ => None,
470 + })
471 + .unwrap_or("value")
472 + }
473 +
474 + /// Hash the attributes, excluding the dimension attribute key if provided.
475 + pub fn hash_attributes<H: Hasher>(&self, state: &mut H, exclude_key: Option<&str>) {
476 + for kv in self.attributes() {
477 + if exclude_key.is_some_and(|k| k == kv.key) {
478 + continue;
479 + }
480 + kv.identity_hash(state);
481 + }
482 + }
483 +
484 + /// Get the underlying NumberDataPoint if this is a number type (Gauge or Sum).
485 + pub fn as_number(&self) -> Option<&NumberDataPoint> {
486 + match self {
487 + DataPointRef::Number(dp) => Some(dp),
488 + _ => None,
489 + }
490 + }
491 +
492 + /// Get the numeric value as f64.
493 + /// Returns None if this is not a number data point or has no value.
494 + pub fn value_as_f64(&self) -> Option<f64> {
495 + self.as_number().and_then(|dp| {
496 + dp.value.as_ref().map(|v| match v {
497 + number_data_point::Value::AsDouble(d) => *d,
498 + number_data_point::Value::AsInt(i) => *i as f64,
499 + })
500 + })
501 + }
502 +
503 + /// Get the time_unix_nano field.
504 + /// Returns 0 if not a number data point.
505 + pub fn time_unix_nano(&self) -> u64 {
506 + match self {
507 + DataPointRef::Number(dp) => dp.time_unix_nano,
508 + DataPointRef::Histogram(dp) => dp.time_unix_nano,
509 + DataPointRef::ExponentialHistogram(dp) => dp.time_unix_nano,
510 + DataPointRef::Summary(dp) => dp.time_unix_nano,
511 + }
512 + }
513 +
514 + /// Get the start_time_unix_nano field.
515 + /// Returns 0 if not a number data point.
516 + pub fn start_time_unix_nano(&self) -> u64 {
517 + match self {
518 + DataPointRef::Number(dp) => dp.start_time_unix_nano,
519 + DataPointRef::Histogram(dp) => dp.start_time_unix_nano,
520 + DataPointRef::ExponentialHistogram(dp) => dp.start_time_unix_nano,
521 + DataPointRef::Summary(dp) => dp.start_time_unix_nano,
522 + }
523 + }
524 +}
525 +
526 +/// Iterator over data points in a metric.
527 +pub struct DataPointIter<'a> {
528 + inner: DataPointIterInner<'a>,
529 +}
530 +
531 +enum DataPointIterInner<'a> {
532 + Number(std::slice::Iter<'a, NumberDataPoint>),
533 + Histogram(std::slice::Iter<'a, HistogramDataPoint>),
534 + ExponentialHistogram(std::slice::Iter<'a, ExponentialHistogramDataPoint>),
535 + Summary(std::slice::Iter<'a, SummaryDataPoint>),
536 + Empty,
537 +}
538 +
539 +impl<'a> Iterator for DataPointIter<'a> {
540 + type Item = DataPointRef<'a>;
541 +
542 + fn next(&mut self) -> Option<Self::Item> {
543 + match &mut self.inner {
544 + DataPointIterInner::Number(iter) => iter.next().map(DataPointRef::Number),
545 + DataPointIterInner::Histogram(iter) => iter.next().map(DataPointRef::Histogram),
546 + DataPointIterInner::ExponentialHistogram(iter) => {
547 + iter.next().map(DataPointRef::ExponentialHistogram)
548 + }
549 + DataPointIterInner::Summary(iter) => iter.next().map(DataPointRef::Summary),
550 + DataPointIterInner::Empty => None,
551 + }
552 + }
553 +}
554 +
555 +/// Extension trait to iterate over data points in a metric.
556 +pub trait DataPointIterExt {
557 + fn data_points(&self) -> DataPointIter<'_>;
558 +}
559 +
560 +impl DataPointIterExt for Metric {
561 + fn data_points(&self) -> DataPointIter<'_> {
562 + let inner = match &self.data {
563 + Some(metric::Data::Gauge(g)) => DataPointIterInner::Number(g.data_points.iter()),
564 + Some(metric::Data::Sum(s)) => DataPointIterInner::Number(s.data_points.iter()),
565 + Some(metric::Data::Histogram(h)) => DataPointIterInner::Histogram(h.data_points.iter()),
566 + Some(metric::Data::ExponentialHistogram(eh)) => {
567 + DataPointIterInner::ExponentialHistogram(eh.data_points.iter())
568 + }
569 + Some(metric::Data::Summary(s)) => DataPointIterInner::Summary(s.data_points.iter()),
570 + None => DataPointIterInner::Empty,
571 + };
572 + DataPointIter { inner }
573 + }
574 +}
src/crates/netdata-otel/otel-plugin/src/output.rs new
+162
@@ -0,0 +1,162 @@
1 +//! Output formatting for Netdata plugin protocol emission.
2 +
3 +use std::fmt::{self, Write as _};
4 +
5 +/// The Netdata chart type.
6 +#[derive(Debug, Clone, Copy, Default)]
7 +pub enum ChartType {
8 + #[default]
9 + Line,
10 + Heatmap,
11 +}
12 +
13 +impl fmt::Display for ChartType {
14 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15 + match self {
16 + ChartType::Line => f.write_str("line"),
17 + ChartType::Heatmap => f.write_str("heatmap"),
18 + }
19 + }
20 +}
21 +
22 +/// Fixed precision divisor for float-to-integer scaling.
23 +///
24 +/// Netdata SET values are integers. To preserve decimal precision,
25 +/// we multiply the float value by this divisor before truncating to i64,
26 +/// and declare the same divisor in the DIMENSION line so Netdata divides
27 +/// it back out for display: `displayed = SET_value * 1 / DIVISOR`.
28 +pub const PRECISION_DIVISOR: i64 = 1000;
29 +
30 +/// Wrapper that writes a string with single quotes replaced by double quotes.
31 +///
32 +/// The Netdata plugin protocol uses single quotes to delimit fields in CHART
33 +/// and CLABEL lines. If a value contains a literal single quote, it breaks
34 +/// the agent's parser. There is no escape mechanism, so we replace `'` with `"`.
35 +struct SanitizedQuote<'a>(&'a str);
36 +
37 +impl fmt::Display for SanitizedQuote<'_> {
38 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 + for ch in self.0.chars() {
40 + if ch == '\'' {
41 + f.write_char('"')?;
42 + } else {
43 + f.write_char(ch)?;
44 + }
45 + }
46 + Ok(())
47 + }
48 +}
49 +
50 +/// A Netdata chart definition (CHART + CLABEL + DIMENSION block).
51 +pub struct ChartDefinition {
52 + pub chart_name: String,
53 + pub title: String,
54 + pub units: String,
55 + pub family: String,
56 + pub context: String,
57 + pub chart_type: ChartType,
58 + pub update_every: u64,
59 + pub labels: Vec<(String, String)>,
60 + pub dimensions: Vec<String>,
61 +}
62 +
63 +impl fmt::Display for ChartDefinition {
64 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 + writeln!(
66 + f,
67 + "CHART {} '' '{}' '{}' '{}' '{}' {} 1 {} 'store_first'",
68 + self.chart_name,
69 + SanitizedQuote(&self.title),
70 + SanitizedQuote(&self.units),
71 + SanitizedQuote(&self.family),
72 + SanitizedQuote(&self.context),
73 + self.chart_type,
74 + self.update_every,
75 + )?;
76 +
77 + if !self.labels.is_empty() {
78 + for (key, value) in &self.labels {
79 + writeln!(
80 + f,
81 + "CLABEL '{}' '{}' 1",
82 + SanitizedQuote(key),
83 + SanitizedQuote(value),
84 + )?;
85 + }
86 + writeln!(f, "CLABEL_COMMIT")?;
87 + }
88 +
89 + for dim_name in &self.dimensions {
90 + writeln!(
91 + f,
92 + "DIMENSION {} {} absolute 1 {}",
93 + dim_name, dim_name, PRECISION_DIVISOR,
94 + )?;
95 + }
96 +
97 + Ok(())
98 + }
99 +}
100 +
101 +impl ChartDefinition {
102 + /// Sort dimensions numerically (ascending), treating "+Inf" as infinity.
103 + ///
104 + /// Used for heatmap charts where Netdata preserves the plugin-defined
105 + /// dimension order and the dashboard renders buckets in that order.
106 + pub fn sort_dimensions_numerically(&mut self) {
107 + self.dimensions.sort_by(|a, b| {
108 + let a_val = if a == "+Inf" {
109 + f64::INFINITY
110 + } else {
111 + a.parse::<f64>().unwrap_or(f64::INFINITY)
112 + };
113 + let b_val = if b == "+Inf" {
114 + f64::INFINITY
115 + } else {
116 + b.parse::<f64>().unwrap_or(f64::INFINITY)
117 + };
118 + a_val
119 + .partial_cmp(&b_val)
120 + .unwrap_or(std::cmp::Ordering::Equal)
121 + });
122 + }
123 +}
124 +
125 +/// A dimension value ready for output.
126 +#[derive(Debug)]
127 +pub struct DimensionValue {
128 + pub name: String,
129 + pub value: Option<f64>,
130 +}
131 +
132 +/// Write a data slot (BEGIN + SET for each dimension + END).
133 +///
134 +/// `update_every` is the collection interval in seconds.
135 +/// `slot_timestamp` is the slot-start boundary (floored to `update_every`).
136 +///
137 +/// BEGIN receives the interval converted to microseconds.
138 +/// END receives `slot_timestamp + update_every` — the slot-end boundary —
139 +/// because Netdata interprets a data point at time T as covering
140 +/// `[T - update_every, T]`.
141 +pub fn write_data_slot(
142 + f: &mut impl fmt::Write,
143 + chart_name: &str,
144 + update_every: u64,
145 + slot_timestamp: u64,
146 + dimensions: &[DimensionValue],
147 +) -> fmt::Result {
148 + writeln!(f, "BEGIN {} {}", chart_name, update_every * 1_000_000)?;
149 +
150 + for dim in dimensions {
151 + match dim.value {
152 + Some(v) => {
153 + let scaled = (v * PRECISION_DIVISOR as f64) as i64;
154 + writeln!(f, "SET {} = {}", dim.name, scaled)?;
155 + }
156 + None => writeln!(f, "SET {} = U", dim.name)?,
157 + }
158 + }
159 +
160 + writeln!(f, "END {}", slot_timestamp + update_every)?;
161 + Ok(())
162 +}
src/crates/netdata-otel/otel-plugin/src/plugin_config.rs
+38 -40
@@ -38,35 +38,32 @@ impl Default for EndpointConfig {
38 }
39 }
40
41 -#[derive(Parser, Debug, Clone, Serialize, Deserialize)]
41 +#[derive(Parser, Debug, Clone, Default, Serialize, Deserialize)]
42 #[serde(deny_unknown_fields)]
43 pub struct MetricsConfig {
44 - /// Print flattened metrics to stdout for debugging
45 - #[arg(long = "otel-metrics-print-flattened")]
46 - pub print_flattened: bool,
47 -
48 - /// Number of samples to buffer for collection interval detection
49 - #[arg(long = "otel-metrics-buffer-samples", default_value = "10")]
50 - pub buffer_samples: usize,
51 -
52 - /// Maximum number of new charts to create per collection interval
53 - #[arg(long = "otel-metrics-throttle-charts", default_value = "100")]
54 - pub throttle_charts: usize,
55 -
44 /// Directory with configuration files for mapping OTEL metrics to Netdata charts
45 #[arg(long = "otel-metrics-charts-configs-dir")]
46 pub chart_configs_dir: Option<String>,
59 -}
47
61 -impl Default for MetricsConfig {
62 - fn default() -> Self {
63 - Self {
64 - print_flattened: false,
65 - buffer_samples: 10,
66 - throttle_charts: 100,
67 - chart_configs_dir: None,
68 - }
69 - }
48 + /// Collection interval in seconds (1–3600). Default: 10.
49 + #[arg(long = "otel-metrics-interval")]
50 + pub interval_secs: Option<u64>,
51 +
52 + /// Grace period in seconds before gap-filling begins. Default: 5 * interval.
53 + #[arg(long = "otel-metrics-grace-period")]
54 + pub grace_period_secs: Option<u64>,
55 +
56 + /// Expiry duration in seconds after which charts with no data are removed. Default: 900.
57 + #[arg(long = "otel-metrics-expiry")]
58 + pub expiry_duration_secs: Option<u64>,
59 +
60 + /// Maximum number of new charts that can be created per gRPC request. Default: 100.
61 + #[arg(
62 + long = "otel-metrics-max-new-charts-per-request",
63 + default_value = "100"
64 + )]
65 + #[serde(default = "default_max_new_charts_per_request")]
66 + pub max_new_charts_per_request: usize,
67 }
68
69 /// Parse a duration string for clap (e.g., "7 days", "1 week", "168h")
@@ -94,6 +91,10 @@ fn default_entries_of_journal_file() -> usize {
91 50000
92 }
93
94 +fn default_max_new_charts_per_request() -> usize {
95 + 100
96 +}
97 +
98 #[derive(Parser, Debug, Clone, Serialize, Deserialize)]
99 #[serde(deny_unknown_fields)]
100 pub struct LogsConfig {
@@ -217,10 +218,16 @@ impl PluginConfig {
218 .user_config_dir
219 .as_ref()
220 .map(|path| path.join("otel.yaml"))
220 - .and_then(|path| {
221 - Self::from_yaml_file(&path)
222 - .with_context(|| format!("Loading user config from {}", path.display()))
223 - .ok()
221 + .and_then(|path| match Self::from_yaml_file(&path) {
222 + Ok(config) => Some(config),
223 + Err(e) => {
224 + tracing::error!(
225 + "failed to load user config from {}: {:#}. Falling back to stock config.",
226 + path.display(),
227 + e
228 + );
229 + None
230 + }
231 });
232
233 if let Some(config) = user_config {
@@ -231,25 +238,16 @@ impl PluginConfig {
238 .map(|p| p.join("otel.yaml"))
239 {
240 Self::from_yaml_file(&stock_path).with_context(|| {
234 - format!("Loading stock config from {}", stock_path.display())
241 + format!("loading stock config from {}", stock_path.display())
242 })?
243 } else {
237 - anyhow::bail!("No configuration directories available");
244 + anyhow::bail!("no configuration directories available");
245 }
246 } else {
247 // load from CLI args
248 Self::parse()
249 };
250
244 - // Validate configuration
245 - if config.metrics.buffer_samples == 0 {
246 - anyhow::bail!("buffer_samples must be greater than 0");
247 - }
248 -
249 - if config.metrics.throttle_charts == 0 {
250 - anyhow::bail!("throttle_charts must be greater than 0");
251 - }
252 -
251 // Validate endpoint format (basic check)
252 if !config.endpoint.path.contains(':') {
253 anyhow::bail!(
@@ -292,9 +290,9 @@ impl PluginConfig {
290 pub fn from_yaml_file<P: AsRef<Path>>(path: P) -> Result<Self> {
291 let path = path.as_ref();
292 let contents = fs::read_to_string(path)
295 - .with_context(|| format!("Failed to read config file: {}", path.display()))?;
293 + .with_context(|| format!("failed to read config file: {}", path.display()))?;
294 let config: PluginConfig = serde_yaml::from_str(&contents)
297 - .with_context(|| format!("Failed to parse YAML config file: {}", path.display()))?;
295 + .with_context(|| format!("failed to parse YAML config file: {}", path.display()))?;
296 Ok(config)
297 }
298 }
src/crates/netdata-otel/otel-plugin/src/regex_cache.rs deleted
-22
@@ -1,22 +0,0 @@
1 -use regex::Regex;
2 -use std::collections::HashMap;
3 -use std::sync::{Arc, Mutex};
4 -
5 -#[derive(Default, Debug)]
6 -pub struct RegexCache {
7 - cache: Arc<Mutex<HashMap<String, Regex>>>,
8 -}
9 -
10 -impl RegexCache {
11 - pub fn get(&self, pattern: &str) -> Result<Regex, regex::Error> {
12 - let mut cache = self.cache.lock().unwrap();
13 -
14 - if let Some(regex) = cache.get(pattern) {
15 - return Ok(regex.clone());
16 - }
17 -
18 - let compiled_regex = Regex::new(pattern)?;
19 - cache.insert(pattern.to_string(), compiled_regex.clone());
20 - Ok(compiled_regex)
21 - }
22 -}
src/crates/netdata-otel/otel-plugin/src/samples_table.rs deleted
-216
@@ -1,216 +0,0 @@
1 -use std::collections::HashMap;
2 -use std::num::NonZeroU64;
3 -
4 -#[derive(Default, Clone, Copy, PartialEq, Debug)]
5 -pub struct SamplePoint {
6 - unix_time: u64,
7 - pub value: f64,
8 -}
9 -
10 -#[derive(Copy, Clone, Debug)]
11 -pub struct CollectionInterval {
12 - pub end_time: u64,
13 - pub update_every: NonZeroU64,
14 -}
15 -
16 -impl CollectionInterval {
17 - fn from_samples(sample_points: &[SamplePoint]) -> Option<Self> {
18 - if sample_points.len() < 2 {
19 - return None;
20 - }
21 -
22 - let collection_time = sample_points[0].unix_time;
23 - let mut update_every = u64::MAX;
24 -
25 - for w in sample_points.windows(2) {
26 - update_every = update_every.min(w[1].unix_time - w[0].unix_time);
27 - }
28 -
29 - NonZeroU64::new(update_every).map(|update_every| Self {
30 - end_time: collection_time,
31 - update_every,
32 - })
33 - }
34 -
35 - pub fn next_interval(&self) -> Self {
36 - Self {
37 - end_time: self.end_time + self.update_every.get(),
38 - update_every: self.update_every,
39 - }
40 - }
41 -
42 - pub fn collection_time(&self) -> u64 {
43 - self.end_time + self.update_every.get()
44 - }
45 -
46 - fn is_stale(&self, sp: &SamplePoint) -> bool {
47 - sp.unix_time < self.end_time
48 - }
49 -
50 - pub fn is_on_time(&self, sp: &SamplePoint) -> bool {
51 - let window = self.update_every.get() / 4;
52 - let window_start = self.end_time + self.update_every.get() - window;
53 - let window_end = self.end_time + self.update_every.get() + window;
54 -
55 - sp.unix_time >= window_start && sp.unix_time <= window_end
56 - }
57 -
58 - pub fn is_in_gap(&self, sp: &SamplePoint) -> bool {
59 - !self.is_stale(sp) && !self.is_on_time(sp)
60 - }
61 -
62 - pub fn aligned_interval(&self) -> Option<Self> {
63 - let dur = std::time::Duration::from_nanos(self.end_time);
64 - let end_time = dur.as_secs() + u64::from(dur.subsec_millis() >= 500);
65 -
66 - let dur = std::time::Duration::from_nanos(self.update_every.get());
67 - let update_every = dur.as_secs() + u64::from(dur.subsec_millis() >= 500);
68 -
69 - Self::from_secs(end_time, update_every)
70 - }
71 -
72 - fn from_secs(end_time: u64, update_every: u64) -> Option<Self> {
73 - let end_time = std::time::Duration::from_secs(end_time).as_nanos() as u64;
74 - let update_every = std::time::Duration::from_secs(update_every).as_nanos() as u64;
75 -
76 - NonZeroU64::new(update_every).map(|update_every| Self {
77 - end_time,
78 - update_every,
79 - })
80 - }
81 -}
82 -
83 -#[derive(Debug, Default, Clone)]
84 -pub struct SamplesBuffer(Vec<SamplePoint>);
85 -
86 -impl SamplesBuffer {
87 - pub fn push(&mut self, sp: SamplePoint) {
88 - match self.0.binary_search_by_key(&sp.unix_time, |p| p.unix_time) {
89 - Ok(idx) => self.0[idx] = sp,
90 - Err(idx) => self.0.insert(idx, sp),
91 - }
92 - }
93 -
94 - pub fn pop(&mut self) -> Option<SamplePoint> {
95 - if self.0.is_empty() {
96 - None
97 - } else {
98 - Some(self.0.remove(0))
99 - }
100 - }
101 -
102 - fn is_empty(&self) -> bool {
103 - self.0.is_empty()
104 - }
105 -
106 - pub fn first(&self) -> Option<&SamplePoint> {
107 - self.0.first()
108 - }
109 -
110 - pub fn len(&self) -> usize {
111 - self.0.len()
112 - }
113 -
114 - pub fn drop_stale_samples(&mut self, ci: &CollectionInterval) -> usize {
115 - let split_idx = self
116 - .0
117 - .iter()
118 - .position(|sp| !ci.is_stale(sp))
119 - .unwrap_or(self.0.len());
120 -
121 - self.0.drain(..split_idx);
122 -
123 - split_idx
124 - }
125 -
126 - pub fn collection_interval(&self) -> Option<CollectionInterval> {
127 - CollectionInterval::from_samples(&self.0)
128 - }
129 -}
130 -
131 -#[derive(Debug, Default)]
132 -pub struct SamplesTable {
133 - dimensions: HashMap<String, SamplesBuffer>,
134 -}
135 -
136 -impl SamplesTable {
137 - pub fn insert(&mut self, dimension: &str, unix_time: u64, value: f64) -> bool {
138 - let sp = SamplePoint { unix_time, value };
139 -
140 - // returns true if this we added a new dimension
141 - if let Some(sb) = self.dimensions.get_mut(dimension) {
142 - sb.push(sp);
143 - false
144 - } else {
145 - let mut sb = SamplesBuffer::default();
146 - sb.push(sp);
147 - self.dimensions.insert(dimension.to_string(), sb);
148 - true
149 - }
150 - }
151 -
152 - pub fn is_empty(&self) -> bool {
153 - self.dimensions.values().all(|sb| sb.is_empty())
154 - }
155 -
156 - pub fn total_samples(&self) -> usize {
157 - self.dimensions
158 - .values()
159 - .map(|sb| sb.len())
160 - .max()
161 - .unwrap_or(0)
162 - }
163 -
164 - pub fn drop_stale_samples(&mut self, ci: &CollectionInterval) -> usize {
165 - let mut dropped_samples = 0;
166 -
167 - for sb in self.dimensions.values_mut() {
168 - dropped_samples += sb.drop_stale_samples(ci);
169 - }
170 -
171 - dropped_samples
172 - }
173 -
174 - pub fn collection_interval(&self) -> Option<CollectionInterval> {
175 - self.dimensions
176 - .values()
177 - .filter_map(|sb| sb.collection_interval())
178 - .min_by_key(|ci| ci.collection_time())
179 - }
180 -
181 - pub fn iter_mut(&mut self) -> impl Iterator<Item = (&String, &mut SamplesBuffer)> {
182 - self.dimensions.iter_mut()
183 - }
184 -
185 - pub fn iter_dimensions(&self) -> impl Iterator<Item = &String> {
186 - self.dimensions.keys()
187 - }
188 -
189 - pub fn iter_samples_buffers(&self) -> impl Iterator<Item = &SamplesBuffer> {
190 - self.dimensions.values()
191 - }
192 -
193 - // returns multiplier/divisor
194 - pub fn scaling_factors(&self) -> (i32, i32) {
195 - let mut has_nonzero = false;
196 -
197 - for buffer in self.dimensions.values() {
198 - for sample in &buffer.0 {
199 - let value = sample.value;
200 -
201 - // Check if value is outside the -100 to 100 range
202 - if !(-100.0..=100.0).contains(&value) {
203 - return (1, 1);
204 - }
205 -
206 - // Check for non-zero values
207 - if value != 0.0 {
208 - has_nonzero = true;
209 - }
210 - }
211 - }
212 -
213 - // Return 1/1000 scaling if all values are in range and at least one is non-zero
214 - if has_nonzero { (1, 1000) } else { (1, 1) }
215 - }
216 -}
src/crates/netdata-plugin/rt/src/tracing_setup.rs
+2 -2
@@ -69,8 +69,8 @@ pub fn init_tracing() {
69 .unwrap_or("info");
70
71 // Create environment filter: configured level as default, but limit noisy
72 - // third-party crates to warn.
73 - let filter = format!("{filter_str},foyer=warn,notify=warn");
72 + // third-party crates to info.
73 + let filter = format!("{filter_str},foyer=info,notify=info,h2=info,tower=info,hyper=info");
74 let env_filter = EnvFilter::new(&filter);
75
76 // Build the registry with base layers