Skip to content

feat(traces): before_span_send hook - #956

Merged
turnipdabeets merged 5 commits into
traces/07-span-limitsfrom
traces/08-before-span-send
Sep 18, 2026
Merged

turnipdabeets merged 5 commits into
traces/07-span-limitsfrom
traces/08-before-span-send

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

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 yet.

Stack (PR 8 of 9, based on traces/07-span-limits):

  1. feat(traces): W3C trace context ids and traceparent parsing #949 traces/01-ids-traceparent
  2. feat(traces): OTLP span encoding and client-side validity #950 traces/02-otlp-encoding
  3. feat(traces): span handles #951 traces/03-span-handles
  4. feat(traces): span batch transport #952 traces/04-transport
  5. feat(traces): span pipeline #953 traces/05-pipeline
  6. feat(traces): span export queue with retries #954 traces/06-export
  7. feat(traces): per-span limits and exception stacktraces #955 traces/07-span-limits
  8. feat(traces): before_span_send hook #956 traces/08-before-span-send ← this PR
  9. feat(traces): wire tracing into the client #957 traces/09-client-wiring

💚 How did you test it?

Unit tests in posthog/test/tracing/test_pipeline.py and test_config.py cover edit, drop, raise, read-only ids, re-sanitizing and non-callable entries.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran sampo add to 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

@turnipdabeets turnipdabeets self-assigned this Sep 14, 2026
@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-09-17 03:43:05 UTC
Duration: 256322ms

✅ All Tests Passed!

111/111 tests passed


Capture_V1 Tests

✅ 94/94 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 514ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 510ms
Required Headers.Has Authorization Bearer Header ✅ 510ms
Required Headers.Has Content Type Json ✅ 510ms
Required Headers.Has Posthog Sdk Info Format ✅ 510ms
Required Headers.Has Posthog Attempt Header ✅ 509ms
Required Headers.Has Posthog Request Id ✅ 509ms
Required Headers.Has Posthog Request Timestamp ✅ 510ms
Required Headers.Has User Agent ✅ 510ms
Body Format.Body Has Created At And Batch ✅ 510ms
Body Format.No Api Key In Body ✅ 511ms
Body Format.No Sent At In Body ✅ 510ms
Event Format.Event Has Required Root Fields ✅ 510ms
Event Format.Event Uuid Is Valid ✅ 509ms
Event Format.Event Timestamp Is Rfc3339 ✅ 510ms
Event Format.Distinct Id Is String ✅ 509ms
Event Format.Distinct Id At Root Not Properties ✅ 510ms
Event Format.Custom Properties Preserved ✅ 510ms
Event Format.Set Properties Preserved ✅ 509ms
Event Format.Set Once Properties Preserved ✅ 510ms
Event Format.Groups Properties Preserved ✅ 510ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 511ms
Event Format.Event Has Required Root Fields Batch ✅ 515ms
Event Format.Event Uuid Is Valid Batch ✅ 514ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 514ms
Event Format.Distinct Id Is String Batch ✅ 513ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 514ms
Event Format.Custom Properties Preserved Batch ✅ 513ms
Event Format.Set Properties Preserved Batch ✅ 513ms
Event Format.Set Once Properties Preserved Batch ✅ 515ms
Event Format.Groups Properties Preserved Batch ✅ 513ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 513ms
Batch Behavior.Multiple Events In Single Batch ✅ 520ms
Batch Behavior.Batch Envelope Smoke ✅ 515ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 506ms
Batch Behavior.Flush At Triggers Batch ✅ 1010ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 511ms
Deduplication.Generates Unique Uuids ✅ 517ms
Deduplication.Different Events Same Content Different Uuids ✅ 514ms
Deduplication.Preserves Uuid On Retry ✅ 6519ms
Deduplication.Preserves Timestamp On Retry ✅ 6520ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 6522ms
Deduplication.No Duplicate Events In Batch ✅ 517ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 510ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 13522ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 6519ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 3019ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 6519ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 511ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 514ms
Response Format Validation.Success No Retry After When All Ok ✅ 513ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 1515ms
Response Format Validation.Success No Retry After When Drop Only ✅ 513ms
Response Format Validation.Response Echoes Request Id ✅ 511ms
Retry Behavior.Retries On 408 ✅ 6518ms
Retry Behavior.Retries On 500 ✅ 6520ms
Retry Behavior.Retries On 503 ✅ 8523ms
Retry Behavior.Retries On 504 ✅ 6519ms
Retry Behavior.Retryable Errors Have Retry After ✅ 3517ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 11523ms
Retry Behavior.Does Not Retry On 400 ✅ 2513ms
Retry Behavior.Does Not Retry On 401 ✅ 2513ms
Retry Behavior.Does Not Retry On 402 ✅ 2514ms
Retry Behavior.Does Not Retry On 413 ✅ 2513ms
Retry Behavior.Does Not Retry On 415 ✅ 2514ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2512ms
Retry Behavior.Implements Backoff ✅ 22534ms
Retry Behavior.Max Retries Respected ✅ 22535ms
Partial Batch Handling.Handles 200 Full Success ✅ 2513ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3517ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3517ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3515ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 6526ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 6522ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 6522ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 6521ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 6520ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 6521ms
Partial Batch Handling.Respects Retry After On Partial ✅ 8518ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3514ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3519ms
Compression.Sends Gzip Content Encoding ✅ 511ms
Compression.No Content Encoding When Disabled ✅ 509ms
Compression.Compressed Body Is Decompressible ✅ 511ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2512ms
Event Options.Cookieless Mode Override ✅ 513ms
Event Options.Disable Skew Correction Override ✅ 510ms
Event Options.Process Person Profile Override ✅ 510ms
Event Options.Product Tour Id Override ✅ 510ms
Event Options.Unset Options Omitted ✅ 510ms
Event Options.Options Override In Batch ✅ 513ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 511ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 511ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 510ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 12ms
Request Payload.Flags Request Uses V2 Query Param ✅ 8ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 9ms
Request Payload.Flags Request Omits Authorization Header ✅ 9ms
Request Payload.Token In Flags Body Matches Init ✅ 8ms
Request Payload.Groups Round Trip ✅ 8ms
Request Payload.Groups Default To Empty Object ✅ 10ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 9ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 9ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 9ms
Request Lifecycle.No Flags Request On Init Alone ✅ 3ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 508ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 15ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 10ms
Retry Behavior.Retries Flags On 502 ✅ 312ms
Retry Behavior.Retries Flags On 504 ✅ 312ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 512ms

@greptile-apps

greptile-apps Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Important Files Changed

Filename Overview
posthog/tracing/_before_span_send.py Runs hooks without tracing locks, restores span IDs, rebuilds safe records, and drops spans when a hook fails.
posthog/tracing/_pipeline.py Runs before_span_send before queueing and safely handles close races and span setup errors.
posthog/tracing/_limits.py Reapplies span and event limits after hooks. Callable values still reach the safe encoder path.
posthog/tracing/_config.py Accepts one hook or a list and skips invalid entries with a warning.
posthog/test/tracing/test_pipeline.py Covers hook edits, drops, errors, limits, lock use, close races, and span setup cleanup.

Reviews (2): Last reviewed commit: "fix(traces): keep a falsy status message..." | Re-trigger Greptile

Comment thread posthog/tracing/_before_span_send.py Outdated
@turnipdabeets
turnipdabeets force-pushed the traces/08-before-span-send branch from 3715073 to a187b2a Compare September 14, 2026 22:58
@turnipdabeets
turnipdabeets added this pull request to stack #958 September 14, 2026 23:03
@turnipdabeets
turnipdabeets force-pushed the traces/08-before-span-send branch from a187b2a to 25ad854 Compare September 15, 2026 14:09
@turnipdabeets
turnipdabeets force-pushed the traces/08-before-span-send branch from 25ad854 to 6ebfcae Compare September 15, 2026 14:17
@turnipdabeets
turnipdabeets force-pushed the traces/08-before-span-send branch from 6ebfcae to 0d5388a Compare September 15, 2026 14:25
@turnipdabeets
turnipdabeets requested a review from a team September 15, 2026 15:19
@turnipdabeets
turnipdabeets force-pushed the traces/08-before-span-send branch from 0d5388a to c214418 Compare September 15, 2026 22:04
@turnipdabeets
turnipdabeets force-pushed the traces/08-before-span-send branch from c214418 to 45d69d2 Compare September 16, 2026 18:06
@turnipdabeets
turnipdabeets marked this pull request as ready for review September 16, 2026 18:32
@turnipdabeets
turnipdabeets requested a review from a team as a code owner September 16, 2026 18:32

@jzhu13 jzhu13 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. posthog/tracing/_before_span_send.py:49 an intentional drop (return None, the documented way to filter) is counted as a dropped span and surfaces as WARNING Dropping N span(s): before_span_send dropped it every flush_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 at test_pipeline.py:590 is named ..._quietly yet asserts the WARNING. Suggest log.debug and no drops.record for a None return; keep drops.record for failures.

Non-blocking, recommended

  1. posthog/tracing/_before_span_send.py:77 a hook that raises logs the traceback at DEBUG only; at WARNING the user sees the aggregate before_span_send failed with 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. Events before_send uses log.exception. Log the first failure per interval at WARNING with exc_info=True.
  2. posthog/tracing/_before_span_send.py:134 a dict missing kind, name, start_time_ns, or end_time_ns drops 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 without kind drops every span. Treat missing like invalid, or list the required keys in #957's docstrings.
  3. posthog/tracing/_before_span_send.py:105 per-event dropped_attributes_count is shown to the hook and read back (clamped) while span-level counts are hidden and preserved. A hook that rebuilds event dicts with name/timestamp_ns/attributes resets the count to 0; it can also set it to 10^6. Make it read-only like the ids.
  4. posthog/tracing/_limits.py:179 apply_span_limits re-runs truncate_attribute_value on every attribute, including values the hook did not touch and the span already truncated at write time; _hook_view also copies record.attributes that end() already copied. Skip values that are the same object as in the pre-hook record.
  5. 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:2539 comment ("defaults would drop a before_span_send hook and export unscrubbed spans"); an async def hook is reported as returned an unusable record with no hint that async is unsupported; no BeforeSpanSendCallback alias in posthog/types.py for parity with BeforeSendCallback.

Reviewed with Claude Code (Claude Fable 5.1). Behaviors above were reproduced against this branch head.

@turnipdabeets
turnipdabeets force-pushed the traces/08-before-span-send branch from 45d69d2 to c39007a Compare September 17, 2026 01:13
Comment thread posthog/tracing/_limits.py Outdated
@veria-ai

veria-ai Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 2 · PR risk: 0/10

@turnipdabeets

Copy link
Copy Markdown
Contributor Author

Thanks. Fixed in c39007a and c73e035:

  • 1: None filters at debug with no drop count.
  • 2: a raising hook warns with its traceback once per interval.
  • 3: a field the hook leaves out keeps the original's value.
  • 6: async hook is named as unsupported, BeforeSpanSendCallback is in posthog.types.
  • 5: tried and reverted. The hook holds the same nested objects, so a container grown in place keeps its identity and would ship unbounded. Scalars already skip the walk.
  • 4: declining, Node reads the count back the same way and it is clamped.
  • Non-callable fail-closed: still open.

@turnipdabeets
turnipdabeets force-pushed the traces/08-before-span-send branch from c73e035 to c4c6aa3 Compare September 17, 2026 02:08
Comment thread posthog/tracing/_before_span_send.py
@turnipdabeets

Copy link
Copy Markdown
Contributor Author

Follow-up, c4c6aa3: a before_span_send entry that is not callable now raises from the resolver, so the client reports it and leaves tracing off, matching what a raising hook does. Ready for another look.

turnipdabeets and others added 5 commits September 16, 2026 23:36
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.
@turnipdabeets
turnipdabeets force-pushed the traces/08-before-span-send branch from c4c6aa3 to de53e54 Compare September 17, 2026 03:37

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as #956 (comment)

@turnipdabeets
turnipdabeets merged commit 186d204 into main Sep 18, 2026
49 of 77 checks passed
@turnipdabeets
turnipdabeets deleted the traces/08-before-span-send branch September 18, 2026 14:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants