1212is attached: every attribute value creates a new series, and per-user series
1313are the canonical metrics-cardinality explosion.
1414
15+ A sample recorded inside a sampled span (a PostHog ``start_span`` span, or the
16+ current OpenTelemetry span) stamps its series with an exemplar: the trace and
17+ span IDs plus the value. One exemplar per series per window, last sample wins,
18+ so a metric point links to one representative trace at no cardinality cost.
19+
1520Delivery is at-least-once: a request that succeeds server-side but fails
1621client-side (e.g. a read timeout) is retried with the next window, which can
1722double-count that window's deltas. Failed flushes retry with exponential
3742from posthog .utils import remove_trailing_slash
3843from posthog .version import VERSION
3944
45+ from .tracing ._traceparent import TRACE_FLAGS_SAMPLED , parse_traceparent
46+
4047log = logging .getLogger ("posthog" )
4148
4249MetricAttributeValue = Union [str , int , float , bool ]
7481_DEFAULT_FLUSH_INTERVAL_SECONDS = 10.0
7582_DEFAULT_MAX_SERIES_PER_FLUSH = 1000
7683_SCOPE_NAME = "posthog-python"
84+ # None until the first lookup; False when opentelemetry-api is not installed.
85+ _otel_trace : Any = None
7786
7887
7988def _snapshot_attribute_value (value : Any ) -> Any :
@@ -135,6 +144,52 @@ def _bucket_index_for(value: float, bounds: list) -> int:
135144 return len (bounds )
136145
137146
147+ def _parse_sampled_traceparent (value : Any ) -> Optional [tuple [str , str ]]:
148+ """The (trace_id, span_id) of a sampled W3C ``traceparent``, or None."""
149+ context = parse_traceparent (value )
150+ if context is None or context .flags != TRACE_FLAGS_SAMPLED :
151+ return None
152+ return context .trace_id , context .span_id
153+
154+
155+ def _otel_trace_module () -> Any :
156+ global _otel_trace
157+ if _otel_trace is None :
158+ try :
159+ from opentelemetry import trace
160+
161+ _otel_trace = trace
162+ except ImportError :
163+ _otel_trace = False
164+ return _otel_trace
165+
166+
167+ def _active_trace_context (client : Any ) -> Optional [tuple [str , str ]]:
168+ """The sampled span a sample is recorded in: PostHog's own active span first,
169+ then the current OpenTelemetry span. An unsampled span is never exported, so
170+ an exemplar pointing at it would link to a trace that does not exist."""
171+ try :
172+ get_active_span = getattr (client , "get_active_span" , None )
173+ span = get_active_span () if callable (get_active_span ) else None
174+ if span is not None :
175+ # A PostHog span is active, so it is the innermost context; an
176+ # unsampled one must not fall through to an outer OTel span.
177+ return _parse_sampled_traceparent (span .traceparent ())
178+ except Exception :
179+ pass
180+
181+ trace = _otel_trace_module ()
182+ if not trace :
183+ return None
184+ try :
185+ span_context = trace .get_current_span ().get_span_context ()
186+ if not span_context .is_valid or not span_context .trace_flags .sampled :
187+ return None
188+ return f"{ span_context .trace_id :032x} " , f"{ span_context .span_id :016x} "
189+ except Exception :
190+ return None
191+
192+
138193def _series_key (
139194 metric_type : str , name : str , unit : Optional [str ], attributes : Optional [dict ]
140195) -> str :
@@ -153,6 +208,24 @@ def _series_key(
153208 return "\x00 " .join ((metric_type , name , unit or "" , attrs_part ))
154209
155210
211+ def _build_exemplars (exemplar : Optional [dict ]) -> dict :
212+ # Spread into the data point, so a point without an exemplar keeps its
213+ # exact pre-exemplar wire shape. OTLP/JSON encodes trace and span IDs as hex.
214+ if exemplar is None :
215+ return {}
216+ return {
217+ "exemplars" : [
218+ {
219+ "filteredAttributes" : [],
220+ "timeUnixNano" : _ms_to_unix_nano (exemplar ["time_ms" ]),
221+ "asDouble" : exemplar ["value" ],
222+ "traceId" : exemplar ["trace_id" ],
223+ "spanId" : exemplar ["span_id" ],
224+ }
225+ ]
226+ }
227+
228+
156229class _SeriesState :
157230 __slots__ = (
158231 "name" ,
@@ -163,6 +236,7 @@ class _SeriesState:
163236 "total" ,
164237 "last" ,
165238 "hist" ,
239+ "exemplar" ,
166240 )
167241
168242 def __init__ (
@@ -195,6 +269,7 @@ def __init__(
195269 self .total : Optional [float ] = None
196270 self .last : Optional [float ] = None
197271 self .hist : Optional [dict ] = None
272+ self .exemplar : Optional [dict ] = None
198273
199274
200275class PostHogMetrics :
@@ -412,6 +487,7 @@ def _capture(
412487 # series identity too — otherwise two indistinguishable data points emit.
413488 attributes = {k : v for k , v in attributes .items () if v is not None }
414489 key = _series_key (metric_type , name , unit , attributes )
490+ trace_context = _active_trace_context (self ._client )
415491
416492 with self ._lock :
417493 self ._reset_after_fork_locked ()
@@ -446,6 +522,13 @@ def _capture(
446522 )
447523
448524 self ._fold (state , float (value ))
525+ if trace_context is not None :
526+ state .exemplar = {
527+ "trace_id" : trace_context [0 ],
528+ "span_id" : trace_context [1 ],
529+ "value" : float (value ),
530+ "time_ms" : int (time .time () * 1000 ),
531+ }
449532 self ._arm_flush_timer ()
450533
451534 def _reinit_after_fork (self ) -> None :
@@ -641,6 +724,9 @@ def _merge_window_back(self, window: dict) -> None:
641724 self ._series [key ] = old
642725 continue
643726 current .window_start_ms = min (current .window_start_ms , old .window_start_ms )
727+ # The live window's exemplar is newer; the old one only fills a gap.
728+ if current .exemplar is None :
729+ current .exemplar = old .exemplar
644730 if current .type == "count" :
645731 current .total = (current .total or 0.0 ) + (old .total or 0.0 )
646732 elif current .type == "histogram" and old .hist :
@@ -718,6 +804,7 @@ def _build_metrics(self, window: dict) -> list:
718804
719805 attributes = _to_otlp_key_value_list (state .attributes or {})
720806 start_nano = _ms_to_unix_nano (state .window_start_ms )
807+ exemplars = _build_exemplars (state .exemplar )
721808
722809 if state .type == "count" :
723810 metric ["sum" ]["dataPoints" ].append (
@@ -726,6 +813,7 @@ def _build_metrics(self, window: dict) -> list:
726813 "startTimeUnixNano" : start_nano ,
727814 "timeUnixNano" : now_nano ,
728815 "asDouble" : state .total or 0.0 ,
816+ ** exemplars ,
729817 }
730818 )
731819 elif state .type == "gauge" :
@@ -734,6 +822,7 @@ def _build_metrics(self, window: dict) -> list:
734822 "attributes" : attributes ,
735823 "timeUnixNano" : now_nano ,
736824 "asDouble" : state .last or 0.0 ,
825+ ** exemplars ,
737826 }
738827 )
739828 elif state .hist is not None :
@@ -751,6 +840,7 @@ def _build_metrics(self, window: dict) -> list:
751840 "max" : state .hist ["max" ],
752841 "bucketCounts" : state .hist ["bucket_counts" ],
753842 "explicitBounds" : DEFAULT_HISTOGRAM_BOUNDS ,
843+ ** exemplars ,
754844 }
755845 )
756846
0 commit comments