@@ -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,54 @@ 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 hash
74+ // of `metric_name` + sorted attribute key/value pairs. Lets the query
75+ // layer group samples into physical series via a single u64 column
76+ // read instead of decoding every label column and hashing per row.
77+ "_series_hash" ,
7178] ;
7279
80+ /// Compute a stable u64 identifier for the physical series a sample
81+ /// belongs to. Hashes `metric_name` plus every attribute key/value pair
82+ /// that survived OTel flattening — everything in the flattened data
83+ /// point that isn't a known sample-level field is treated as a label.
84+ ///
85+ /// Hash output must be stable across process restarts and matching at
86+ /// query time. Uses rustc-hash's FxHasher (fast, deterministic,
87+ /// non-cryptographic) and feeds keys in sorted order so the hash
88+ /// doesn't depend on JSON Map iteration order.
89+ fn compute_series_hash ( dp : & Map < String , Value > ) -> u64 {
90+ let known: std:: collections:: HashSet < & str > =
91+ OTEL_METRICS_KNOWN_FIELD_LIST . iter ( ) . copied ( ) . collect ( ) ;
92+ let mut label_pairs: Vec < ( & str , String ) > = dp
93+ . iter ( )
94+ . filter ( |( k, _) | !known. contains ( k. as_str ( ) ) )
95+ . map ( |( k, v) | {
96+ let v_str = match v {
97+ Value :: String ( s) => s. clone ( ) ,
98+ other => other. to_string ( ) ,
99+ } ;
100+ ( k. as_str ( ) , v_str)
101+ } )
102+ . collect ( ) ;
103+ label_pairs. sort_by ( |a, b| a. 0 . cmp ( b. 0 ) ) ;
104+
105+ let mut hasher = FxHasher :: default ( ) ;
106+ // Include metric_name in the identity. Without it, two different
107+ // metrics with identical label sets would collide into one series.
108+ if let Some ( Value :: String ( name) ) = dp. get ( "metric_name" ) {
109+ hasher. write ( name. as_bytes ( ) ) ;
110+ hasher. write ( b"\0 " ) ;
111+ }
112+ for ( k, v) in & label_pairs {
113+ hasher. write ( k. as_bytes ( ) ) ;
114+ hasher. write ( b"=" ) ;
115+ hasher. write ( v. as_bytes ( ) ) ;
116+ hasher. write ( b"\0 " ) ;
117+ }
118+ hasher. finish ( )
119+ }
120+
73121/// otel metrics event has json array for exemplar
74122/// this function flatten the exemplar json array
75123/// and returns a `Map` of the exemplar json
@@ -564,6 +612,16 @@ fn process_resource_metrics<T, S, M>(
564612 for ( k, v) in & envelope {
565613 dp. insert ( k. clone ( ) , v. clone ( ) ) ;
566614 }
615+ // Compute the physical-series hash AFTER envelope merge
616+ // so resource/scope attributes participate in series
617+ // identity (they're labels from the query layer's
618+ // perspective). Computed once per data point — O(label
619+ // count) per sample, ~200 ns at typical attribute counts.
620+ let series_hash = compute_series_hash ( & dp) ;
621+ dp. insert (
622+ "_series_hash" . to_string ( ) ,
623+ Value :: Number ( series_hash. into ( ) ) ,
624+ ) ;
567625 vec_otel_json. push ( Value :: Object ( dp) ) ;
568626 }
569627 }
@@ -655,3 +713,110 @@ fn flatten_data_point_flags(flags: u32) -> Map<String, Value> {
655713 ) ;
656714 data_point_flags_json
657715}
716+
717+ #[ cfg( test) ]
718+ mod tests {
719+ use super :: * ;
720+
721+ fn make_dp ( ) -> Map < String , Value > {
722+ let mut dp = Map :: new ( ) ;
723+ dp. insert (
724+ "metric_name" . to_string ( ) ,
725+ Value :: String ( "counter.app.metric_0006" . into ( ) ) ,
726+ ) ;
727+ dp. insert (
728+ "time_unix_nano" . to_string ( ) ,
729+ Value :: String ( "2026-05-19T09:00:00Z" . into ( ) ) ,
730+ ) ;
731+ dp. insert ( "data_point_value" . to_string ( ) , Value :: Number ( 1000 . into ( ) ) ) ;
732+ dp. insert ( "is_monotonic" . to_string ( ) , Value :: Bool ( true ) ) ;
733+ dp. insert ( "service.name" . to_string ( ) , Value :: String ( "api" . into ( ) ) ) ;
734+ dp. insert ( "http.method" . to_string ( ) , Value :: String ( "GET" . into ( ) ) ) ;
735+ dp. insert ( "request.id" . to_string ( ) , Value :: String ( "req-1" . into ( ) ) ) ;
736+ dp
737+ }
738+
739+ #[ test]
740+ fn series_hash_stable_across_runs ( ) {
741+ // Same input → same hash. Locks in the wire contract between
742+ // ingest and query layers; any algorithm change here breaks
743+ // grouping for already-ingested data.
744+ let dp = make_dp ( ) ;
745+ let h1 = compute_series_hash ( & dp) ;
746+ let h2 = compute_series_hash ( & dp) ;
747+ assert_eq ! ( h1, h2) ;
748+ }
749+
750+ #[ test]
751+ fn series_hash_independent_of_label_insertion_order ( ) {
752+ // serde_json::Map preserves insertion order; query side may see
753+ // labels in different order. Hash must be insertion-order-agnostic.
754+ let mut a = Map :: new ( ) ;
755+ a. insert ( "metric_name" . to_string ( ) , Value :: String ( "m" . into ( ) ) ) ;
756+ a. insert ( "service.name" . to_string ( ) , Value :: String ( "api" . into ( ) ) ) ;
757+ a. insert ( "http.method" . to_string ( ) , Value :: String ( "GET" . into ( ) ) ) ;
758+
759+ let mut b = Map :: new ( ) ;
760+ b. insert ( "http.method" . to_string ( ) , Value :: String ( "GET" . into ( ) ) ) ;
761+ b. insert ( "metric_name" . to_string ( ) , Value :: String ( "m" . into ( ) ) ) ;
762+ b. insert ( "service.name" . to_string ( ) , Value :: String ( "api" . into ( ) ) ) ;
763+
764+ assert_eq ! ( compute_series_hash( & a) , compute_series_hash( & b) ) ;
765+ }
766+
767+ #[ test]
768+ fn series_hash_changes_with_label_value ( ) {
769+ let dp = make_dp ( ) ;
770+ let mut dp2 = dp. clone ( ) ;
771+ dp2. insert ( "service.name" . to_string ( ) , Value :: String ( "billing" . into ( ) ) ) ;
772+ assert_ne ! ( compute_series_hash( & dp) , compute_series_hash( & dp2) ) ;
773+ }
774+
775+ #[ test]
776+ fn series_hash_changes_with_metric_name ( ) {
777+ // Two different metrics with identical labels must hash to
778+ // different values, otherwise samples for `requests_total` and
779+ // `latency_seconds` would collide into one logical series.
780+ let dp = make_dp ( ) ;
781+ let mut dp2 = dp. clone ( ) ;
782+ dp2. insert (
783+ "metric_name" . to_string ( ) ,
784+ Value :: String ( "other.metric" . into ( ) ) ,
785+ ) ;
786+ assert_ne ! ( compute_series_hash( & dp) , compute_series_hash( & dp2) ) ;
787+ }
788+
789+ #[ test]
790+ fn series_hash_ignores_sample_level_fields ( ) {
791+ // time_unix_nano and data_point_value belong to the SAMPLE, not
792+ // the series. Hash must be identical across samples of the same
793+ // physical series taken at different times with different values.
794+ let dp = make_dp ( ) ;
795+ let mut dp_later = dp. clone ( ) ;
796+ dp_later. insert (
797+ "time_unix_nano" . to_string ( ) ,
798+ Value :: String ( "2026-05-19T10:00:00Z" . into ( ) ) ,
799+ ) ;
800+ dp_later. insert ( "data_point_value" . to_string ( ) , Value :: Number ( 2000 . into ( ) ) ) ;
801+ assert_eq ! ( compute_series_hash( & dp) , compute_series_hash( & dp_later) ) ;
802+ }
803+
804+ #[ test]
805+ fn series_hash_distinguishes_label_kv_swap ( ) {
806+ // Pathological pair: {a=bc, d=e} vs {a=b, cd=e}. A naive
807+ // concatenation hash would emit identical bytes. Delimiters in
808+ // the hasher input prevent this — verify here so a future
809+ // optimisation can't silently regress collision resistance.
810+ let mut a = Map :: new ( ) ;
811+ a. insert ( "metric_name" . to_string ( ) , Value :: String ( "m" . into ( ) ) ) ;
812+ a. insert ( "a" . to_string ( ) , Value :: String ( "bc" . into ( ) ) ) ;
813+ a. insert ( "d" . to_string ( ) , Value :: String ( "e" . into ( ) ) ) ;
814+
815+ let mut b = Map :: new ( ) ;
816+ b. insert ( "metric_name" . to_string ( ) , Value :: String ( "m" . into ( ) ) ) ;
817+ b. insert ( "a" . to_string ( ) , Value :: String ( "b" . into ( ) ) ) ;
818+ b. insert ( "cd" . to_string ( ) , Value :: String ( "e" . into ( ) ) ) ;
819+
820+ assert_ne ! ( compute_series_hash( & a) , compute_series_hash( & b) ) ;
821+ }
822+ }
0 commit comments