Skip to main content

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}