Skip to content

fix(errors): order and mark the stack of a never-raised exception - #1181

Open
luantaraschi wants to merge 4 commits into
PostHog:mainfrom
luantaraschi:fix/synthetic-stack-trace
Open

luantaraschi wants to merge 4 commits into
PostHog:mainfrom
luantaraschi:fix/synthetic-stack-trace

Conversation

@luantaraschi

@luantaraschi luantaraschi commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

When capture_exception gets an exception that was never raised, construct_artificial_traceback builds a traceback from the current stack. That traceback has three problems:

  1. The frames are reversed and then linked by passing each new traceback as tb_next of the next one, so the head of the chain is the innermost frame. frames[0] ends up being the innermost frame and the last one is <module>. A raised exception gives the opposite order, and the capture-exception spec in PostHog/sdk-specs ("Stack frame ordering") asks for outermost first.
  2. The stack starts at sys._getframe(0), so the innermost frames are construct_artificial_traceback, exc_info_from_error and capture_exception. The event then points at SDK code as the crash site instead of the application line that called the SDK. posthog-js drops its own frame from a synthetic stack (skipFirstLines).
  3. The builder always defaults mechanism.synthetic to false, with a comment saying the SDK never replaces a stack with a generated one. The exception-event-metadata spec says an SDK-generated current stack is synthetic: true.

For example, with report() calling capture_exception(ValueError("never raised")) from handler() and entry_point(), main sends:

frames: construct_artificial_traceback, exc_info_from_error, capture_exception, report, handler, entry_point, <module>
mechanism: {"exception_id": 0, "type": "generic", "handled": true, "synthetic": false}

With this change it sends <module>, entry_point, handler, report and "synthetic": true.

The fix links the traceback from the innermost frame outwards, skips the innermost frames that belong to the SDK's own package (posthog, or posthoganalytics in the mirror), and keeps the traceback it built in a small holder on the exception. The holder reduces to None when the exception is pickled or deep copied, because a traceback cannot be pickled. The builder sets synthetic to true only when the traceback it serializes is the one in the holder. A raised exception and an explicit synthetic in the mechanism behave as before.

The reversal goes back to #266, which built the full stack this way on purpose. I think the spec and the order used for raised exceptions now point the other way, but I'm happy to adjust if there is a reason to keep it.

💚 How did you test it?

  • Seven new tests in test_exception_capture.py. They call the SDK from functions compiled into a module named app, so the frames look like application code. Four of them (order, SDK frames, synthetic, and the same checks through Client.capture_exception) fail on main and pass with the fix. A control checks that a raised exception passed by object stays synthetic: false, and passes on both. Two more cover the first review: the exception still pickles after capture, and raising it after capture reports synthetic: false. Three more cover the second one: raising a pickled or copied exception reports synthetic: false, a pickled exception loads without the SDK installed, and the stack ends at the caller with the module loaded under the mirror's name.
  • Full suite in a python:3.12-slim container: 6106 passed, 63 skipped, no failures. test_exception_capture.py also passes with the lock on 3.10, 3.11 and 3.13. The lock didn't install on python:3.14-slim (zstandard 0.23), so on 3.14 I ran that file without it: only test_excepthook failed, with and without this change.
  • ruff check and ruff format --check (0.12.2), the mypy baseline and python -W error -c "import posthog" are clean.

📝 Checklist

  • All commits are signed (see Commit signing)
  • 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)

Claude Code with Claude Opus 5.5 found this while checking posthog-python against the error tracking specs in PostHog/sdk-specs, reproduced it, and wrote the fix and the tests. Luan directed the work.

An exception passed to capture_exception without being raised gets a stack built by the SDK. That stack came out innermost first, started with the SDK's own capture frames, and was sent with mechanism.synthetic false. Build it outermost first like a raised traceback, leave out the SDK frames, and mark it synthetic.
@luantaraschi
luantaraschi requested a review from a team as a code owner November 4, 2026 15:42

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

Automated advisory code review.

Comment thread posthog/exception_utils.py Resolved
luantaraschi and others added 2 commits November 5, 2026 13:10
Store the id of the synthesized traceback instead of the traceback, which cannot be pickled.
continue
frames.append(frame)

frames.reverse()

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.

i think this is intentional

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yeah I think so too #266

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.

#898 seems more relevant

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

#266 did reverse it on purpose. Going by the screenshots there, the UI drew frames in the order they arrived, and the reversal put capture_exception at the top. The current spec rules that reason out: frames[0] has to be the outermost frame and "SDKs never reorder frames for display reasons". Raised exceptions already arrive outermost first, so on main the two kinds come in opposite orders.

In #898 I only found the synthetic default, it doesn't touch how the traceback is built. Is there something else in it I should look at? I haven't checked how the UI renders these today. If you'd rather keep the reversal I'll take that part out and leave the SDK frames and synthetic.

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.

yeah we just need to confirm with @PostHog/team-error-tracking before we merge and break but i think its fine

@marandaneto
marandaneto requested a review from a team November 6, 2026 08:10
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

SDK performance: posthog-python

Benchmarks completed. No changes flagged for review.

Flags require at least a 5% change in the median and all six pairs agreeing on direction. This is a noise filter, not proof of a performance change or a CI gate.

View all results
Test Measurement Before PR With PR Change Assessment
Capture and deliver events (32-byte payload, burst)
Shard: capture
Managed heap after settling — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​; head:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Managed heap after settling — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​; head:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​
Capture and deliver events (32-byte payload, steady)
Shard: capture
Managed heap after settling — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​; head:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Managed heap after settling — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​; head:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​
Queue an event (32-byte payload)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​; head:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​
Queue an event (32-byte payload)
Shard: capture
Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​; head:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​
Queue an event (4,096-byte payload)
Shard: capture
Allocated memory per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​; head:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​
Queue an event (4,096-byte payload)
Shard: capture
Allocations per operation — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​; head:​ Background preparation has no public completion boundary before flush; see capture.​drain for full-pipeline allocations.​
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Managed heap after settling — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​; head:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​
Refresh 1,000 flag definitions
Shard: flags-memory
Managed heap after settling — — — unavailable; base:​ unsupported; head:​ unsupported; base:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​; head:​ CPython has no uninstrumented total managed-heap byte counter; tracing would distort this RSS pass.​
Capture and deliver events (32-byte payload, burst)
Shard: capture
Allocated memory per operation 113.54 KiB/op 113.54 KiB/op <0.1% more memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Allocations per operation 1,096.6 count/op 1,096.6 count/op <0.1% more Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Time per operation 115.38 µs/op 112.7 µs/op 2.3% less time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Idle process memory (RSS) 36.05 MiB 36.02 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 37.79 MiB 37.78 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Process memory after settling (RSS) 37.79 MiB 37.78 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Process CPU time per operation 116.65 µs/op 113.05 µs/op 3.1% less time Not flagged
Capture and deliver events (32-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 40.07 MiB 40.07 MiB No observed change Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Allocated memory per operation 166.11 KiB/op 166.1 KiB/op <0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Allocations per operation 1,089.64 count/op 1,089.62 count/op <0.1% fewer Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Time per operation 144.63 µs/op 145.71 µs/op 0.7% more time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Idle process memory (RSS) 40.85 MiB 40.79 MiB 0.1% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Observed peak process memory (RSS) 45.95 MiB 45.88 MiB 0.2% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Process memory after settling (RSS) 45.95 MiB 45.88 MiB 0.2% less memory Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Process CPU time per operation 136.71 µs/op 136.58 µs/op <0.1% less time Not flagged
Capture and deliver events (4,096-byte payload, burst)
Shard: capture
Lifetime peak process memory (RSS) 50.7 MiB 50.68 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Allocated memory per operation 117.68 KiB/op 117.68 KiB/op <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Allocations per operation 1,136.6 count/op 1,136.59 count/op <0.1% fewer Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Time per operation 123.76 µs/op 127.57 µs/op 3.1% more time Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Idle process memory (RSS) 35.95 MiB 35.92 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 36.06 MiB 36.04 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Process memory after settling (RSS) 36.06 MiB 36.04 MiB <0.1% less memory Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Process CPU time per operation 122.2 µs/op 126.71 µs/op 3.7% more time Not flagged
Capture and deliver events (32-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 40.07 MiB 40.07 MiB No observed change Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Allocated memory per operation 170.57 KiB/op 170.57 KiB/op <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Allocations per operation 1,129.48 count/op 1,129.51 count/op <0.1% more Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Time per operation 168.34 µs/op 150.77 µs/op 10.4% less time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Idle process memory (RSS) 40.63 MiB 40.66 MiB <0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Observed peak process memory (RSS) 40.63 MiB 40.67 MiB 0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Process memory after settling (RSS) 40.63 MiB 40.67 MiB 0.1% more memory Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Process CPU time per operation 163.83 µs/op 143.94 µs/op 12.1% less time Not flagged
Capture and deliver events (4,096-byte payload, steady)
Shard: capture
Lifetime peak process memory (RSS) 40.93 MiB 40.99 MiB 0.1% more memory Not flagged
Queue an event (32-byte payload)
Shard: capture
Caller-thread CPU time per operation 86.39 µs/op 92.65 µs/op 7.3% more time Not flagged
Queue an event (32-byte payload)
Shard: capture
Time per operation 91.14 µs/op 92.85 µs/op 1.9% more time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Caller-thread CPU time per operation 82.91 µs/op 83.42 µs/op 0.6% more time Not flagged
Queue an event (4,096-byte payload)
Shard: capture
Time per operation 85.24 µs/op 88.81 µs/op 4.2% more time Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Allocated memory per operation 200.98 KiB/op 200.98 KiB/op No observed change Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Allocations per operation 2,339.33 count/op 2,339.33 count/op No observed change Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Time per operation 130.73 µs/op 132.2 µs/op 1.1% more time Not flagged
Evaluate all 10 flags
Shard: flags-scaling
Process CPU time per operation 130.71 µs/op 132.2 µs/op 1.1% more time Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Allocated memory per operation 1.93 MiB/op 1.93 MiB/op No observed change Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Allocations per operation 23,013.56 count/op 23,013.56 count/op No observed change Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Time per operation 1.23 ms/op 1.24 ms/op 0.2% more time Not flagged
Evaluate all 100 flags
Shard: flags-scaling
Process CPU time per operation 1.23 ms/op 1.24 ms/op 0.2% more time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Allocated memory per operation 19.24 MiB/op 19.24 MiB/op No observed change Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Allocations per operation 229,654.94 count/op 229,654.94 count/op No observed change Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Time per operation 12.18 ms/op 12.16 ms/op 0.1% less time Not flagged
Evaluate all 1,000 flags
Shard: flags-scaling
Process CPU time per operation 12.18 ms/op 12.16 ms/op 0.1% less time Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Idle process memory (RSS) 41.6 MiB 42.34 MiB 1.8% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Observed peak process memory (RSS) 41.91 MiB 42.64 MiB 1.7% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Process memory after settling (RSS) 41.91 MiB 42.64 MiB 1.7% more memory Not flagged
Evaluate 1,000 flags under concurrent load
Shard: flags-memory
Lifetime peak process memory (RSS) 42.37 MiB 42.55 MiB 0.4% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Idle process memory (RSS) 42.14 MiB 41.85 MiB 0.7% less memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Observed peak process memory (RSS) 42.19 MiB 42.21 MiB <0.1% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Process memory after settling (RSS) 41.53 MiB 41.88 MiB 0.8% more memory Not flagged
Refresh 1,000 flag definitions
Shard: flags-memory
Lifetime peak process memory (RSS) 42.54 MiB 42.51 MiB <0.1% less memory Not flagged
Evaluate one boolean flag
Shard: flags-single
Allocated memory per operation 15.05 KiB/op 15.05 KiB/op No observed change Not flagged
Evaluate one boolean flag
Shard: flags-single
Allocations per operation 173.76 count/op 173.76 count/op No observed change Not flagged
Evaluate one boolean flag
Shard: flags-single
Time per operation 13.82 µs/op 13.87 µs/op 0.4% more time Not flagged
Evaluate one boolean flag
Shard: flags-single
Process CPU time per operation 13.82 µs/op 13.87 µs/op 0.4% more time Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Allocated memory per operation 23.12 KiB/op 23.12 KiB/op No observed change Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Allocations per operation 270.27 count/op 270.27 count/op No observed change Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Time per operation 21.82 µs/op 21.77 µs/op 0.2% less time Not flagged
Evaluate a cohort-filtered flag
Shard: flags-single
Process CPU time per operation 21.82 µs/op 21.77 µs/op 0.2% less time Not flagged
Evaluate a flag (early match)
Shard: flags-single
Allocated memory per operation 35.71 KiB/op 35.71 KiB/op No observed change Not flagged
Evaluate a flag (early match)
Shard: flags-single
Allocations per operation 387.27 count/op 387.27 count/op No observed change Not flagged
Evaluate a flag (early match)
Shard: flags-single
Time per operation 27.55 µs/op 27.55 µs/op <0.1% less time Not flagged
Evaluate a flag (early match)
Shard: flags-single
Process CPU time per operation 27.55 µs/op 27.55 µs/op <0.1% less time Not flagged
Evaluate a flag (late match)
Shard: flags-single
Allocated memory per operation 53.59 KiB/op 53.59 KiB/op No observed change Not flagged
Evaluate a flag (late match)
Shard: flags-single
Allocations per operation 576.27 count/op 576.27 count/op No observed change Not flagged
Evaluate a flag (late match)
Shard: flags-single
Time per operation 38.64 µs/op 38.31 µs/op 0.9% less time Not flagged
Evaluate a flag (late match)
Shard: flags-single
Process CPU time per operation 38.64 µs/op 38.31 µs/op 0.9% less time Not flagged
Evaluate a flag (no match)
Shard: flags-single
Allocated memory per operation 52.14 KiB/op 52.14 KiB/op No observed change Not flagged
Evaluate a flag (no match)
Shard: flags-single
Allocations per operation 556.77 count/op 556.77 count/op No observed change Not flagged
Evaluate a flag (no match)
Shard: flags-single
Time per operation 34.85 µs/op 34.5 µs/op 1% less time Not flagged
Evaluate a flag (no match)
Shard: flags-single
Process CPU time per operation 34.85 µs/op 34.49 µs/op 1% less time Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Allocated memory per operation 17.84 KiB/op 17.84 KiB/op No observed change Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Allocations per operation 198.27 count/op 198.27 count/op No observed change Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Time per operation 16.42 µs/op 16.62 µs/op 1.2% more time Not flagged
Evaluate a property-filtered flag
Shard: flags-single
Process CPU time per operation 16.42 µs/op 16.62 µs/op 1.2% more time Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Allocated memory per operation 19.23 KiB/op 19.23 KiB/op No observed change Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Allocations per operation 210.77 count/op 210.77 count/op No observed change Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Time per operation 17.37 µs/op 17.53 µs/op 0.9% more time Not flagged
Evaluate a regex-filtered flag
Shard: flags-single
Process CPU time per operation 17.37 µs/op 17.53 µs/op 0.9% more time Not flagged
Evaluate a rollout flag
Shard: flags-single
Allocated memory per operation 14.04 KiB/op 14.04 KiB/op No observed change Not flagged
Evaluate a rollout flag
Shard: flags-single
Allocations per operation 160.76 count/op 160.76 count/op No observed change Not flagged
Evaluate a rollout flag
Shard: flags-single
Time per operation 12.96 µs/op 13.09 µs/op 1% more time Not flagged
Evaluate a rollout flag
Shard: flags-single
Process CPU time per operation 12.96 µs/op 13.09 µs/op 1% more time Not flagged
Evaluate a multivariate flag
Shard: flags-single
Allocated memory per operation 15.21 KiB/op 15.21 KiB/op No observed change Not flagged
Evaluate a multivariate flag
Shard: flags-single
Allocations per operation 177.77 count/op 177.77 count/op No observed change Not flagged
Evaluate a multivariate flag
Shard: flags-single
Time per operation 14.52 µs/op 14.51 µs/op <0.1% less time Not flagged
Evaluate a multivariate flag
Shard: flags-single
Process CPU time per operation 14.52 µs/op 14.51 µs/op <0.1% less time Not flagged

Values are medians of paired measurements. Not flagged does not mean unchanged. Unavailable means a measurement could not be provided, not zero. Estimated allocation measurements retain their instrumentation uncertainty. RSS measures the whole process, not just SDK allocations; observed peaks are sampled checkpoints. Paired ranges in the diagnostics describe variability, not confidence intervals.

Each shard uses its own runner; compare Before PR against With PR within a row, not across shards.

Compared 6b7c0ba3e72b8ebdc3b1f65aedafbb44c7c67256 → 38d0b9cbfddb7398c7dece474072f7fb6744a08b.

Full report and diagnostics

102 metric rows; 102 rows shown in all results. Omitted or shortened data remain in comparison.json, including per-runner environments, resource limits, measurement methods, samples and checkpoints.

@marandaneto

Copy link
Copy Markdown
Member

Checked the changed behavior against PostHog/sdk-specs at ff629e2, using PR head 38d0b9cb in an isolated worktree.

SDK spec requirement PR behavior Result
Stack frame ordering: outermost frame first, innermost last Synthesized stacks follow entry-point → caller order Matches
Stack trace preservation over synthesis Existing native traceback is preserved rather than replaced Matches
Explicit synthetic state: SDK-generated current stack is true; original native stack is false Traceback provenance distinguishes synthesized and native stacks Matches
Independent synthetic state for nested exceptions Each entry uses its own traceback provenance, not the outermost entry’s state Matches
Preserve valid integration mechanism metadata Explicit boolean synthetic overrides are preserved Matches

Validation: 34 tests passed, covering test_exception_capture.py, test_exception_utils.py, and six additional temporary contract checks. The additional checks exercised synthesized stack ordering/state, native traceback preservation, independent nested synthetic state in both directions, and both boolean integration overrides.

Dropping SDK capture frames is compatible with the spec, but is not explicitly required. Keeping captured exceptions pickleable is a Python compatibility requirement rather than an explicit SDK-spec requirement.

No spec divergence found within this PR’s changed behavior; this is not a claim of full SDK conformance.

@ioannisj ioannisj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 An AI agent wrote this on behalf of @ioannisj.

Nothing blocking from me, two edge cases in the synthesized stack handling. The SDK frame filter doesn't match the posthoganalytics mirror, and the id() marker can outlive its traceback and mark a raised stack as synthetic. Both reproduce locally and neither touches the common path.

Comment thread posthog/exception_utils.py Outdated
exception_value["mechanism"].setdefault(
"synthetic",
tb is not None
and getattr(exc_value, _SYNTHESIZED_TRACEBACK_ID_ATTR, None) == id(tb),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 An AI agent wrote this on behalf of @ioannisj.

[suggestion] id(tb) is only unique while that traceback is alive, so I think this can report synthetic: true for a real raised stack.

The marker sits in the instance dict, so pickle and copy.copy carry it to a copy that has no traceback. Once the synthesized traceback is freed and the copy gets raised, the new traceback often lands on the same address. Capture, pickle.loads(pickle.dumps(e)), then raise gave me synthetic: true in 198 of 200 runs on Python 3.14, and about half the time with copy.copy.

Would a small holder object work instead? It keeps a reference to the traceback, gets compared with is, and reduces to an empty holder so pickling still drops it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reproduced. In a loop that frees the traceback, a pickle round trip came back synthetic: true in 199 of 200 on 3.14 and deepcopy in 100 of 200 (copy.copy only 1 of 200 for me). The rate depends on allocation: that loop gave 0 on 3.12, while the pytest version fails on 3.12 and passes on 3.14.

Moved to the holder in 20de5fc, compared with is. One difference: it reduces to None instead of an empty holder, so the class stays out of the pickle and the exception still loads where the SDK isn't installed.

Comment thread posthog/exception_utils.py Outdated
def _is_sdk_frame(frame):
# type: (FrameType) -> bool
module = frame.f_globals.get("__name__") or ""
return module == "posthog" or module.startswith("posthog.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 An AI agent wrote this on behalf of @ioannisj.

[suggestion] I think _is_sdk_frame misses the posthoganalytics mirror. It matches the literal posthog, but the mirror build in the Makefile only rewrites from posthog imports, so there every SDK module is posthoganalytics.* and nothing gets skipped.

I built the mirror with those sed rules and the same never-raised capture still ends at exc_info_from_error and construct_artificial_traceback.

Would deriving the root from the module work, something like __name__.partition(".")[0]? A test with the package renamed would be cheap too, as it stands a regression on the mirror name would still pass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same result here with the Makefile's sed rules. In 20de5fc the package comes from __name__.partition(".")[0], and a new test loads the module as posthoganalytics.exception_utils and checks the stack ends at the caller.

continue
frames.append(frame)

frames.reverse()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yeah I think so too #266

…ackage

An id can be reused once the traceback is freed, so a pickled or copied exception could report a raised stack as synthetic. Keep the traceback in a small holder compared by identity, which reduces to None when the exception is pickled or deep copied.

The SDK frame check only matched the posthog package, so the posthoganalytics mirror kept its capture frames. Take the package name from the module.

This branch has not been deployed

No deployments
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