feat(traces): before_span_send hook - #956
Conversation
posthog-python Compliance ReportDate: 2026-09-17 03:43:05 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
Important Files Changed
Reviews (2): Last reviewed commit: "fix(traces): keep a falsy status message..." | Re-trigger Greptile |
3715073 to
a187b2a
Compare
a187b2a to
25ad854
Compare
25ad854 to
6ebfcae
Compare
6ebfcae to
0d5388a
Compare
0d5388a to
c214418
Compare
c214418 to
45d69d2
Compare
jzhu13
left a comment
There was a problem hiding this comment.
Reviewed against traces/07-span-limits. Tests pass at the head, ruff and mypy are clean. The core is sound: no lock held during the hook, ids cannot be forged, malformed returns drop rather than export junk, and limits are re-applied with the span's original write order. One item I would fix before merge.
Blocking
posthog/tracing/_before_span_send.py:49an intentional drop (return None, the documented way to filter) is counted as a dropped span and surfaces asWARNING Dropping N span(s): before_span_send dropped iteveryflush_interval. A health-check filter warns every 5 s for the life of the process. The events hook logs the same case at DEBUG (client.py:2340), and the test attest_pipeline.py:590is named..._quietlyyet asserts the WARNING. Suggestlog.debugand nodrops.recordfor aNonereturn; keepdrops.recordfor failures.
Non-blocking, recommended
posthog/tracing/_before_span_send.py:77a hook that raises logs the traceback at DEBUG only; at WARNING the user sees the aggregatebefore_span_send failedwith no cause or hook name. This is the scrubbing point, so a one-character bug in a scrubber makes tracing go dark with no actionable message. Eventsbefore_senduseslog.exception. Log the first failure per interval at WARNING withexc_info=True.posthog/tracing/_before_span_send.py:134a dict missingkind,name,start_time_ns, orend_time_nsdrops the span, but the same key present with an unusable value (kind=None,start_time_ns="later") falls back to the original. Reproduced: a hook that builds a fresh allowlisted dict withoutkinddrops every span. Treat missing like invalid, or list the required keys in #957's docstrings.posthog/tracing/_before_span_send.py:105per-eventdropped_attributes_countis shown to the hook and read back (clamped) while span-level counts are hidden and preserved. A hook that rebuilds event dicts withname/timestamp_ns/attributesresets the count to 0; it can also set it to 10^6. Make it read-only like the ids.posthog/tracing/_limits.py:179apply_span_limitsre-runstruncate_attribute_valueon every attribute, including values the hook did not touch and the span already truncated at write time;_hook_viewalso copiesrecord.attributesthatend()already copied. Skip values that are the same object as in the pre-hook record.- Nits: non-callable hook entries warn and fail open while a raising hook fails closed, which is the opposite of the rationale in #957's
client.py:2539comment ("defaults would drop a before_span_send hook and export unscrubbed spans"); anasync defhook is reported asreturned an unusable recordwith no hint that async is unsupported; noBeforeSpanSendCallbackalias inposthog/types.pyfor parity withBeforeSendCallback.
Reviewed with Claude Code (Claude Fable 5.1). Behaviors above were reproduced against this branch head.
45d69d2 to
c39007a
Compare
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 2 · PR risk: 0/10 |
|
Thanks. Fixed in c39007a and c73e035:
|
c73e035 to
c4c6aa3
Compare
|
Follow-up, c4c6aa3: a |
Adds the traces `before_span_send` option: a callable, or a list run in order, that receives each finished span as a plain dict (like the events before_send hook) and returns it edited, or None to drop it. It is the documented place to scrub sensitive values, so a hook that raises drops the span rather than exporting it unscrubbed. trace_id, span_id and parent_span_id are read-only; names, times, status and events are re-sanitized and the per-span limits re-applied to whatever the hook returns. The hook runs with no tracing lock held, and entries that are not callable are ignored with a warning. Not reachable from the client. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TkZAsCciW4PV8ZdcCHmAbA
A message of 0 or False was dropped, and one whose truth test raises took the whole span with it. Only None now means no message, as in set_status.
…one says why Returning None is the documented way to filter, so it no longer counts as a dropped span or warns every interval. A hook that raises now warns with its traceback once per interval rather than only at debug, since it is the scrubbing point. A field the hook leaves out keeps the original's value instead of dropping the span, an async hook is named as unsupported, values the hook did not touch skip the second truncation walk, and BeforeSpanSendCallback joins posthog.types.
…back alias Skipping values the hook left as the same object let a container grown in place ship past max_attribute_value_length. The public API snapshot gains BeforeSpanSendCallback.
…ing off Warning and exporting without the entry was the opposite of what a raising hook does, and the opposite of the fail-closed rationale in the client. The resolver now raises, so the client reports the error and leaves tracing off rather than exporting spans the entry was meant to redact.
c4c6aa3 to
de53e54
Compare
💡 Motivation and Context
Adds the traces
before_span_sendoption: a callable, or a list run in order, that receives each finished span as a plain dict (like the eventsbefore_sendhook) and returns it edited, orNoneto drop it.It is the documented place to scrub sensitive values, so a hook that raises drops the span rather than exporting it unscrubbed.
trace_id,span_idandparent_span_idare read-only. Names, times, status and events are re-sanitized and the per-span limits re-applied to whatever the hook returns. The hook runs with no tracing lock held, and entries that are not callable are ignored with a warning.Not reachable from the client yet.
Stack (PR 8 of 9, based on
traces/07-span-limits):traces/01-ids-traceparenttraces/02-otlp-encodingtraces/03-span-handlestraces/04-transporttraces/05-pipelinetraces/06-exporttraces/07-span-limitstraces/08-before-span-send← this PRtraces/09-client-wiring💚 How did you test it?
Unit tests in
posthog/test/tracing/test_pipeline.pyandtest_config.pycover edit, drop, raise, read-only ids, re-sanitizing and non-callable entries.📝 Checklist
If releasing new changes
sampo addto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Implemented with Claude Code (Claude Opus 5) against the traces spec, one commit per slice so each PR reviews on its own. Rebased onto main and opened as a stacked draft in a later Claude Code session (Claude Fable 5.1).
🤖 Generated with Claude Code
https://claude.ai/code/session_012o7CtHLfcypjmXL7g9ZGRC