Skip to content

Commit da514c4

Browse files
feat(tracing): Add span links (#8068)
## Description Adds span links as is specified in the [specs](https://develop.sentry.dev/sdk/telemetry/spans/#span-links): a span can reference other spans, e.g. in other traces, without making them its parent. - `sentry_sdk.start_span(..., links=[...])`, `span.add_link(link)` and `span.add_links(links)`; with links being of the following format: `{"trace_id", "span_id", "sampled"?, "attributes"?}` - span without links omits `links` - `NoOpSpan` ignores links - added to the migration guide as well - [example](https://sentry-sdks.sentry.io/explore/traces/trace/8194b70609574447aa8732f24e1350a0/?mode=samples&node=span-bad54d1bc8cb9604&project=4511733153660928&source=traces&statsPeriod=24h&table=trace&timestamp=1792484175.113) in sentry-test Related docs update can be found [here](getsentry/sentry-docs#20103). ## Why An SQS/SNS consumer can receive a batch of records sent by several producers, each in its own trace. A span can only have one parent, so without links there's no way to connect the consumer each producer. With links the consumer span can point to each producer's trace.
1 parent 495ff6b commit da514c4

6 files changed

Lines changed: 155 additions & 5 deletions

File tree

‎MIGRATION_GUIDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Looking to upgrade from Sentry SDK 2.x to 3.x? Here's a comprehensive list of wh
1313
- If a `before_breadcrumb` callback, an event processor, or an error processor raises an exception, the breadcrumb or event is now dropped. Previously, the SDK would log the exception and continue processing the unmodified breadcrumb or event.
1414
- The top-level manual tracing API is now wired to span streaming tracing:
1515
- `sentry_sdk.start_transaction` doesn't exist anymore. Use `sentry_sdk.start_span` instead.
16-
- `sentry_sdk.start_span` accepts four arguments now: `name` (mandatory), `attributes`, `parent_span` (default: current active span), `active` (default: `True`).
16+
- `sentry_sdk.start_span` accepts five arguments now: `name` (mandatory), `attributes`, `parent_span` (default: current active span), `active` (default: `True`), `links` (spans to link to without making them the parent; more can be added with `span.add_link()` / `span.add_links()`).
1717
- The `@trace` decorator accepts three optional arguments: `name`, `attributes`, `active` (default: `True`).
1818
- `sentry_sdk.continue_trace` accepts a single dictionary with the incoming headers or environment data. It is no longer a context manager and it does not return a span/transaction anymore. A span needs to be started manually afterwards.
1919
- `sentry_sdk.new_trace` resets the trace.

‎sentry_sdk/_span_batcher.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,12 @@ def _estimate_size(item: "SpanJSON") -> int:
159159
# 210 is the rough size of the payload without attributes, and then we
160160
# estimate the attributes separately.
161161
estimate = 210
162-
for value in (item.get("attributes") or {}).values():
162+
attribute_values = list((item.get("attributes") or {}).values())
163+
for link in item.get("links") or ():
164+
estimate += 100
165+
attribute_values.extend((link.get("attributes") or {}).values())
166+
167+
for value in attribute_values:
163168
estimate += 50
164169

165170
if isinstance(value, str):
@@ -180,6 +185,22 @@ def _to_transport_format(item: "SpanJSON") -> "Any":
180185
else:
181186
del res["attributes"]
182187

188+
if item.get("links"):
189+
res["links"] = []
190+
for link in item["links"]:
191+
if "attributes" in link:
192+
res["links"].append( # type: ignore[attr-defined]
193+
{
194+
**link,
195+
"attributes": {
196+
k: serialize_attribute(v)
197+
for (k, v) in link["attributes"].items()
198+
},
199+
}
200+
)
201+
else:
202+
res["links"].append(link) # type: ignore[attr-defined]
203+
183204
return res
184205

185206
def _flush(self, only_pending: bool = False) -> None:

‎sentry_sdk/_types.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,16 @@ class DataCollection(TypedDict):
364364

365365
MetricProcessor = Callable[[Metric, Hint], Optional[Metric]]
366366

367+
SpanLink = TypedDict(
368+
"SpanLink",
369+
{
370+
"span_id": str,
371+
"trace_id": str,
372+
"sampled": NotRequired[bool],
373+
"attributes": NotRequired[Attributes],
374+
},
375+
)
376+
367377
SpanJSON = TypedDict(
368378
"SpanJSON",
369379
{
@@ -376,6 +386,7 @@ class DataCollection(TypedDict):
376386
"start_timestamp": float,
377387
"end_timestamp": NotRequired[float],
378388
"attributes": NotRequired[Attributes],
389+
"links": NotRequired[list[SpanLink]],
379390
"_segment_span": NotRequired["sentry_sdk.traces.Span"],
380391
},
381392
)

‎sentry_sdk/scope.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
Log,
7777
LogLevelStr,
7878
Metric,
79+
SpanLink,
7980
Type,
8081
)
8182

@@ -937,6 +938,7 @@ def start_span(
937938
attributes: "Optional[Attributes]",
938939
parent_span: "Optional[Span]",
939940
active: bool,
941+
links: "Optional[list[SpanLink]]" = None,
940942
) -> "Span":
941943
"""
942944
Start a span.
@@ -987,6 +989,11 @@ def start_span(
987989
provided explicitly via the `parent_span` argument), set this to `False`.
988990
:type active: bool
989991
992+
:param links: Spans to link the span to, without making them its parent,
993+
e.g. in other traces. More can be added with `span.add_link()` or
994+
`span.add_links()`.
995+
:type links: "Optional[list[SpanLink]]"
996+
990997
:return: The span that has been started.
991998
:rtype: Span
992999
"""
@@ -1055,6 +1062,7 @@ def start_span(
10551062
baggage=propagation_context.baggage,
10561063
sample_rand=sample_rand,
10571064
sample_rate=sample_rate,
1065+
links=links,
10581066
)
10591067

10601068
# This is a child span; take propagation context from the parent span
@@ -1090,6 +1098,7 @@ def start_span(
10901098
trace_id=parent_span.trace_id,
10911099
parent_span_id=parent_span.span_id,
10921100
parent_sampled=parent_span.sampled,
1101+
links=links,
10931102
)
10941103

10951104
def _update_sample_rate(self, sample_rate: float) -> None:

‎sentry_sdk/traces.py‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
import sys
23
import uuid
34
from datetime import datetime, timedelta, timezone
@@ -33,7 +34,7 @@
3334
overload,
3435
)
3536

36-
from sentry_sdk._types import Attributes, AttributeValue, SpanJSON
37+
from sentry_sdk._types import Attributes, AttributeValue, SpanJSON, SpanLink
3738
from sentry_sdk.profiler.continuous_profiler import ContinuousProfile
3839

3940
P = ParamSpec("P")
@@ -132,10 +133,11 @@ def start_span(
132133
attributes: "Optional[Attributes]" = None,
133134
parent_span: "Optional[Span]" = _DEFAULT_PARENT_SPAN, # type: ignore[assignment]
134135
active: bool = True,
136+
links: "Optional[list[SpanLink]]" = None,
135137
) -> "Span":
136138

137139
return sentry_sdk.get_current_scope().start_span(
138-
name, attributes, parent_span, active
140+
name, attributes, parent_span, active, links
139141
)
140142

141143

@@ -171,6 +173,7 @@ class Span:
171173
__slots__ = (
172174
"_name",
173175
"_attributes",
176+
"_links",
174177
"_active",
175178
"_span_id",
176179
"_trace_id",
@@ -203,6 +206,7 @@ def __init__(
203206
baggage: "Optional[Baggage]" = None,
204207
sample_rate: "Optional[float]" = None,
205208
sample_rand: "Optional[float]" = None,
209+
links: "Optional[list[SpanLink]]" = None,
206210
):
207211
self._name: str = name
208212
self._active: bool = active
@@ -215,6 +219,10 @@ def __init__(
215219
for attribute, value in attributes.items():
216220
self.set_attribute(attribute, value)
217221

222+
self._links: "list[SpanLink]" = []
223+
if links:
224+
self.add_links(links)
225+
218226
self._scope = scope
219227

220228
self._segment = segment or self
@@ -372,6 +380,21 @@ def remove_attribute(self, key: str) -> None:
372380
except KeyError:
373381
pass
374382

383+
def add_link(self, link: "SpanLink") -> None:
384+
stored: "SpanLink" = {"trace_id": link["trace_id"], "span_id": link["span_id"]}
385+
if "sampled" in link:
386+
stored["sampled"] = link["sampled"]
387+
if link.get("attributes"):
388+
stored["attributes"] = {
389+
key: format_attribute(value)
390+
for key, value in link["attributes"].items()
391+
}
392+
self._links.append(stored)
393+
394+
def add_links(self, links: "list[SpanLink]") -> None:
395+
for link in links:
396+
self.add_link(link)
397+
375398
@property
376399
def status(self) -> "str":
377400
return self._status
@@ -551,6 +574,9 @@ def _to_json(self) -> "SpanJSON":
551574

552575
res["attributes"] = {k: v for k, v in self._attributes.items()}
553576

577+
if self._links:
578+
res["links"] = copy.deepcopy(self._links)
579+
554580
return res
555581

556582

@@ -674,6 +700,12 @@ def set_attributes(self, attributes: "Attributes") -> None:
674700
def remove_attribute(self, key: str) -> None:
675701
pass
676702

703+
def add_link(self, link: "SpanLink") -> None:
704+
pass
705+
706+
def add_links(self, links: "list[SpanLink]") -> None:
707+
pass
708+
677709
@property
678710
def status(self) -> "str":
679711
return SpanStatus.OK.value

‎tests/tracing/test_span_streaming.py‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ def test_before_send_span_unsupported_edit(sentry_init, capture_items):
284284
def before_send_span(span, hint):
285285
# Anything beyond attribute and name changes will be ignored
286286
span["trace_id"] = "my-trace-id"
287+
span["links"][0]["attributes"]["kind"] = "changed"
287288

288289
return span
289290

@@ -294,7 +295,8 @@ def before_send_span(span, hint):
294295

295296
items = capture_items("span")
296297

297-
with sentry_sdk.start_span(name="span"):
298+
link = {"trace_id": "a" * 32, "span_id": "b" * 16, "attributes": {"kind": "a"}}
299+
with sentry_sdk.start_span(name="span", links=[link]):
298300
...
299301

300302
sentry_sdk.flush()
@@ -305,6 +307,7 @@ def before_send_span(span, hint):
305307

306308
assert span["name"] == "span"
307309
assert span["trace_id"] != "my-trace-id"
310+
assert span["links"][0]["attributes"]["kind"] == {"type": "string", "value": "a"}
308311

309312

310313
def test_before_send_span_doesnt_receive_ignored_spans(sentry_init, capture_items):
@@ -435,6 +438,80 @@ class Class:
435438
assert "Class" in span["attributes"]["attribute2"]
436439

437440

441+
def test_span_links(sentry_init, capture_envelopes):
442+
sentry_init(
443+
traces_sample_rate=1.0,
444+
)
445+
446+
envelopes = capture_envelopes()
447+
448+
previous_trace = {
449+
"trace_id": "627a2885119dcc8184fae7eef09438cb",
450+
"span_id": "6c71fc6b09b8b716",
451+
"sampled": True,
452+
"attributes": {"sentry.link.type": "previous_trace"},
453+
}
454+
with sentry_sdk.start_span(name="parent", links=[previous_trace]) as span:
455+
span.add_link({"trace_id": "a" * 32, "span_id": "b" * 16})
456+
span.add_links(
457+
[
458+
{
459+
"trace_id": "c" * 32,
460+
"span_id": "d" * 16,
461+
"sampled": False,
462+
"attributes": {"messaging.message.id": "1", "retries": 2},
463+
}
464+
]
465+
)
466+
with sentry_sdk.start_span(name="child"):
467+
...
468+
# span keeps its own copy.
469+
previous_trace["attributes"]["sentry.link.type"] = "changed"
470+
471+
sentry_sdk.flush()
472+
child, span = envelopes[0].items[0].payload.json["items"]
473+
474+
# empty `links` array is omitted.
475+
assert "links" not in child
476+
assert span["links"] == [
477+
{
478+
"trace_id": "627a2885119dcc8184fae7eef09438cb",
479+
"span_id": "6c71fc6b09b8b716",
480+
"sampled": True,
481+
"attributes": {
482+
"sentry.link.type": {"type": "string", "value": "previous_trace"}
483+
},
484+
},
485+
{"trace_id": "a" * 32, "span_id": "b" * 16},
486+
{
487+
"trace_id": "c" * 32,
488+
"span_id": "d" * 16,
489+
"sampled": False,
490+
"attributes": {
491+
"messaging.message.id": {"type": "string", "value": "1"},
492+
"retries": {"type": "integer", "value": 2},
493+
},
494+
},
495+
]
496+
497+
498+
def test_span_links_on_noop_span(sentry_init, capture_items):
499+
sentry_init(
500+
traces_sample_rate=0.0,
501+
)
502+
503+
items = capture_items("span")
504+
505+
link = {"trace_id": "a" * 32, "span_id": "b" * 16}
506+
with sentry_sdk.start_span(name="parent", links=[link]) as span:
507+
assert isinstance(span, NoOpSpan)
508+
span.add_link(link)
509+
span.add_links([link])
510+
511+
sentry_sdk.flush()
512+
assert items == []
513+
514+
438515
def test_traces_sampler_drops_span(sentry_init, capture_items):
439516
def traces_sampler(sampling_context):
440517
assert "data" in sampling_context["transaction_context"]

0 commit comments

Comments
 (0)