@@ -23,6 +23,8 @@ use opentelemetry_proto::tonic::metrics::v1::{
2323} ;
2424use serde_json:: { Map , Value } ;
2525
26+ use rustc_hash:: FxHasher ;
27+ use std:: hash:: Hasher ;
2628use tracing:: info_span;
2729
2830use crate :: metrics:: increment_metrics_collected_by_date;
@@ -31,7 +33,7 @@ use super::otel_utils::{
3133 convert_epoch_nano_to_timestamp, insert_attributes, insert_number_if_some,
3234} ;
3335
34- pub const OTEL_METRICS_KNOWN_FIELD_LIST : [ & str ; 36 ] = [
36+ pub const OTEL_METRICS_KNOWN_FIELD_LIST : [ & str ; 37 ] = [
3537 "metric_name" ,
3638 "metric_description" ,
3739 "metric_unit" ,
@@ -68,8 +70,58 @@ pub const OTEL_METRICS_KNOWN_FIELD_LIST: [&str; 36] = [
6870 "scope_dropped_attributes_count" ,
6971 "resource_dropped_attributes_count" ,
7072 "resource_schema_url" ,
73+ // Precomputed per-sample identity of the physical series. Stable
74+ // u64 hash of `metric_name` + sorted attribute key/value pairs,
75+ // stored as a decimal-encoded string so arrow-json infers Utf8 and
76+ // we get byte-exact roundtrip. Int64/Float64 inference dropped bits
77+ // for hashes near the high range; string sidesteps that entirely.
78+ // Lets the query layer group samples into physical series via a
79+ // single column read instead of decoding every label column and
80+ // hashing per row.
81+ "__series_hash" ,
7182] ;
7283
84+ /// Compute a stable u64 identifier for the physical series a sample
85+ /// belongs to. Hashes `metric_name` plus every attribute key/value pair
86+ /// that survived OTel flattening — everything in the flattened data
87+ /// point that isn't a known sample-level field is treated as a label.
88+ ///
89+ /// Hash output must be stable across process restarts and matching at
90+ /// query time. Uses rustc-hash's FxHasher (fast, deterministic,
91+ /// non-cryptographic) and feeds keys in sorted order so the hash
92+ /// doesn't depend on JSON Map iteration order.
93+ fn compute_series_hash ( dp : & Map < String , Value > ) -> u64 {
94+ let known: std:: collections:: HashSet < & str > =
95+ OTEL_METRICS_KNOWN_FIELD_LIST . iter ( ) . copied ( ) . collect ( ) ;
96+ let mut label_pairs: Vec < ( & str , String ) > = dp
97+ . iter ( )
98+ . filter ( |( k, _) | !known. contains ( k. as_str ( ) ) )
99+ . map ( |( k, v) | {
100+ let v_str = match v {
101+ Value :: String ( s) => s. clone ( ) ,
102+ other => other. to_string ( ) ,
103+ } ;
104+ ( k. as_str ( ) , v_str)
105+ } )
106+ . collect ( ) ;
107+ label_pairs. sort_by ( |a, b| a. 0 . cmp ( b. 0 ) ) ;
108+
109+ let mut hasher = FxHasher :: default ( ) ;
110+ // Include metric_name in the identity. Without it, two different
111+ // metrics with identical label sets would collide into one series.
112+ if let Some ( Value :: String ( name) ) = dp. get ( "metric_name" ) {
113+ hasher. write ( name. as_bytes ( ) ) ;
114+ hasher. write ( b"\0 " ) ;
115+ }
116+ for ( k, v) in & label_pairs {
117+ hasher. write ( k. as_bytes ( ) ) ;
118+ hasher. write ( b"=" ) ;
119+ hasher. write ( v. as_bytes ( ) ) ;
120+ hasher. write ( b"\0 " ) ;
121+ }
122+ hasher. finish ( )
123+ }
124+
73125/// otel metrics event has json array for exemplar
74126/// this function flatten the exemplar json array
75127/// and returns a `Map` of the exemplar json
@@ -564,6 +616,19 @@ fn process_resource_metrics<T, S, M>(
564616 for ( k, v) in & envelope {
565617 dp. insert ( k. clone ( ) , v. clone ( ) ) ;
566618 }
619+ // Compute the physical-series hash AFTER envelope merge
620+ // so resource/scope attributes participate in series
621+ // identity (they're labels from the query layer's
622+ // perspective). Computed once per data point — O(label
623+ // count) per sample, ~200 ns at typical attribute counts.
624+ let series_hash = compute_series_hash ( & dp) ;
625+ // Stored as decimal-encoded string. Arrow-json
626+ // infers Utf8, preserving all 64 bits — Int64/Float64
627+ // inference truncated values near the high range.
628+ dp. insert (
629+ "__series_hash" . to_string ( ) ,
630+ Value :: String ( series_hash. to_string ( ) ) ,
631+ ) ;
567632 vec_otel_json. push ( Value :: Object ( dp) ) ;
568633 }
569634 }
@@ -655,3 +720,110 @@ fn flatten_data_point_flags(flags: u32) -> Map<String, Value> {
655720 ) ;
656721 data_point_flags_json
657722}
723+
724+ #[ cfg( test) ]
725+ mod tests {
726+ use super :: * ;
727+
728+ fn make_dp ( ) -> Map < String , Value > {
729+ let mut dp = Map :: new ( ) ;
730+ dp. insert (
731+ "metric_name" . to_string ( ) ,
732+ Value :: String ( "counter.app.metric_0006" . into ( ) ) ,
733+ ) ;
734+ dp. insert (
735+ "time_unix_nano" . to_string ( ) ,
736+ Value :: String ( "2026-05-19T09:00:00Z" . into ( ) ) ,
737+ ) ;
738+ dp. insert ( "data_point_value" . to_string ( ) , Value :: Number ( 1000 . into ( ) ) ) ;
739+ dp. insert ( "is_monotonic" . to_string ( ) , Value :: Bool ( true ) ) ;
740+ dp. insert ( "service.name" . to_string ( ) , Value :: String ( "api" . into ( ) ) ) ;
741+ dp. insert ( "http.method" . to_string ( ) , Value :: String ( "GET" . into ( ) ) ) ;
742+ dp. insert ( "request.id" . to_string ( ) , Value :: String ( "req-1" . into ( ) ) ) ;
743+ dp
744+ }
745+
746+ #[ test]
747+ fn series_hash_stable_across_runs ( ) {
748+ // Same input → same hash. Locks in the wire contract between
749+ // ingest and query layers; any algorithm change here breaks
750+ // grouping for already-ingested data.
751+ let dp = make_dp ( ) ;
752+ let h1 = compute_series_hash ( & dp) ;
753+ let h2 = compute_series_hash ( & dp) ;
754+ assert_eq ! ( h1, h2) ;
755+ }
756+
757+ #[ test]
758+ fn series_hash_independent_of_label_insertion_order ( ) {
759+ // serde_json::Map preserves insertion order; query side may see
760+ // labels in different order. Hash must be insertion-order-agnostic.
761+ let mut a = Map :: new ( ) ;
762+ a. insert ( "metric_name" . to_string ( ) , Value :: String ( "m" . into ( ) ) ) ;
763+ a. insert ( "service.name" . to_string ( ) , Value :: String ( "api" . into ( ) ) ) ;
764+ a. insert ( "http.method" . to_string ( ) , Value :: String ( "GET" . into ( ) ) ) ;
765+
766+ let mut b = Map :: new ( ) ;
767+ b. insert ( "http.method" . to_string ( ) , Value :: String ( "GET" . into ( ) ) ) ;
768+ b. insert ( "metric_name" . to_string ( ) , Value :: String ( "m" . into ( ) ) ) ;
769+ b. insert ( "service.name" . to_string ( ) , Value :: String ( "api" . into ( ) ) ) ;
770+
771+ assert_eq ! ( compute_series_hash( & a) , compute_series_hash( & b) ) ;
772+ }
773+
774+ #[ test]
775+ fn series_hash_changes_with_label_value ( ) {
776+ let dp = make_dp ( ) ;
777+ let mut dp2 = dp. clone ( ) ;
778+ dp2. insert ( "service.name" . to_string ( ) , Value :: String ( "billing" . into ( ) ) ) ;
779+ assert_ne ! ( compute_series_hash( & dp) , compute_series_hash( & dp2) ) ;
780+ }
781+
782+ #[ test]
783+ fn series_hash_changes_with_metric_name ( ) {
784+ // Two different metrics with identical labels must hash to
785+ // different values, otherwise samples for `requests_total` and
786+ // `latency_seconds` would collide into one logical series.
787+ let dp = make_dp ( ) ;
788+ let mut dp2 = dp. clone ( ) ;
789+ dp2. insert (
790+ "metric_name" . to_string ( ) ,
791+ Value :: String ( "other.metric" . into ( ) ) ,
792+ ) ;
793+ assert_ne ! ( compute_series_hash( & dp) , compute_series_hash( & dp2) ) ;
794+ }
795+
796+ #[ test]
797+ fn series_hash_ignores_sample_level_fields ( ) {
798+ // time_unix_nano and data_point_value belong to the SAMPLE, not
799+ // the series. Hash must be identical across samples of the same
800+ // physical series taken at different times with different values.
801+ let dp = make_dp ( ) ;
802+ let mut dp_later = dp. clone ( ) ;
803+ dp_later. insert (
804+ "time_unix_nano" . to_string ( ) ,
805+ Value :: String ( "2026-05-19T10:00:00Z" . into ( ) ) ,
806+ ) ;
807+ dp_later. insert ( "data_point_value" . to_string ( ) , Value :: Number ( 2000 . into ( ) ) ) ;
808+ assert_eq ! ( compute_series_hash( & dp) , compute_series_hash( & dp_later) ) ;
809+ }
810+
811+ #[ test]
812+ fn series_hash_distinguishes_label_kv_swap ( ) {
813+ // Pathological pair: {a=bc, d=e} vs {a=b, cd=e}. A naive
814+ // concatenation hash would emit identical bytes. Delimiters in
815+ // the hasher input prevent this — verify here so a future
816+ // optimisation can't silently regress collision resistance.
817+ let mut a = Map :: new ( ) ;
818+ a. insert ( "metric_name" . to_string ( ) , Value :: String ( "m" . into ( ) ) ) ;
819+ a. insert ( "a" . to_string ( ) , Value :: String ( "bc" . into ( ) ) ) ;
820+ a. insert ( "d" . to_string ( ) , Value :: String ( "e" . into ( ) ) ) ;
821+
822+ let mut b = Map :: new ( ) ;
823+ b. insert ( "metric_name" . to_string ( ) , Value :: String ( "m" . into ( ) ) ) ;
824+ b. insert ( "a" . to_string ( ) , Value :: String ( "b" . into ( ) ) ) ;
825+ b. insert ( "cd" . to_string ( ) , Value :: String ( "e" . into ( ) ) ) ;
826+
827+ assert_ne ! ( compute_series_hash( & a) , compute_series_hash( & b) ) ;
828+ }
829+ }
0 commit comments