Skip to content

feat(server): opt-in uncaught exception capture - #671

Merged
cat-ph merged 23 commits into
mainfrom
cat/java-et-uncaught
Aug 31, 2026
Merged

feat(server): opt-in uncaught exception capture#671
cat-ph merged 23 commits into
mainfrom
cat/java-et-uncaught

Conversation

@cat-ph

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

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Third PR in the 4-PR JVM error-tracking stack: opt-in capture of uncaught JVM exceptions for the server SDK.

  • New PostHogConfig.captureUncaughtExceptions (also on the Builder), default off. When enabled, PostHog.setup installs a global Thread.defaultUncaughtExceptionHandler that captures the crashing exception as a fatal, unhandled $exception event (mechanism UncaughtExceptionHandler), flushes, then delegates to the previously registered handler. close() removes it again.
  • Unlike the Android SDK, this is gated purely on the local flag: the server SDK never fetches remote config, so the remote autocapture toggle can never fire. To support that, the shared PostHogErrorTrackingAutoCaptureIntegration takes an optional caller-supplied enabledGate instead of always using the built-in gate (local errorTrackingConfig.autoCapture + remote config as a kill-switch, i.e. the behavior from Bug: error tracking autocapture misses crashes on first launch before remote config resolves #648 is preserved for Android).
  • The handler now delivers captures through an internal CaptureTarget seam (installWith), because the core PostHogInterface and the stateless server client share no capture supertype.
  • With no previous default handler present, the handler reproduces the JVM's own Exception in thread "..." stderr output, so opting into capture never hides a crash from stderr log collection.
  • New internal PostHogCapturedThrowables guard: a weak, identity-keyed, process-wide set letting independent capture paths avoid double-reporting the same Throwable instance. It is deliberately directional — the uncaught handler only marks (a crash is always captured as the authoritative fatal/unhandled record, even if the same instance was logged first), while log-mirror paths (the appender in PR 4) consult it and skip instances already reported. It never keeps a throwable or its stack alive.

Delivery caveat documented in the KDoc and changeset: flush() drains the queue synchronously on the crashing thread, but the preceding capture enqueues asynchronously, so under an immediate hard exit the final exception is best-effort — the same guarantee the Android SDK gives. Services that care should keep flushAt low.

💚 How did you test it?

  • 15 new tests: 7 in the core PostHogErrorTrackingAutoCaptureIntegrationTest (local-only gate installs/refuses, CaptureTarget capture+flush, capture-not-suppressed-by-dedup, marking for later log mirrors, JVM stderr fallback, non-owning instance cannot tear down the installed handler), 2 in PostHogCapturedThrowablesTest (identity keying, value-equal throwables both captured), and 6 in the new server PostHogUncaughtExceptionTest (off by default, install+chain+restore on close, fatal/unhandled/mechanism assertions over a real /batch request, repeated setup keeps ownership, second client does not stack a handler, works with no remote config).
  • ./gradlew :posthog:test :posthog-server:test pass (PostHogErrorTrackingAutoCaptureIntegrationTest 33 tests, PostHogCapturedThrowablesTest 2, PostHogUncaughtExceptionTest 6), plus :posthog:apiCheck / :posthog-server:apiCheck and spotlessCheck. API dumps regenerated; additive only.

📝 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 3 of 4. Base: cat/java-et-server-config (PR #670).

  1. PR feat(error-tracking): complete exception chain metadata and in-app classification #669 — core exception chain metadata + in-app classification
  2. PR feat(server): expose in-app frame config and captureException options #670 — server error-tracking config and captureException options
  3. this PR — opt-in server uncaught-exception capture
  4. cat/java-et-logback — new posthog-server-logback appender module

@github-actions

Copy link
Copy Markdown
Contributor

This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the stale label – otherwise this will be closed in another week.

@github-actions github-actions Bot added the stale label Aug 17, 2026
@cat-ph
cat-ph force-pushed the cat/java-et-uncaught branch from b276e0d to 899946e Compare August 18, 2026 15:09
@cat-ph
cat-ph force-pushed the cat/java-et-server-config branch from d6d6b47 to 709788c Compare August 18, 2026 15:09
@posthog

posthog Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 2 must fix, 1 should fix, 0 consider.

Published 3 findings (view the review).

@posthog

posthog Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

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

ReviewHog Report

Found 2 must fix, 1 should fix.

Comment thread posthog-server/src/main/java/com/posthog/server/PostHog.kt Outdated
Comment thread posthog-server/src/main/java/com/posthog/server/PostHog.kt Outdated
@github-actions github-actions Bot removed the stale label Aug 19, 2026
Base automatically changed from cat/java-et-server-config to main August 20, 2026 15:44
cat-ph and others added 2 commits August 20, 2026 19:55
Adds captureUncaughtExceptions (default false) to the server PostHogConfig.
When enabled, the core PostHogErrorTrackingAutoCaptureIntegration installs a
Thread.defaultUncaughtExceptionHandler that captures the throwable as a fatal,
unhandled $exception (mechanism UncaughtExceptionHandler), flushes, then
delegates to the previously installed handler.

Core changes (all additive; Android behavior and the released
install(PostHogInterface) path unchanged):
- Gate strategy seam on the integration so the server can install with a
  local-only gate (no remote config, which the server SDK never fetches);
  Android keeps the remote errorTracking.autocaptureExceptions gate.
- Captures flow through an internal CaptureTarget seam so the server's
  stateless client can drive the integration.
- Handler-install ownership is tracked per integration instance, so closing a
  second opted-in client (whose install was a process-wide no-op) does not
  tear down the handler a still-open first client owns.
- New @PostHogInternal PostHogCapturedThrowables identity marker (weak,
  ReferenceQueue-pruned). The guard is directional: log mirrors consult it,
  the uncaught handler only marks — a crash is always captured as the
  authoritative fatal/unhandled record even if the same instance was logged
  first (logger.error(..., e); throw e), and marking keeps post-crash log
  mirrors from re-reporting it.
- Repeated setup() cannot replace the owning integration with a non-owning
  one, which would leave the global handler installed after close().
- With no previous default handler to chain to, the handler reproduces the
  JVM's built-in stderr crash output so enabling capture never hides crashes
  from stderr log collection.
- Server config KDoc documents the flushAt implication for the crash path.
The server PostHog.setup/close overrides did their integration lifecycle
work outside setupLock. Two concurrent setup() calls could both read
alreadySetUp as false, letting a rejected call install an uncaught-exception
handler bound to a config the base discarded; and a close() racing setup()
could read uncaughtExceptionIntegration before it was assigned, leaking the
process-wide handler after the client closed.

Wrap both the setup and close bodies in synchronized(setupLock) — the same
reentrant monitor the base uses and the core client installs its integrations
under — so the enabled-transition check + handler install are atomic with the
base's setup, and the field is only ever touched under the lock. No behavior
change for the single-client-per-process path.

Paths: posthog-server/src/main/java/com/posthog/server/PostHog.kt

Generated-By: PostHog Desktop
Task-Id: 468ded4c-ba2e-440c-8096-5d78489afcdc
@cat-ph
cat-ph force-pushed the cat/java-et-uncaught branch from 598f163 to 06affef Compare August 20, 2026 16:59
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

posthog-android Compliance Report

Date: 2026-08-25 17:42:38 UTC
Duration: 118283ms

✅ 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 26ms
Format Validation.Event Has Lib Properties 28ms
Format Validation.Distinct Id Is String 24ms
Format Validation.Token Is Present 23ms
Format Validation.Custom Properties Preserved 25ms
Format Validation.Event Has Timestamp 25ms
Retry Behavior.Retries On 503 7032ms
Retry Behavior.Does Not Retry On 400 4022ms
Retry Behavior.Does Not Retry On 401 4024ms
Retry Behavior.Respects Retry After Header 7024ms
Retry Behavior.Implements Backoff 17036ms
Retry Behavior.Retries On 500 7014ms
Retry Behavior.Retries On 502 7020ms
Retry Behavior.Retries On 504 7020ms
Retry Behavior.Max Retries Respected 17022ms
Deduplication.Generates Unique Uuids 39ms
Deduplication.Preserves Uuid On Retry 7015ms
Deduplication.Preserves Uuid And Timestamp On Retry 12026ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7018ms
Deduplication.No Duplicate Events In Batch 35ms
Deduplication.Different Events Have Different Uuids 22ms
Compression.Sends Gzip When Enabled 19ms
Batch Format.Uses Proper Batch Structure 17ms
Batch Format.Flush With No Events Sends Nothing 12ms
Batch Format.Multiple Events Batched Together 33ms
Error Handling.Does Not Retry On 403 4020ms
Error Handling.Does Not Retry On 413 4019ms
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 31ms
Request Payload.Flags Request Uses V2 Query Param 19ms
Request Payload.Flags Request Hits Flags Path Not Decide 21ms
Request Payload.Flags Request Omits Authorization Header 23ms
Request Payload.Token In Flags Body Matches Init 22ms
Request Payload.Groups Round Trip 36ms
Request Payload.Groups Default To Empty Object 24ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 19ms
Request Payload.Disable Geoip Omitted Defaults To False 17ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 17ms
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 21ms
Retry Behavior.Retries Flags On 502 324ms
Retry Behavior.Retries Flags On 504 322ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 20ms

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

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
posthog-server/src/main/java/com/posthog/server/internal/PostHogMemoryQueue.kt:107-113
**Bounded flush misses crash event**

When at least `maxBatchSize` older events are queued, this single barrier flush sends only the oldest bounded batch and returns while the newly appended fatal event remains queued, causing JVM termination to discard the crash without a network attempt.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(server): flush the crash capture beh..." | Re-trigger Greptile

cat-ph added 16 commits August 24, 2026 18:21
If capture or flush throws inside uncaughtException, the failure used to
escape the handler, so the previous default handler (the app's own crash
handling) and the stderr fallback never ran — enabling PostHog could replace
the application's crash handling. Catch capture-path failures, log them, and
always fall through to delegation.
…path

Key the crash path off the same fatal-record marker the core PostHogQueue
uses (PostHogEvent.isFatalExceptionEvent) instead of a client-side downcast
to flushBlocking: a fatal $exception event's enqueue and send now run as one
ordered task on the queue executor, draining batch by batch (ignoring
flushAt) until the queue is empty or the bounded timeout is spent, while the
crashing thread waits at most FATAL_FLUSH_TIMEOUT_MS.

This moves the crash-delivery logic out of PostHog.kt (the capture target now
just flushes), narrows the scheduling catch to RejectedExecutionException,
and fixes the bounded-batch gap where a backlog of maxBatchSize or more
older events left the crash event stranded behind a single capped batch.
PostHogCapturedThrowables has no consumer in this PR — the consulting side is
the posthog-server-logback appender's log-mirror dedup. Move the guard, its
marking call, its tests and its changeset to that stacked PR so the guard
lands together with its consumer.
…he capture hook

Per the sdk-specs exception-event-metadata spec, mechanism.type names the
semantic capture-boundary category — the canonical one for an uncaught
handler is onuncaughtexception, not the noncanonical UncaughtExceptionHandler
— and the concrete integration hook belongs in event-level $exception_source.
The server SDK now stamps jvm.uncaught_exception_handler on its uncaught
captures, following the lowercase <technology>.<stable_hook> convention.
…fatal

On the JVM an uncaught exception kills only its thread; the process continues
unless the crashing thread is main. Per the sdk-specs boundary table, fatal is
reserved for boundaries expected to terminate the process, so the integration
now takes a layer-supplied per-thread fatal policy: the server SDK marks a
main-thread crash fatal (blocking bounded delivery) and any other thread's
error (regular async delivery, handled=false either way). Android keeps the
always-fatal default, which is correct there — any uncaught exception kills
the app.
The process-wide handler is first-owner-wins and does not transfer when the
owning client closes; state that in the public captureUncaughtExceptions
documentation instead of only in an implementation comment.
The memory queue created its Timer non-daemon, unlike the core PostHogQueue's
Timer(true) and the SDK's daemon executor threads, so any set-up client kept
the JVM alive after main finished — or crashed — until close() was called.
Found by the new forked-JVM crash tests: the main-crash fixture never exited.
The in-process suite invokes the handler directly, which cannot prove real
process exit, worker-thread survival, or the stderr the JVM actually emits.
Add a CrashFixture main class launched via ProcessBuilder: a main-thread
crash exits 1 with the default crash banner on stderr and delivers a fatal
event first; a worker-thread crash leaves the process running (exit 0) and
delivers an error-level event.
A periodic-timer flush racing the crash made the fatal drain a silent no-op:
the drain saw the isFlushing flag held, made no progress, and returned with
the crash event still queued. Poll the flag within the bounded budget instead
of bailing; a failed send (batch requeued, no progress) still exits — one
straight-line attempt per batch, never a retry loop.
A backlog batch failing with a retriable error is requeued and stops the
drain (one straight-line attempt per batch), which consumed the whole crash
budget before the appended fatal event ever reached the wire. Front-insert
the fatal event instead, so the first — possibly only — wire attempt carries
it; batch order does not matter to ingestion. Found by codex review.
The capture target's post-capture sweep called the public flush(), which runs
an HTTP batch inline on the crashing thread with no timeout — e.g. retrying a
batch a 5xx had just requeued — stalling crash delegation past the advertised
bound. The fatal path already drains the queue within its budget, so the
sweep is now a documented no-op; worker-thread (non-fatal) captures leave the
process alive for the periodic flush. Also hardened the subprocess harness
(stream drains on their own threads + destroyForcibly on timeout) and
documented the fatal-policy approximation limits. Found by codex review.
Front-inserting the fatal event broke the head-is-oldest eviction invariant:
in a process that survived a failed crash-send, ordinary captures reaching
maxQueueSize evicted the deque head — the crash event awaiting retry.
Capacity trimming now evicts the oldest non-fatal event (O(1) in the common
case). Found by codex review.
The fatal-eviction preference returned no victim when every queued event was
fatal, letting repeated failed fatal submissions in a surviving process grow
the deque past the cap. Evict the oldest event in that case — the cap wins
over fatal priority. Found by codex review.
Two corner cases in the all-fatal-queue fallback: ordinary traffic could
displace a parked crash report (the incoming ordinary event is now dropped
instead), and fatal-on-fatal trimming removed the head — the NEWEST record,
since fatal events are front-inserted — instead of the oldest at the tail.
maxQueueSize stays a hard bound throughout. Found by codex review.
A concurrent failed flush requeues its batch at the head, which can reorder
parked fatal events; which of several parked crash reports gets trimmed at
capacity is deliberately not age-tracked. Comment-only.
removedEvent is null-guarded right above; K2 smart-casts the local captured
by the inline synchronized block, so the safe-call was a useless null check.

@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-server/src/main/java/com/posthog/server/PostHog.kt Outdated
Comment thread .changeset/core-uncaught-gate.md Outdated
@marandaneto
marandaneto requested a review from a team August 25, 2026 09:37
Comment thread .changeset/core-uncaught-gate.md Outdated
Comment thread .changeset/server-uncaught-exceptions.md Outdated

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

approving to unblock, still a few comments left

cat-ph added 2 commits August 25, 2026 20:30
Thread.setDefaultUncaughtExceptionHandler can throw (SecurityException under
a SecurityManager), and the process-wide install flag was set before that
call — a denied install leaked the flag, permanently blocking any later
installation, and could leave a half-initialized client. The ownership state
now rolls back before the failure propagates to the caller's per-integration
handling.
…class

The capture target, fatal policy and $exception_source stamping now live in
an internal PostHogUncaughtExceptionCapture helper; PostHog.setup only calls
install(). A denied handler installation logs and leaves the client running
with capture disabled instead of failing setup. Changesets trimmed to
customer-facing one-liners.
@cat-ph
cat-ph requested a review from marandaneto August 25, 2026 19:24
@cat-ph
cat-ph merged commit 7e6bbc5 into main Aug 31, 2026
17 checks passed
@cat-ph
cat-ph deleted the cat/java-et-uncaught branch August 31, 2026 17:57
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.

4 participants