Skip to content

Commit b37591b

Browse files
committed
feat(otel): add durable sampling coordination
1 parent e3f2437 commit b37591b

2 files changed

Lines changed: 616 additions & 0 deletions

File tree

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""Durable execution sampling support."""
2+
3+
from __future__ import annotations
4+
5+
import functools
6+
import inspect
7+
from dataclasses import dataclass
8+
from typing import Any, Callable
9+
10+
from opentelemetry import context as otel_context
11+
from opentelemetry.context import Context
12+
from opentelemetry.sdk.trace import Tracer as SdkTracer
13+
from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult
14+
from opentelemetry.trace import Span, SpanContext, SpanKind, TraceFlags
15+
16+
from aws_durable_execution_sdk_python_otel.context_extractors import (
17+
ExtractedContext,
18+
Sampling,
19+
)
20+
21+
22+
_DURABLE_SAMPLING_INTENT_KEY = otel_context.create_key(
23+
"aws_durable_execution_sampling_intent"
24+
)
25+
26+
27+
@dataclass(frozen=True)
28+
class DurableSamplingIntent:
29+
"""Sampling result to apply to each durable span in one invocation."""
30+
31+
result: SamplingResult
32+
33+
34+
class DurableSampler(Sampler):
35+
"""Sampler that honors a durable sampling intent carried on parent context."""
36+
37+
def __init__(self, delegate: Sampler) -> None:
38+
self.delegate = delegate
39+
40+
@classmethod
41+
def install_on_tracer(cls, tracer: SdkTracer) -> "DurableSampler":
42+
current_sampler = tracer.sampler
43+
if isinstance(current_sampler, cls):
44+
return current_sampler
45+
sampler = cls(current_sampler)
46+
tracer.sampler = sampler
47+
return sampler
48+
49+
def should_sample(
50+
self,
51+
parent_context: Context | None,
52+
trace_id: int,
53+
name: str,
54+
kind: SpanKind | None = None,
55+
attributes: Any = None,
56+
links: Any = None,
57+
trace_state: Any = None,
58+
) -> SamplingResult:
59+
intent = otel_context.get_value(_DURABLE_SAMPLING_INTENT_KEY, parent_context)
60+
if isinstance(intent, DurableSamplingIntent):
61+
merged_attributes = dict(attributes or {})
62+
merged_attributes.update(dict(intent.result.attributes or {}))
63+
return SamplingResult(
64+
intent.result.decision,
65+
attributes=merged_attributes,
66+
trace_state=intent.result.trace_state,
67+
)
68+
return _delegate_should_sample(
69+
self.delegate,
70+
parent_context,
71+
trace_id,
72+
name,
73+
kind,
74+
attributes,
75+
links,
76+
trace_state,
77+
)
78+
79+
def get_description(self) -> str:
80+
return f"DurableSampler{{{self.delegate.get_description()}}}"
81+
82+
83+
def store_sampling_intent(
84+
parent_context: Context,
85+
intent: DurableSamplingIntent | None,
86+
) -> Context:
87+
"""Attach a durable sampling intent to a span parent context."""
88+
if intent is None:
89+
return parent_context
90+
return otel_context.set_value(_DURABLE_SAMPLING_INTENT_KEY, intent, parent_context)
91+
92+
93+
def resolve_sampling_result(
94+
*,
95+
extracted: ExtractedContext | None,
96+
ambient_span: Span,
97+
canonical_trace_id: int,
98+
sampler: Sampler,
99+
span_name: str,
100+
attributes: dict[str, Any],
101+
) -> SamplingResult:
102+
"""Resolve one sampling decision for all durable spans in an invocation.
103+
104+
Trace state from a same-trace ambient span is preserved across every
105+
branch, so an explicit backend decision overrides only the sampling
106+
outcome, not vendor/W3C ``tracestate`` propagation.
107+
"""
108+
ambient_context = ambient_span.get_span_context()
109+
on_canonical_trace = _is_same_trace(ambient_context, canonical_trace_id)
110+
ambient_trace_state = ambient_context.trace_state if on_canonical_trace else None
111+
112+
sampling = extracted.sampling if extracted is not None else Sampling.UNDECIDED
113+
if sampling is Sampling.SAMPLED:
114+
return SamplingResult(
115+
Decision.RECORD_AND_SAMPLE,
116+
trace_state=ambient_trace_state,
117+
)
118+
if sampling is Sampling.NOT_SAMPLED:
119+
return SamplingResult(Decision.DROP, trace_state=ambient_trace_state)
120+
121+
if on_canonical_trace:
122+
if bool(ambient_context.trace_flags & TraceFlags.SAMPLED):
123+
decision = Decision.RECORD_AND_SAMPLE
124+
elif ambient_span.is_recording():
125+
decision = Decision.RECORD_ONLY
126+
else:
127+
decision = Decision.DROP
128+
return SamplingResult(decision, trace_state=ambient_trace_state)
129+
130+
return _delegate_should_sample(
131+
sampler,
132+
Context(),
133+
canonical_trace_id,
134+
span_name,
135+
SpanKind.INTERNAL,
136+
attributes,
137+
(),
138+
None,
139+
)
140+
141+
142+
def is_sampled(result: SamplingResult) -> bool:
143+
return result.decision is Decision.RECORD_AND_SAMPLE
144+
145+
146+
@functools.lru_cache(maxsize=None)
147+
def _delegate_accepts_trace_state(should_sample: Callable[..., SamplingResult]) -> bool:
148+
"""Return whether a sampler's ``should_sample`` accepts ``trace_state``.
149+
150+
``trace_state`` was added to ``Sampler.should_sample`` in OpenTelemetry SDK
151+
1.21. The package supports ``opentelemetry-sdk>=1.20.0``, whose samplers end
152+
at ``links``. A parameter probe (rather than a call-time ``try/except``)
153+
avoids masking a ``TypeError`` raised inside the sampler body and never
154+
invokes the sampler twice.
155+
"""
156+
try:
157+
parameters = inspect.signature(should_sample).parameters
158+
except (TypeError, ValueError):
159+
return True
160+
if "trace_state" in parameters:
161+
return True
162+
return any(
163+
parameter.kind is inspect.Parameter.VAR_KEYWORD
164+
for parameter in parameters.values()
165+
)
166+
167+
168+
def _delegate_should_sample(
169+
sampler: Sampler,
170+
parent_context: Context | None,
171+
trace_id: int,
172+
name: str,
173+
kind: SpanKind | None,
174+
attributes: Any,
175+
links: Any,
176+
trace_state: Any,
177+
) -> SamplingResult:
178+
"""Call a delegate sampler using the signature its OTel version supports."""
179+
if _delegate_accepts_trace_state(sampler.should_sample):
180+
return sampler.should_sample(
181+
parent_context,
182+
trace_id,
183+
name,
184+
kind,
185+
attributes,
186+
links,
187+
trace_state,
188+
)
189+
return sampler.should_sample(
190+
parent_context,
191+
trace_id,
192+
name,
193+
kind,
194+
attributes,
195+
links,
196+
)
197+
198+
199+
def _is_same_trace(span_context: SpanContext, trace_id: int) -> bool:
200+
return span_context.is_valid and span_context.trace_id == trace_id

0 commit comments

Comments
 (0)