Skip to content

feat(error-tracking): complete exception chain metadata and in-app classification - #669

Draft
cat-ph wants to merge 6 commits into
mainfrom
cat/java-et-coercer
Draft

feat(error-tracking): complete exception chain metadata and in-app classification#669
cat-ph wants to merge 6 commits into
mainfrom
cat/java-et-coercer

Conversation

@cat-ph

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

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

First PR in a 4-PR stack that brings JVM/server error tracking up to parity with the other PostHog SDKs.

This one completes the exception-item model that the shared ThrowableCoercer emits. Today every $exception_list item is serialized in isolation: there are no chain ids (the code still carried a // TODO: exception_id and parent_id), suppressed exceptions are dropped, nothing bounds the payload, and there is no way to force third-party frames out of in_app.

What changed:

  • Chain metadata — each item's mechanism carries exception_id (0-based position); cause items also get parent_id and mechanism type: "chained", while the primary item keeps its own mechanism type. A single-item list carries no ids at all, matching posthog-rs (which only links a chain when there is more than one exception). The ids are emitted on the wire; persisting the relationships needs the server-side mechanism-schema change (see "Review round 1" below).
  • Suppressed exceptionsThrowable.suppressed (one level, bounded) is serialized after the cause chain with mechanism type: "suppressed" and the holder's parent_id.
  • Caps — at most 50 items per $exception_list and 64 frames per stacktrace (keeping the frames nearest the crash). The 50-item cap bounds the traversal itself, not just the output.
  • Synthetic frames — compiler-generated noise (JVM and Kotlin lambdas, Android D8/R8 desugared lambdas and outlines, Spring CGLIB proxies, reflection accessors, dynamic proxies) is flagged method_synthetic: true rather than dropped.
  • inAppExcludes — new PostHogErrorTrackingConfig.inAppExcludes forces frames out of in_app; excludes win over inAppIncludes.

Notes for reviewers:

  • Frame ordering is not part of this PR — crash-last ordering already shipped in feat: send error tracking stack frames in canonical bottom-up order #603, and this PR keeps that code and its comment as-is.
  • The wire additions are additive: no existing key changes meaning, platform: "java" is unchanged, and the new fields are omitted rather than sent as false/null when they do not apply.
  • inAppExcludes is a body property, not a constructor param, so every constructor descriptor of PostHogErrorTrackingConfig — including the Kotlin $default synthetic — is byte-identical to main; the only API-dump change for that class is the added getter.
  • ThrowableCoercer.fromThrowableToPostHogProperties gains a trailing defaulted inAppExcludes param. Kotlin callers are source-compatible; the JVM descriptor changes, which is fine for a @PostHogInternal entry point.

💚 How did you test it?

  • New ThrowableCoercerTest (11 tests) covering single-item id omission, a 3-deep cause chain, suppressed exceptions, both caps, bounded traversal of an endless cause chain, suppressed-fills-leftover-capacity, every synthetic-frame heuristic (with negatives), and excludes-beat-includes.
  • Updated the existing PostHogTest exception assertions for the new mechanism fields (including the single-item case), on top of the ordering assertions from feat: send error tracking stack frames in canonical bottom-up order #603.
  • ./gradlew :posthog:test and :posthog:apiCheck pass; posthog/api/posthog.api regenerated with apiDump and the diff is additive apart from the documented @PostHogInternal defaulted-arg descriptors. spotlessCheck clean.

🔍 Review round 1

Five findings from review, and what happened to each:

  1. Kotlin ABI break on PostHogErrorTrackingConfig (fixed). Adding inAppExcludes as a trailing defaulted constructor param rewrote the Kotlin $default synthetic constructor descriptor, so a consumer compiled against the previous release would hit a NoSuchMethodError even for a bare PostHogErrorTrackingConfig(). It is now a body property; the API dump shows the constructor block restored byte-for-byte with only an additive getter. Call sites are unchanged — the list is mutated through the property either way.
  2. The 50-item cap did not bound the traversal (fixed). The coercer walked the entire cause chain plus every suppressed set into intermediate lists and sliced afterwards. It now follows cause only while capacity remains and then lets suppressed exceptions fill the remainder — same order, same output, no unbounded intermediate collections. Covered by a test whose cause chain mints a fresh throwable on every read (so the identity guard cannot stop it) and which asserts both the item count and the number of cause reads.
  3. Wrong wire field for synthetic frames (fixed). Frame-level synthetic means "the SDK constructed this frame" server-side, which is not what the heuristics detect. Java frames have a dedicated method_synthetic field, so that is what is emitted now (still omitted when false), with a regression assertion that the common synthetic frame field is never sent.
  4. inAppExcludes is defeated by ProGuard/R8 and server-side reclassification (documented, not solvable here). Excludes match runtime class names, which are the obfuscated ones on minified builds, and PostHog re-derives in_app after deobfuscation regardless of what the SDK sent. This cannot be fixed SDK-side; the KDoc on both inAppIncludes and inAppExcludes and the changeset now say so plainly. A deobfuscation-aware in-app rule needs a server-side contract and is a follow-up.
  5. exception_id/parent_id are dropped on ingestion today (kept, claim corrected). The server's mechanism struct does not model the ids yet, so the chain relationships do not survive ingestion. We still emit them: the wire shape is correct, it matches posthog-rs, and it is forward-compatible. A server-side mechanism-schema change is in flight; until it lands, the changeset and the code comment say the metadata is emitted on the wire but the relationships are not persisted.

A follow-up review round also caught that the synthetic-frame heuristics missed Android D8/R8 output — the SDK's primary runtime. Modern 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. Both are now matched, along with D8/R8 outlined methods, with regression cases for each.

📝 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
  • Added the "release" label to the PR to indicate we're publishing new versions for the affected packages

🔗 Stacked PR

Position 1 of 4. Base: main.

  1. this PR — core exception chain metadata + in-app classification
  2. cat/java-et-server-config — server error-tracking config and captureException options
  3. cat/java-et-uncaught — opt-in server uncaught-exception capture
  4. cat/java-et-logback — new posthog-server-logback appender module

Please review and merge in stack order; each PR targets the previous branch.

…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.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-08-03 23:10:53 UTC
Duration: 118453ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 401ms
Format Validation.Event Has Uuid 30ms
Format Validation.Event Has Lib Properties 33ms
Format Validation.Distinct Id Is String 28ms
Format Validation.Token Is Present 25ms
Format Validation.Custom Properties Preserved 25ms
Format Validation.Event Has Timestamp 27ms
Retry Behavior.Retries On 503 7030ms
Retry Behavior.Does Not Retry On 400 4025ms
Retry Behavior.Does Not Retry On 401 4025ms
Retry Behavior.Respects Retry After Header 7027ms
Retry Behavior.Implements Backoff 17029ms
Retry Behavior.Retries On 500 7015ms
Retry Behavior.Retries On 502 7019ms
Retry Behavior.Retries On 504 7018ms
Retry Behavior.Max Retries Respected 17034ms
Deduplication.Generates Unique Uuids 37ms
Deduplication.Preserves Uuid On Retry 7012ms
Deduplication.Preserves Uuid And Timestamp On Retry 12020ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7020ms
Deduplication.No Duplicate Events In Batch 38ms
Deduplication.Different Events Have Different Uuids 23ms
Compression.Sends Gzip When Enabled 18ms
Batch Format.Uses Proper Batch Structure 17ms
Batch Format.Flush With No Events Sends Nothing 11ms
Batch Format.Multiple Events Batched Together 32ms
Error Handling.Does Not Retry On 403 4022ms
Error Handling.Does Not Retry On 413 4021ms
Error Handling.Retries On 408 5027ms

Feature_Flags Tests

17/17 tests passed

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

cat-ph added 5 commits August 4, 2026 01:35
…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".
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