fix(error-tracking): bound the ignoredExceptionTypes cause-chain walk - #687
Draft
cat-ph wants to merge 7 commits into
Draft
fix(error-tracking): bound the ignoredExceptionTypes cause-chain walk#687cat-ph wants to merge 7 commits into
cat-ph wants to merge 7 commits into
Conversation
…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".
Contributor
posthog-android Compliance ReportDate: 2026-08-07 21:46:24 UTC ✅ All Tests Passed!46/46 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
cat-ph
force-pushed
the
cat/bound-ignored-exception-walk
branch
from
August 7, 2026 21:39
4237939 to
0e9971d
Compare
cat-ph
force-pushed
the
cat/java-et-coercer
branch
from
August 7, 2026 22:21
32939f8 to
4c2b895
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
💡 Motivation and Context
The
ignoredExceptionTypesprefilter (findIgnoredTypeInCauseChain, consulted by bothPostHog.captureExceptionandcaptureExceptionStatelessbefore coercion) walkedthrowable.causewith equality-based cycle detection and no depth bound. Two ways that hangs the capture caller: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;equals/hashCodecan 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
Collections.newSetFromMap(IdentityHashMap())instead ofhashSetOf, so user-definedequals/hashCodecan't confuse the walk.MAX_IGNORED_CHECK_CHAIN_LENGTH): every traversal of a user-provided exception graph must be bounded independently, sincegetCause()is user code.Stacked on #669 (base
cat/java-et-coercer; will restack ontomainafter it merges). On that base the coercer's own walk is already identity-based and bounded byMAX_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:getCause()read) with an invocation counter — cause reads stay ≤ the bound;equals/hashCode-colliding throwables are still walked by identity — a distinct-but-equal second link matching an ignored type is found;captureExceptionStatelessstill enqueues the$exceptionevent (truncated chain);make checkFormat,make testJava, andapiDump(no public API diff) all pass.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file