Skip to content

Commit 3cc2d64

Browse files
authored
feat(metrics): attach trace exemplars to flushed metric series (#1057)
1 parent ee686ff commit 3cc2d64

3 files changed

Lines changed: 226 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: minor
3+
---
4+
5+
`client.metrics` now attaches a trace exemplar to each series it flushes. A sample recorded inside a sampled span (a PostHog `start_span` span, or the current OpenTelemetry span) stamps its data point with that span's trace ID, span ID, and value, so metric charts can link to a representative trace. One exemplar per series per flush window; the last sample wins.

‎posthog/metrics_capture.py‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
is attached: every attribute value creates a new series, and per-user series
1313
are 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+
1520
Delivery is at-least-once: a request that succeeds server-side but fails
1621
client-side (e.g. a read timeout) is retried with the next window, which can
1722
double-count that window's deltas. Failed flushes retry with exponential
@@ -37,6 +42,8 @@
3742
from posthog.utils import remove_trailing_slash
3843
from posthog.version import VERSION
3944

45+
from .tracing._traceparent import TRACE_FLAGS_SAMPLED, parse_traceparent
46+
4047
log = logging.getLogger("posthog")
4148

4249
MetricAttributeValue = Union[str, int, float, bool]
@@ -74,6 +81,8 @@
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

7988
def _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+
138193
def _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+
156229
class _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

200275
class 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

‎posthog/test/test_metrics.py‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from unittest import mock
55

66
import pytest
7+
from opentelemetry import trace
8+
from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags
79

810
import posthog
911
from posthog.client import Client
@@ -25,6 +27,21 @@ def client():
2527
c.metrics.reset()
2628

2729

30+
@pytest.fixture
31+
def traced_client():
32+
# Tracing on, so start_span returns a recording span with its own span ID
33+
# instead of a pass-through that echoes the parent's traceparent.
34+
with mock.patch(
35+
"posthog.tracing._transport._get_session", return_value=mock_session()
36+
):
37+
c = Client(
38+
FAKE_API_KEY, host="https://us.example.com", sync_mode=True, traces={}
39+
)
40+
yield c
41+
c.metrics.reset()
42+
c.shutdown()
43+
44+
2845
def mock_session(status_code=200):
2946
session = mock.Mock()
3047
session.post.return_value = mock.Mock(status_code=status_code)
@@ -586,6 +603,120 @@ def test_any_value_encoding_matches_js(self, client, value, expected):
586603
assert attr["value"] == expected
587604

588605

606+
OTEL_TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736"
607+
OTEL_SPAN_ID = "00f067aa0ba902b7"
608+
609+
610+
def otel_span(sampled):
611+
return NonRecordingSpan(
612+
SpanContext(
613+
trace_id=int(OTEL_TRACE_ID, 16),
614+
span_id=int(OTEL_SPAN_ID, 16),
615+
is_remote=False,
616+
trace_flags=TraceFlags(
617+
TraceFlags.SAMPLED if sampled else TraceFlags.DEFAULT
618+
),
619+
)
620+
)
621+
622+
623+
def data_points_by_type(payload):
624+
points = {}
625+
for metric in metrics_from(payload):
626+
for kind in ("sum", "gauge", "histogram"):
627+
if kind in metric:
628+
points[kind] = metric[kind]["dataPoints"][0]
629+
return points
630+
631+
632+
def record_one_of_each(client):
633+
client.metrics.count("c", 1)
634+
client.metrics.count("c", 4)
635+
client.metrics.gauge("g", 7)
636+
client.metrics.histogram("h", 12)
637+
638+
639+
class TestMetricsExemplars:
640+
def test_sampled_otel_span_stamps_last_sample_on_every_type(self, client):
641+
with trace.use_span(otel_span(sampled=True)):
642+
record_one_of_each(client)
643+
644+
payload, _, _ = flush_and_capture(client)
645+
points = data_points_by_type(payload)
646+
647+
for kind, value in (("sum", 4), ("gauge", 7), ("histogram", 12)):
648+
(exemplar,) = points[kind]["exemplars"]
649+
assert exemplar["traceId"] == OTEL_TRACE_ID
650+
assert exemplar["spanId"] == OTEL_SPAN_ID
651+
assert exemplar["asDouble"] == value
652+
assert exemplar["timeUnixNano"].isdigit()
653+
654+
def test_active_posthog_span_wins_over_outer_otel_span(self, traced_client):
655+
parent_trace_id, parent_span_id = "a" * 32, "b" * 16
656+
with trace.use_span(otel_span(sampled=True)):
657+
with traced_client.start_span(
658+
"job", parent=f"00-{parent_trace_id}-{parent_span_id}-01"
659+
) as span:
660+
traced_client.metrics.count("c", 1)
661+
_, trace_id, span_id, _ = span.traceparent().split("-")
662+
663+
payload, _, _ = flush_and_capture(traced_client)
664+
(exemplar,) = data_points_by_type(payload)["sum"]["exemplars"]
665+
# The recording span's own ID, in the inbound trace, not the parent's.
666+
assert span_id not in (parent_span_id, OTEL_SPAN_ID)
667+
assert (exemplar["traceId"], exemplar["spanId"]) == (parent_trace_id, span_id)
668+
669+
def test_innermost_posthog_span_is_the_exemplar(self, traced_client):
670+
with traced_client.start_span("request"):
671+
with traced_client.start_span("db.query") as child:
672+
traced_client.metrics.histogram("h", 3)
673+
_, trace_id, span_id, _ = child.traceparent().split("-")
674+
675+
payload, _, _ = flush_and_capture(traced_client)
676+
(exemplar,) = data_points_by_type(payload)["histogram"]["exemplars"]
677+
assert (exemplar["traceId"], exemplar["spanId"]) == (trace_id, span_id)
678+
679+
@pytest.mark.parametrize("context", ["no_span", "unsampled_otel_span"])
680+
def test_no_exemplar_without_a_sampled_span(self, client, context):
681+
if context == "no_span":
682+
record_one_of_each(client)
683+
else:
684+
with trace.use_span(otel_span(sampled=False)):
685+
record_one_of_each(client)
686+
687+
payload, _, _ = flush_and_capture(client)
688+
for point in data_points_by_type(payload).values():
689+
assert "exemplars" not in point
690+
691+
def test_unsampled_posthog_span_hides_a_sampled_outer_otel_span(
692+
self, traced_client
693+
):
694+
with trace.use_span(otel_span(sampled=True)):
695+
with traced_client.start_span(
696+
"job", parent=f"00-{'a' * 32}-{'b' * 16}-00"
697+
) as span:
698+
assert span.traceparent().endswith("-00")
699+
record_one_of_each(traced_client)
700+
701+
payload, _, _ = flush_and_capture(traced_client)
702+
for point in data_points_by_type(payload).values():
703+
assert "exemplars" not in point
704+
705+
def test_failed_flush_keeps_exemplar_for_the_retry(self, client):
706+
with trace.use_span(otel_span(sampled=True)):
707+
client.metrics.count("c", 1)
708+
with mock.patch(
709+
"posthog.metrics_capture._get_session", return_value=mock_session(503)
710+
):
711+
client.metrics.flush()
712+
client.metrics.count("c", 2)
713+
714+
payload, _, _ = flush_and_capture(client)
715+
point = data_points_by_type(payload)["sum"]
716+
assert point["asDouble"] == 3
717+
assert point["exemplars"][0]["traceId"] == OTEL_TRACE_ID
718+
719+
589720
class TestMetricsFailureHandling:
590721
def test_fork_drops_inherited_window_and_rearms(self, client):
591722
# A forked child inherits a dead timer thread and the parent's window; without the

0 commit comments

Comments
 (0)