Skip to content

fix(error-tracking): bound the ignoredExceptionTypes cause-chain walk - #687

Draft
cat-ph wants to merge 7 commits into
cat/java-et-coercerfrom
cat/bound-ignored-exception-walk
Draft

fix(error-tracking): bound the ignoredExceptionTypes cause-chain walk#687
cat-ph wants to merge 7 commits into
cat/java-et-coercerfrom
cat/bound-ignored-exception-walk

Conversation

@cat-ph

@cat-ph cat-ph commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

The ignoredExceptionTypes prefilter (findIgnoredTypeInCauseChain, consulted by both PostHog.captureException and captureExceptionStateless before coercion) walked throwable.cause with equality-based cycle detection and no depth bound. Two ways that hangs the capture caller:

  • a throwable whose getCause() returns a fresh throwable on every call defeats any visited-set cycle detection — the walk loops forever and allocates unboundedly, worst on synchronous crash paths;
  • a throwable overriding equals/hashCode can make the hash-set report a false cycle on genuinely distinct links (early stop, wrong answer) or fail to detect a real one.

(Shipped in #608, spotted during review of #669.)

Fix semantics

  • Identity-based cycle detection: Collections.newSetFromMap(IdentityHashMap()) instead of hashSetOf, so user-defined equals/hashCode can't confuse the walk.
  • Hard bound of 50 links (MAX_IGNORED_CHECK_CHAIN_LENGTH): every traversal of a user-provided exception graph must be bounded independently, since getCause() is user code.
  • Proceed on limit: hitting the bound without a match returns "not ignored", so the capture proceeds rather than dropping the event.

Stacked on #669 (base cat/java-et-coercer; will restack onto main after it merges). On that base the coercer's own walk is already identity-based and bounded by MAX_EXCEPTION_LIST_SIZE = 50, so this closes the last unbounded traversal of user-provided exception graphs on the capture path. The bound here mirrors that cap; unifying both walks into one shared bounded helper is left as a follow-up so this stays a surgical fix.

💚 How did you test it?

New unit tests in PostHogStatelessTest:

  • regenerating-cause throwable (fresh object per getCause() read) with an invocation counter — cause reads stay ≤ the bound;
  • self-cycle and two-object mutual cycle terminate;
  • equals/hashCode-colliding throwables are still walked by identity — a distinct-but-equal second link matching an ignored type is found;
  • match at the root and mid-chain still short-circuits;
  • ignored type parked beyond the bound → prefilter returns no match AND captureExceptionStateless still enqueues the $exception event (truncated chain);
  • existing test: a matching ignored type still skips capture.

make checkFormat, make testJava, and apiDump (no public API diff) all pass.

📝 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 pnpm changeset to generate a changeset file

cat-ph added 6 commits August 5, 2026 14:22
…assification

Fills in the exception-item model the shared ThrowableCoercer emits:

- Mechanisms carry exception_id (0-based position in $exception_list); cause
  items get parent_id and mechanism type "chained". A single-item list carries
  no ids at all, matching the other SDKs.
- Suppressed exceptions (Throwable.suppressed, one level) are serialized after
  the cause chain with mechanism type "suppressed" and their holder's parent_id.
- Caps: 50 items per $exception_list (keeping the primary and nearest causes)
  and 64 frames per stacktrace (keeping the frames nearest the crash).
- JVM-synthesized frames (lambdas, Spring CGLIB proxies, reflection accessors,
  dynamic proxies) are flagged synthetic: true instead of being dropped.
- New PostHogErrorTrackingConfig.inAppExcludes forces frames out of in_app;
  excludes win over inAppIncludes.

All key names and platform: "java" are unchanged, so the additions are
backwards compatible on the wire.
…stable

Appending `inAppExcludes` as a trailing defaulted constructor param rewrote the
Kotlin `$default` synthetic constructor descriptor, so a consumer compiled
against the previous release hit a NoSuchMethodError even for a bare
`PostHogErrorTrackingConfig()`. Declare it as a body property instead, which
restores every constructor descriptor byte-for-byte and leaves only an additive
getter in the API dump. Call sites are unchanged: the list is mutated through
the property either way.

Also document the matching caveats on both in-app lists: prefixes are compared
against runtime class names before symbolication, so on minified (ProGuard/R8)
builds they generally will not match, and PostHog re-derives `in_app`
server-side after deobfuscation. Making excludes survive deobfuscation needs a
server-side in-app contract (follow-up).
Frame-level `synthetic` is the common field meaning "the SDK constructed this
frame", which is not what the lambda/CGLIB/reflection/proxy heuristics detect.
Java frames have a dedicated `method_synthetic` field for "the compiler
generated this method", so emit that instead (still omitted when false). Adds a
regression assertion that the common `synthetic` frame field is never emitted.
The 50-item cap only trimmed the output: the coercer walked the whole cause
chain plus every suppressed set into intermediate lists and sliced afterwards,
so the cap did not bound the work at all. Follow `cause` only while there is
capacity left, then let suppressed exceptions fill the remainder — same
deterministic order and same output, no unbounded intermediate collections.

The identity-based circular guard cannot stop a chain whose `cause` returns a
fresh instance on every read, so the walk bound is what makes that terminate;
covered by a test that asserts both the item count and the number of `cause`
reads.
…in-app

`exception_id`/`parent_id` are emitted on the wire, but PostHog's ingestion
drops them today — its mechanism schema does not model the ids yet, so the chain
relationships are not persisted until that server-side change (in flight) lands.
Say so in the changeset and next to the code that emits them instead of implying
end-to-end support.

Also record the in-app matching caveat (runtime class names, ProGuard/R8
obfuscation, server-side reclassification after deobfuscation) and the fact that
the item cap now bounds the traversal.
… synthetic

The heuristics only matched javac's `lambda$...` methods and the `$$Lambda`
class marker, so on the Android runtime — the SDK's primary target — modern D8/R8
output slipped through: desugared lambdas are named `Foo$$ExternalSyntheticLambda0`
(only the legacy `-$$Lambda$Foo$hash` form contained `$$Lambda`) and Kotlin's
invokedynamic lambda bodies are named `onCreate$lambda$3`. Match the D8/R8
`$$ExternalSynthetic`/`$$InternalSynthetic` markers (which also cover outlined
methods) and the Kotlin `$lambda$` method marker, with regression cases for each
plus a negative for class names that merely contain "Synthetic".
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-08-07 21:46:24 UTC
Duration: 118450ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 378ms
Format Validation.Event Has Uuid 36ms
Format Validation.Event Has Lib Properties 29ms
Format Validation.Distinct Id Is String 29ms
Format Validation.Token Is Present 25ms
Format Validation.Custom Properties Preserved 29ms
Format Validation.Event Has Timestamp 26ms
Retry Behavior.Retries On 503 7026ms
Retry Behavior.Does Not Retry On 400 4024ms
Retry Behavior.Does Not Retry On 401 4026ms
Retry Behavior.Respects Retry After Header 7024ms
Retry Behavior.Implements Backoff 17034ms
Retry Behavior.Retries On 500 7019ms
Retry Behavior.Retries On 502 7019ms
Retry Behavior.Retries On 504 7020ms
Retry Behavior.Max Retries Respected 17035ms
Deduplication.Generates Unique Uuids 38ms
Deduplication.Preserves Uuid On Retry 7017ms
Deduplication.Preserves Uuid And Timestamp On Retry 12031ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7018ms
Deduplication.No Duplicate Events In Batch 38ms
Deduplication.Different Events Have Different Uuids 26ms
Compression.Sends Gzip When Enabled 24ms
Batch Format.Uses Proper Batch Structure 22ms
Batch Format.Flush With No Events Sends Nothing 15ms
Batch Format.Multiple Events Batched Together 40ms
Error Handling.Does Not Retry On 403 4023ms
Error Handling.Does Not Retry On 413 4021ms
Error Handling.Retries On 408 5026ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 35ms
Request Payload.Flags Request Uses V2 Query Param 24ms
Request Payload.Flags Request Hits Flags Path Not Decide 29ms
Request Payload.Flags Request Omits Authorization Header 24ms
Request Payload.Token In Flags Body Matches Init 44ms
Request Payload.Groups Round Trip 33ms
Request Payload.Groups Default To Empty Object 26ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 20ms
Request Payload.Disable Geoip Omitted Defaults To False 22ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 25ms
Request Lifecycle.No Flags Request On Init Alone 10ms
Request Lifecycle.No Flags Request On Normal Capture 24ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 45ms
Request Lifecycle.Mock Response Value Is Returned To Caller 27ms
Retry Behavior.Retries Flags On 502 328ms
Retry Behavior.Retries Flags On 504 325ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 23ms

@cat-ph
cat-ph force-pushed the cat/bound-ignored-exception-walk branch from 4237939 to 0e9971d Compare August 7, 2026 21:39
@cat-ph
cat-ph changed the base branch from main to cat/java-et-coercer August 7, 2026 21:39
@cat-ph
cat-ph force-pushed the cat/java-et-coercer branch from 32939f8 to 4c2b895 Compare August 7, 2026 22:21
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.

1 participant