mira_core/identity.rs
1//! Stable entity identity — the join key that makes correlation work across
2//! blocks.
3//!
4//! Interning gives a resource a `resource_id` that is *block-local* by design
5//! (section 0). Correlation needs the opposite: a value that is identical whenever two
6//! resources denote the same running thing, and different otherwise, in any
7//! block, forever.
8//!
9//! Attribute-set equality is not that value. A pod that starts reporting one
10//! extra attribute halfway through an hour would become two entities, and every
11//! "show me everything from this pod" answer would silently lose half its rows.
12//! That failure is invisible — the query succeeds and returns a plausible
13//! subset — which makes it the worst kind of bug to ship into a correlation
14//! feature.
15//!
16//! So identity is a hash of only the attributes OpenTelemetry semantic
17//! conventions define as *identifying*, taken at the most specific level that is
18//! actually present. `service.instance.id` is specified as globally unique when
19//! combined with `service.name` and `service.namespace`, which makes that triple
20//! the primary candidate; the rest of the ladder covers the very common case of
21//! telemetry that predates it.
22
23use mira_proto::common::v1::{AnyValue, KeyValue, any_value::Value};
24use prost::Message;
25
26/// Identity candidates, most specific first. A candidate matches when every one
27/// of its required keys is present; its optional keys join the hash when they
28/// are. First match wins.
29///
30/// This list is deliberately fixed rather than configurable: an identity rule
31/// that two operators set differently is an identity rule that does not identify
32/// anything. See principle 3 in docs/architecture.md section 1.
33const IDENTITY: &[(&[&str], &[&str])] = &[
34 (
35 &["service.name", "service.instance.id"],
36 &["service.namespace"],
37 ),
38 (&["k8s.pod.uid"], &["k8s.container.name"]),
39 (&["container.id"], &[]),
40 (&["host.id"], &["process.pid"]),
41 (&["host.name"], &["process.pid"]),
42 (&["service.name"], &["service.namespace"]),
43];
44
45/// FNV-1a 64 with a splitmix64 finalizer over raw bytes.
46///
47/// Shared with [`crate::block::node_id`], which needs the same property this
48/// module was written for: the same input must hash the same way in every build,
49/// forever.
50pub fn hash64(bytes: &[u8]) -> u64 {
51 let mut h = Fnv::new();
52 h.write(bytes);
53 h.finish()
54}
55
56/// No candidate matched, so this resource has no stable identity and correlation
57/// by entity is not answerable for it.
58///
59/// The tempting fallback is to hash the whole attribute set. It is wrong for
60/// exactly the reason stated at the top of this module: that hash changes when an
61/// attribute is added or removed, so entity drift forks the entity anyway and
62/// "everything this pod emitted" silently returns a plausible subset. A sentinel
63/// makes the query layer refuse instead of guessing, and a refusal naming the
64/// missing attribute is an answer the user can act on.
65pub const NO_IDENTITY: u64 = 0;
66
67/// The stable 64-bit identity of the entity described by `attrs`, or
68/// [`NO_IDENTITY`] if nothing identifying is present.
69pub fn resource_key(attrs: &[KeyValue]) -> u64 {
70 for (n, (required, optional)) in IDENTITY.iter().enumerate() {
71 if !required.iter().all(|k| find(attrs, k).is_some()) {
72 continue;
73 }
74 // The candidate index is folded in so that `host.id = "abc"` and
75 // `container.id = "abc"` cannot collide.
76 let mut h = Fnv::new();
77 h.write(&[n as u8]);
78 for k in required.iter().chain(optional.iter()) {
79 if let Some(v) = find(attrs, k) {
80 h.write(k.as_bytes());
81 h.write(&[0]);
82 h.value(v);
83 h.write(&[0]);
84 }
85 }
86 // The sentinel must never be reachable from the hash, or an unidentified
87 // resource would silently join with an identified one.
88 let key = h.finish();
89 return if key == NO_IDENTITY { 1 } else { key };
90 }
91
92 NO_IDENTITY
93}
94
95fn find<'a>(attrs: &'a [KeyValue], key: &str) -> Option<Option<&'a AnyValue>> {
96 attrs
97 .iter()
98 .find(|kv| kv.key == key)
99 .map(|kv| kv.value.as_ref())
100}
101
102/// FNV-1a 64 with a splitmix64 finalizer. Hand-rolled because the hash has to be
103/// byte-stable across builds and across releases — `DefaultHasher` explicitly is
104/// not, and a key that changes when the binary is upgraded is not an identity.
105struct Fnv(u64);
106
107impl Fnv {
108 fn new() -> Self {
109 Self(0xcbf2_9ce4_8422_2325)
110 }
111
112 fn write(&mut self, bytes: &[u8]) {
113 for &b in bytes {
114 self.0 ^= b as u64;
115 self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
116 }
117 }
118
119 fn value(&mut self, v: Option<&AnyValue>) {
120 match v.and_then(|v| v.value.as_ref()) {
121 // Strings are the whole identity set in practice; hash them without
122 // the protobuf round trip.
123 Some(Value::StringValue(s)) => self.write(s.as_bytes()),
124 Some(other) => {
125 let owned = AnyValue {
126 value: Some(other.clone()),
127 };
128 self.write(&owned.encode_to_vec());
129 }
130 None => {}
131 }
132 }
133
134 fn finish(self) -> u64 {
135 mix(self.0)
136 }
137}
138
139/// FNV-1a alone avalanches poorly in the high bits; this is the splitmix64
140/// finalizer, which is what makes the 64 bits worth 64 bits.
141fn mix(mut z: u64) -> u64 {
142 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
143 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
144 z ^ (z >> 31)
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn kv(k: &str, v: &str) -> KeyValue {
152 KeyValue {
153 key: k.into(),
154 value: Some(AnyValue {
155 value: Some(Value::StringValue(v.into())),
156 }),
157 }
158 }
159
160 #[test]
161 fn identity_survives_attribute_drift_and_reordering() {
162 let base = vec![
163 kv("service.name", "checkout"),
164 kv("service.instance.id", "7f3a"),
165 ];
166 // The same instance, later, with an extra non-identifying attribute and
167 // the identifying ones in the other order. This is the case that splits
168 // an entity in two if identity is attribute-set equality.
169 let drifted = vec![
170 kv("service.instance.id", "7f3a"),
171 kv("k8s.node.name", "node-4"),
172 kv("service.name", "checkout"),
173 ];
174 assert_eq!(resource_key(&base), resource_key(&drifted));
175
176 // Different instance of the same service is a different entity.
177 let other = vec![
178 kv("service.name", "checkout"),
179 kv("service.instance.id", "91bd"),
180 ];
181 assert_ne!(resource_key(&base), resource_key(&other));
182
183 // Same value under a different identifying key is a different entity.
184 assert_ne!(
185 resource_key(&[kv("host.id", "abc")]),
186 resource_key(&[kv("container.id", "abc")])
187 );
188
189 // No identifying attribute at all: the sentinel, not a hash of whatever
190 // happened to be there. Any hash of the full set would make these three
191 // three different entities, which is the drift bug the first assertion
192 // in this test exists to forbid.
193 assert_eq!(resource_key(&[kv("a", "1"), kv("b", "2")]), NO_IDENTITY);
194 assert_eq!(resource_key(&[kv("a", "1")]), NO_IDENTITY);
195 assert_eq!(resource_key(&[]), NO_IDENTITY);
196 }
197
198 /// Nothing in OTLP says an identifying attribute has to be a string. An SDK
199 /// that reports `service.instance.id` as an integer, or a proxy that
200 /// forwards a `KeyValue` with the value stripped, still describes an
201 /// entity, and both have to come out of here as a stable key rather than as
202 /// the sentinel or a panic.
203 ///
204 /// The two properties worth pinning are the ones the protobuf round trip
205 /// buys: an integer `7` is not the string `"7"` — they are different
206 /// entities, so a join must not merge them — and a valueless attribute is
207 /// still *present*, so it selects the candidate its key belongs to rather
208 /// than falling through to a less specific one.
209 #[test]
210 fn a_non_string_identifying_value_is_still_an_identity_and_is_not_its_own_text() {
211 let int = |k: &str, v: i64| KeyValue {
212 key: k.into(),
213 value: Some(AnyValue {
214 value: Some(Value::IntValue(v)),
215 }),
216 };
217 let numeric = vec![
218 kv("service.name", "checkout"),
219 int("service.instance.id", 7),
220 ];
221
222 assert_ne!(resource_key(&numeric), NO_IDENTITY);
223 // Byte-stable: the same attributes hash the same way every time, which
224 // is the entire contract of this module.
225 assert_eq!(
226 resource_key(&numeric),
227 resource_key(&[
228 kv("service.name", "checkout"),
229 int("service.instance.id", 7)
230 ])
231 );
232 assert_ne!(
233 resource_key(&numeric),
234 resource_key(&[
235 kv("service.name", "checkout"),
236 int("service.instance.id", 8)
237 ])
238 );
239 assert_ne!(
240 resource_key(&numeric),
241 resource_key(&[
242 kv("service.name", "checkout"),
243 kv("service.instance.id", "7")
244 ])
245 );
246
247 // Present with no value at all. `find` reports the key, so the
248 // `service.name`+`service.instance.id` candidate matches and the
249 // candidate index goes into the hash — which is what keeps this
250 // distinct from the same resource with no instance id at all, where the
251 // last candidate would have matched instead.
252 let valueless = vec![
253 kv("service.name", "checkout"),
254 KeyValue {
255 key: "service.instance.id".into(),
256 value: None,
257 },
258 ];
259 assert_ne!(resource_key(&valueless), NO_IDENTITY);
260 assert_ne!(
261 resource_key(&valueless),
262 resource_key(&[kv("service.name", "checkout")])
263 );
264 }
265}