Skip to content

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

Open
cat-ph wants to merge 8 commits into
mainfrom
cat/java-et-coercer
Open

feat(error-tracking): complete exception chain metadata and in-app classification#669
cat-ph wants to merge 8 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.

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

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-08-07 22:27:34 UTC
Duration: 118342ms

✅ All Tests Passed!

46/46 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 372ms
Format Validation.Event Has Uuid 30ms
Format Validation.Event Has Lib Properties 31ms
Format Validation.Distinct Id Is String 27ms
Format Validation.Token Is Present 22ms
Format Validation.Custom Properties Preserved 25ms
Format Validation.Event Has Timestamp 27ms
Retry Behavior.Retries On 503 7026ms
Retry Behavior.Does Not Retry On 400 4025ms
Retry Behavior.Does Not Retry On 401 4023ms
Retry Behavior.Respects Retry After Header 7026ms
Retry Behavior.Implements Backoff 17033ms
Retry Behavior.Retries On 500 7019ms
Retry Behavior.Retries On 502 7018ms
Retry Behavior.Retries On 504 7020ms
Retry Behavior.Max Retries Respected 17039ms
Deduplication.Generates Unique Uuids 36ms
Deduplication.Preserves Uuid On Retry 7019ms
Deduplication.Preserves Uuid And Timestamp On Retry 12030ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7019ms
Deduplication.No Duplicate Events In Batch 36ms
Deduplication.Different Events Have Different Uuids 23ms
Compression.Sends Gzip When Enabled 18ms
Batch Format.Uses Proper Batch Structure 18ms
Batch Format.Flush With No Events Sends Nothing 11ms
Batch Format.Multiple Events Batched Together 33ms
Error Handling.Does Not Retry On 403 4018ms
Error Handling.Does Not Retry On 413 4020ms
Error Handling.Retries On 408 5028ms

Feature_Flags Tests

17/17 tests passed

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

@posthog

posthog Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Nothing worth raising this time, so here's a calming picture instead:

A happy dog on a sunny path

@cat-ph
cat-ph marked this pull request as ready for review August 6, 2026 22:22
@cat-ph
cat-ph requested a review from a team as a code owner August 6, 2026 22:22
@cat-ph
cat-ph requested review from a team, ablaszkiewicz and hpouillot August 6, 2026 22:22
@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "fix(error-tracking): detect Android D8/R..." | Re-trigger Greptile

Comment on lines +10 to +12
// Do NOT add new options as constructor params: appending even a defaulted param rewrites the
// Kotlin `$default` synthetic constructor descriptor, so already-compiled consumers hit a
// NoSuchMethodError on `PostHogErrorTrackingConfig()`. New options go in the class body.

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.

In a major release, remove constructor defaults and declare every trailing overload explicitly. Then no $default constructor is generated, and future
options can add a new full constructor while retaining all previously shipped overloads. For arbitrary named optional settings, a builder/configuration
block is the better API.

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.

Agreed, and worth writing down. The synthetic $default constructor is exactly the hazard you describe: adding a defaulted parameter changes its descriptor, so every previously compiled call site breaks at runtime — this area has already bitten us twice.

The body-property shape here was the deliberate choice for that reason (properties are additive, no constructor descriptor to break), but it does not solve the general case.

No code change on this PR. Noting the explicit-trailing-overloads / builder-or-configuration-block approach for the next major, where we can drop the constructor defaults outright.

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.

🤖 ^ but true, didn't change anything, did you want me to add a note in the comments here?

cat-ph added 8 commits August 8, 2026 01:09
…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".
@cat-ph
cat-ph force-pushed the cat/java-et-coercer branch from 32939f8 to 4c2b895 Compare August 7, 2026 22:21
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor
⚠️ `posthog-android` is modified but not declared in any changeset

This is informational — the PR is not blocked. Click the triangle above to collapse, or push a fix and this comment will auto-delete.

Modified in this PR but not in any changeset:

  • posthog-android

If this package should ship the change, add it to the changeset frontmatter:

---
"posthog-android": patch
---

Changesets in this PR:

  • posthog — minor

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.

2 participants