Skip to content

Commit ac33079

Browse files
authored
feat(otlp): duplicate resource and instrumentation attrs into spans (#4533)
OTLP trace payloads contain a nested structure wherein resources contain instrumentation scopes, and instrumentation scopes contain spans. The attributes set on the resource or instrumentation scope apply to all contained spans, and are useful to query and aggregate on at the span level. (For example, resource attributes conventionally contain the service name and version of the service emitting spans). When converting incoming OTLP traces into individual `OtelSpan` items, denormalize these incoming extra attributes onto each span. This will make the data available in Sentry for viewing, searching, and aggregation. Prefix each attribute name with a prefix indicating its source so that attributes with the same name from different sources don't clobber each other. (We might change our mind on whether these attributes should be prefixed or not - that's fine as no one is using the OTLP endpoint or `OtlpSpan` envelope item type yet). In addition to an `attributes` dictionary, instrumentation scopes also have `name` and `version` string properties. Copy those to spans too.
1 parent 1a54071 commit ac33079

File tree

4 files changed

+179
-4
lines changed

4 files changed

+179
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- Track an utilization metric for internal services. ([#4501](https://github.com/getsentry/relay/pull/4501))
1616
- Add new `relay-threading` crate with asynchronous thread pool. ([#4500](https://github.com/getsentry/relay/pull/4500))
1717
- Expose additional metrics through the internal relay metric endpoint. ([#4511](https://github.com/getsentry/relay/pull/4511))
18+
- Write resource and instrumentation scope attributes as span attributes during OTLP ingestion. ([#4533](https://github.com/getsentry/relay/pull/4533))
1819

1920
## 25.2.0
2021

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

relay-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ mime = { workspace = true }
5656
minidump = { workspace = true, optional = true }
5757
multer = { workspace = true }
5858
once_cell = { workspace = true }
59+
opentelemetry-proto = { workspace = true }
5960
papaya = { workspace = true }
6061
pin-project-lite = { workspace = true }
6162
priority-queue = { workspace = true }

relay-server/src/services/processor/span.rs

Lines changed: 176 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
33
use std::sync::Arc;
44

5+
use opentelemetry_proto::tonic::common::v1::any_value::Value;
6+
use opentelemetry_proto::tonic::common::v1::{AnyValue, KeyValue};
57
use prost::Message;
68
use relay_dynamic_config::Feature;
79
use relay_event_normalization::span::tag_extraction;
@@ -62,10 +64,43 @@ fn convert_traces_data(item: Item, managed_envelope: &mut TypedEnvelope<SpanGrou
6264
return;
6365
}
6466
};
65-
for resource in traces_data.resource_spans {
66-
for scope in resource.scope_spans {
67-
for span in scope.spans {
68-
// TODO: resources and scopes contain attributes, should denormalize into spans?
67+
for resource_spans in traces_data.resource_spans {
68+
for scope_spans in resource_spans.scope_spans {
69+
for mut span in scope_spans.spans {
70+
// Denormalize instrumentation scope and resource attributes into every span.
71+
if let Some(ref scope) = scope_spans.scope {
72+
if !scope.name.is_empty() {
73+
span.attributes.push(KeyValue {
74+
key: "instrumentation.name".to_owned(),
75+
value: Some(AnyValue {
76+
value: Some(Value::StringValue(scope.name.clone())),
77+
}),
78+
})
79+
}
80+
if !scope.version.is_empty() {
81+
span.attributes.push(KeyValue {
82+
key: "instrumentation.version".to_owned(),
83+
value: Some(AnyValue {
84+
value: Some(Value::StringValue(scope.version.clone())),
85+
}),
86+
})
87+
}
88+
scope.attributes.iter().for_each(|a| {
89+
span.attributes.push(KeyValue {
90+
key: format!("instrumentation.{}", a.key),
91+
value: a.value.clone(),
92+
});
93+
});
94+
}
95+
if let Some(ref resource) = resource_spans.resource {
96+
resource.attributes.iter().for_each(|a| {
97+
span.attributes.push(KeyValue {
98+
key: format!("resource.{}", a.key),
99+
value: a.value.clone(),
100+
});
101+
});
102+
}
103+
69104
let Ok(payload) = serde_json::to_vec(&span) else {
70105
track_invalid(managed_envelope, DiscardReason::Internal);
71106
continue;
@@ -119,3 +154,140 @@ pub fn extract_transaction_span(
119154

120155
spans.into_iter().next().and_then(Annotated::into_value)
121156
}
157+
158+
#[cfg(test)]
159+
mod tests {
160+
use std::collections::BTreeMap;
161+
162+
use super::*;
163+
use crate::services::processor::ProcessingGroup;
164+
use crate::utils::{ManagedEnvelope, TypedEnvelope};
165+
use crate::Envelope;
166+
use bytes::Bytes;
167+
use relay_spans::otel_trace::Span as OtelSpan;
168+
use relay_system::Addr;
169+
170+
#[test]
171+
fn attribute_denormalization() {
172+
// Construct an OTLP trace payload with:
173+
// - a resource with one attribute, containing:
174+
// - an instrumentation scope with one attribute, containing:
175+
// - a span with one attribute
176+
let traces_data = r#"
177+
{
178+
"resourceSpans": [
179+
{
180+
"resource": {
181+
"attributes": [
182+
{
183+
"key": "resource_key",
184+
"value": {
185+
"stringValue": "resource_value"
186+
}
187+
}
188+
]
189+
},
190+
"scopeSpans": [
191+
{
192+
"scope": {
193+
"name": "test_instrumentation",
194+
"version": "0.0.1",
195+
"attributes": [
196+
{
197+
"key": "scope_key",
198+
"value": {
199+
"stringValue": "scope_value"
200+
}
201+
}
202+
]
203+
},
204+
"spans": [
205+
{
206+
"attributes": [
207+
{
208+
"key": "span_key",
209+
"value": {
210+
"stringValue": "span_value"
211+
}
212+
}
213+
]
214+
}
215+
]
216+
}
217+
]
218+
}
219+
]
220+
}
221+
"#;
222+
223+
// Build an envelope containing the OTLP trace data.
224+
let bytes =
225+
Bytes::from(r#"{"dsn":"https://e12d836b15bb49d7bbf99e64295d995b:@sentry.io/42"}"#);
226+
let envelope = Envelope::parse_bytes(bytes).unwrap();
227+
let (test_store, _) = Addr::custom();
228+
let (outcome_aggregator, _) = Addr::custom();
229+
let managed_envelope = ManagedEnvelope::new(
230+
envelope,
231+
outcome_aggregator,
232+
test_store,
233+
ProcessingGroup::Span,
234+
);
235+
let mut typed_envelope: TypedEnvelope<_> = managed_envelope.try_into().unwrap();
236+
let mut item = Item::new(ItemType::OtelTracesData);
237+
item.set_payload(ContentType::Json, traces_data);
238+
typed_envelope.envelope_mut().add_item(item.clone());
239+
240+
// Convert the OTLP trace data into `OtelSpan` item(s).
241+
convert_traces_data(item, &mut typed_envelope);
242+
243+
// Assert that the attributes from the resource and instrumentation
244+
// scope were copied.
245+
let item = typed_envelope
246+
.envelope()
247+
.items()
248+
.find(|i| *i.ty() == ItemType::OtelSpan)
249+
.expect("converted span missing from envelope");
250+
let attributes = serde_json::from_slice::<OtelSpan>(&item.payload())
251+
.expect("unable to deserialize otel span")
252+
.attributes
253+
.into_iter()
254+
.map(|kv| (kv.key, kv.value.unwrap()))
255+
.collect::<BTreeMap<_, _>>();
256+
let attribute_value = |key: &str| -> String {
257+
match attributes
258+
.get(key)
259+
.unwrap_or_else(|| panic!("attribute {} missing", key))
260+
.to_owned()
261+
.value
262+
{
263+
Some(Value::StringValue(str)) => str,
264+
_ => panic!("attribute {} not a string", key),
265+
}
266+
};
267+
assert_eq!(
268+
attribute_value("span_key"),
269+
"span_value".to_owned(),
270+
"original span attribute should be present"
271+
);
272+
assert_eq!(
273+
attribute_value("instrumentation.name"),
274+
"test_instrumentation".to_owned(),
275+
"instrumentation name should be in attributes"
276+
);
277+
assert_eq!(
278+
attribute_value("instrumentation.version"),
279+
"0.0.1".to_owned(),
280+
"instrumentation version should be in attributes"
281+
);
282+
assert_eq!(
283+
attribute_value("resource.resource_key"),
284+
"resource_value".to_owned(),
285+
"resource attribute should be copied with prefix"
286+
);
287+
assert_eq!(
288+
attribute_value("instrumentation.scope_key"),
289+
"scope_value".to_owned(),
290+
"instruementation scope attribute should be copied with prefix"
291+
);
292+
}
293+
}

0 commit comments

Comments
 (0)