Skip to content

Modernize to a first-class Exceptionless client (0.9, toward 1.0) - #92

Open
ejsmith wants to merge 9 commits into
mainfrom
feature/2.0-modernization
Open

ejsmith wants to merge 9 commits into
mainfrom
feature/2.0-modernization

Conversation

@ejsmith

@ejsmith ejsmith commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Implements #91. A ground-up rewrite, released as 0.9 on the way to 1.0, that matches the current Exceptionless server contract and brings the Java client in line with the .NET and JavaScript clients.

What's included

Module Contents
exceptionless-core Zero runtime dependencies, Java 17+, JPMS module com.exceptionless.core. Snake_case wire format, full cause/suppressed chains, request/environment/module info, privacy settings and data exclusions. Server settings (@@log, @@error, @@<type>:<source>, @@DataExclusions, @@IncludePrivateInformation, @@UserAgentBotPatterns). Status-aware queue (429/503/402/401/403/400/404/413 handled like .NET), gzip, optional file persistence, sessions and heartbeats, reference ids and user descriptions. One daemon thread; queued events are flushed on shutdown and uncaught exceptions are reported.
exceptionless-logback, -log4j2, -jul Appenders/handler: logger name → source, MDC/key-values → properties, markers → tags; project @@log settings override the local minimum level.
exceptionless-servlet Jakarta Servlet 6 filter: unhandled errors and 404s with request details; request info added to errors logged during a request.
exceptionless-spring-boot-starter Auto-configuration and exceptionless.* properties, MVC + WebFlux capture, Logback appender wired automatically. Spring Boot 3.5 and 4.1.
exceptionless-bom Version alignment.
exceptionless-e2e-tests Testcontainers suite against exceptionless/exceptionless:8.10.0 (opt-in: -De2e=true).

Also: new README, MIGRATION.md, CONTRIBUTING.md, console and Spring Boot samples, an agent skill, Maven wrapper, Spotless (GJF), JaCoCo, Dependabot, and a single tag-versioned Build workflow (MinVer, like the other Exceptionless builds): v* tags publish to Maven Central, other pushes publish alpha pre-release builds to GitHub Packages. The first release will be v0.9.0-beta.1.

Breaking: new package com.exceptionless, new artifacts (exceptionless-core etc.), Java 17 baseline. See MIGRATION.md.

Testing

  • 501 unit/integration tests pass (./mvnw verify), on JDK 17, 21 and 25.
  • On JDK 21+ the build runs Error Prone and NullAway (JSpecify mode) with warnings as errors.
  • Starter verified on Spring Boot 3.5.16 and 4.1.1 (-Pspring-boot-4), including back-off when Logback, Reactor or the servlet filter are absent.
  • 21/21 e2e tests pass against a real Exceptionless 8.10.0 server, including wire format, stacking, sessions, settings and error responses.
  • Child-JVM test confirms the JVM exits without close() and the shutdown hook still sends queued events.
  • Both samples smoke-tested against a stub server.

Before releasing

  • Add the CENTRAL_USERNAME, CENTRAL_PASSWORD (a Central Portal user token), GPG_PRIVATE_KEY and GPG_PASSPHRASE secrets; none exist yet. exceptionless-client 0.1.0 (2021) was published by hand through the now-retired OSSRH, so confirm the com.exceptionless namespace appears in the Central Portal account. Releases are cut by pushing a v* tag (or creating a GitHub release); the tag name is the version.

Server-side follow-ups (not in this PR)

  1. java.* frames are treated as user code in stacking signatures, so JDK frames (e.g. NumberFormatException.forInputString) become the stack signature.
  2. The "User Namespaces" project setting is saved as config but read from project data, so it has no effect.
  3. $ in nested class names isn't normalized in stacking signatures.

Closes #91

🤖 Generated with Claude Code

Rewrite the client as a multi-module Java 17 library that matches the
current Exceptionless server contract, with integrations for Spring Boot,
Servlet, Logback, Log4j 2 and java.util.logging.

- exceptionless-core: zero runtime dependencies, snake_case wire format,
  full cause/suppressed chains, server settings (@@log, @@error,
  @@DataExclusions, bot patterns), sessions and heartbeats, status-aware
  queue with optional file persistence, shutdown flush and uncaught
  exception capture on a single daemon thread
- logging appenders, servlet filter and Spring Boot 3/4 starter
- BOM, Maven wrapper, Spotless, JaCoCo, Central Portal release workflow
- 368 unit/integration tests plus a Testcontainers e2e suite against
  exceptionless/exceptionless:8.10.0
- new README, MIGRATION.md, CONTRIBUTING.md, samples and agent skill

Closes #91

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T04:35:37.784877Z 926d71c New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f80879f3ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +246 to +247
String prefix = apiKey.length() > 8 ? apiKey.substring(0, 8) : apiKey;
return prefix.replaceAll("[^A-Za-z0-9_-]", "_");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive storage directories from the entire API key

When two API keys share their first eight characters, they resolve to the same queue and state directory. If the same storage root is reused by those clients—such as after switching projects—the second client can load and submit the first project's persisted events with its own credentials, while also inheriting its settings and install ID. Use a collision-resistant hash of the complete API key instead of the truncated prefix.

Useful? React with 👍 / 👎.

Comment on lines +950 to +952
public ExceptionlessConfiguration removePlugin(String name) {
plugins.removeIf(p -> p.getName().equals(name));
return this;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suspend plugins before removing or replacing them

After a plugin has run once, removing it only deletes it from the pipeline and never invokes suspend. Stateful plugins such as HeartbeatPlugin therefore retain their scheduled task and continue issuing heartbeat requests after removePlugin, and replacing one through addPlugin leaves the old instance running as well; the removed instance is also absent from the close-time suspension loop. Suspend started plugins and clear their lifecycle bookkeeping when they are removed.

Useful? React with 👍 / 👎.

ejsmith and others added 4 commits October 19, 2026 17:53
- Models are plain JavaBeans (void setters); Event tags go through add/remove/hasTag
- ModuleInfo is an immutable record; record map components are copied
- SettingsResponse is a sealed interface; SubmissionResponse.notSent factory
- SubmissionClient no longer takes the configuration per call
- EventPlugin.of factory, EventsPostedListener, suspendProcessing(Duration)
- Configuration naming fixes (userIdentity, isReferenceIdsEnabled, getProxy)
- SettingsManager and settings parsing moved to internal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- One InstantSource-based clock across the queue, storage and plugins
- FileQueueStorage keeps an in-memory index instead of listing the directory per enqueue
- Precompiled wildcard exclusions cached by the configuration
- Strict RFC 8259 JsonParser; JsonWriter and DataNormalizer share simple-value rules
- Holder-class lazy init, ClassValue accessor cache, strip() over trim()
- Test cleanups: latches instead of sleeps, parameterized tables, @tempdir

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Add com.exceptionless.logging.LogEventSupport (exported) and use it from the
  Logback, Log4j 2 and JUL integrations: one client resolution/owned-client
  lifecycle, internal-logger/thread filter, recursion guard and event factory;
  only the first failure after start is reported.
- Log4j 2: honour ignoreExceptions, stop(timeout), MapMessage fields.
- Servlet filter: extend HttpFilter, resolve the started default client per
  request, register the request plugin once per client, void setter.
- Starter: @ConditionalOnBooleanProperty, LogLevel/Path properties, Logback
  registration re-attaches after a context reset, WebFlux query params from
  getQueryParams(), class-presence tests, StepVerifier.
- Tests: share FakeServer/TestClients via the core test-jar, Awaitility,
  parameterized level mapping tests.
- Build/CI: requirePluginVersions, JaCoCo 0.8.15, central
  Automatic-Module-Name, SHA-pinned actions, release job permissions and
  concurrency, timeouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he console sample

- errorprone profile (JDK 21+): Error Prone + NullAway (JSpecify mode) with -Werror
- LogEventSupport uses the client's thread-name constant and Strings helpers
- Console sample runs in a forked JVM (exec:exec) so the JUL handler loads and crashes are reported

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 83ad2206fc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +269 to +274
Object normalized =
value instanceof JsonSerializable
? value
: DataNormalizer.normalize(value, maxDepth, excluded);
if (normalized != null) {
event.getData().put(name, normalized);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reapply exclusions after loading persisted settings

When a lazily started client has persisted @@DataExclusions, the normal fluent sequence createEvent().setProperty(...).submit() normalizes and stores the property here before submit() starts the client and loads those settings. The pipeline never filters existing custom event data again, so the first event after a restart can transmit a property such as password despite the saved server exclusion. Apply exclusions after startup or scrub event data during submission.

Useful? React with 👍 / 👎.

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.

Thanks for the work on this. I traced this at 83ad220: ConfigurationDefaultsPlugin skips existing custom-data keys, and the remaining default plugins don't scrub them before DefaultEventQueue serializes the event. I haven't reproduced it locally yet.

Could we add a regression using synthetic persisted @@DataExclusions and a lazily constructed client, then createEvent().setProperty(...).submit()? Capturing the serialized payload with a fake submission client would let us check that excluded top-level and nested fields are absent while allowed fields remain. Loading saved settings before normalization or applying a final custom-data scrub both seem worth considering, whichever fits the lifecycle best.

Comment on lines +323 to +325
if (isOurUncaughtExceptionHandlerInstalled()) {
Thread.setDefaultUncaughtExceptionHandler(previousHandler);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Unlink closed clients from the uncaught-handler chain

With two clients using the default captureUncaughtExceptions=true, starting A and then B makes B's previous handler point to A. If A is closed before B, this identity check leaves A in the chain; when B later closes, it restores A's now-closed handler as the JVM default. Subsequent uncaught exceptions therefore pass through a closed client and are no longer reported by an active Exceptionless client. Closing a client needs to remove its handler from the chain even when another client is currently installed.

Useful? React with 👍 / 👎.

Path temp = directory.resolve(stored.fileName() + ".tmp");
try {
Files.writeString(temp, json, StandardCharsets.UTF_8);
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back when atomic moves are unavailable

When file persistence is configured on a filesystem provider that does not support ATOMIC_MOVE (for example, some mounted or network-backed paths), every enqueue reaches this move and throws AtomicMoveNotSupportedException; the catch deletes the temporary file and the event is never indexed, so persistence silently drops all events. Retry the rename without ATOMIC_MOVE when that specific exception occurs, as durability is still preferable to losing the queue entirely.

Useful? React with 👍 / 👎.

FakeServer and TestClients move to exceptionless-core/src/testFixtures/java.
Modules that need them add that directory as a test source through
build-helper-maven-plugin, so exceptionless-core no longer attaches a tests
jar that would be deployed to Maven Central. Spotless now formats every
src/*/java directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 564255f577

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +37 to +40
if (!markProcessed(exception)) {
context.cancel();
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Defer deduplication until the event is accepted

When the same throwable is first logged and later escapes a servlet or framework boundary, this records it before EventExclusionPlugin and submitting handlers have decided whether the first event will be queued. If that logged event is filtered by a source-specific setting or cancelled by a handler, the later unhandled submission is nevertheless cancelled as already processed, so the exception is never reported. Record the throwable only after the event is accepted, or roll back the marker when downstream processing cancels it.

Useful? React with 👍 / 👎.

Comment on lines +23 to +24
/** Exceptions already reported. Weak, so reported exceptions can still be collected. */
private final Set<Throwable> processed = Collections.newSetFromMap(new WeakHashMap<>());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use identity semantics when tracking reported throwables

For a custom Throwable that overrides equals/hashCode, this WeakHashMap-backed set treats distinct exception instances as the same key. After one such exception is submitted, a later independent occurrence that compares equal is cancelled indefinitely rather than merely deduplicated within the configured duplicate window. The processed set needs weak identity semantics, consistent with the stated intent to track exception instances.

Useful? React with 👍 / 👎.

Comment on lines +247 to +249
} catch (ReflectiveOperationException | RuntimeException e) {
LOG.debug(() -> "Unable to read record " + value.getClass().getName() + ": " + e);
return safeToString(value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve exclusions when record reflection fails

When a custom property is a non-public record in a named module whose package is not open, setAccessible throws here; an overridden accessor can trigger the same path. Falling back to the record's generated toString() includes every component, including components such as password that were skipped by the exclusion predicate, so @@DataExclusions can be bypassed and secrets transmitted. Omit the value or retain only the successfully filtered components instead of serializing the whole record as text.

Useful? React with 👍 / 👎.

ejsmith and others added 2 commits October 19, 2026 23:01
Match the other Exceptionless builds: MinVer computes the version from v*
tags and every job builds with it (-Drevision). A v* tag publishes that
version to Maven Central after the tests, end-to-end tests and the Spring
Boot 4 job pass; other pushes publish a preview build to GitHub Packages;
pull requests only build and test. release.yml is folded into build.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When MvcIntegrationTest runs first, its cached application context keeps
its own appender on the shared root logger, so asserting that no appender
named EXCEPTIONLESS remains failed depending on test-class order (CI vs
local). Assert only on the appenders this test's context added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: decda840b7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return; // already processing
}
try {
for (int run = 0; run < MAX_BATCHES_PER_RUN; run++) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drain beyond the 100-batch cap during shutdown

When the queue contains more than 100 batches—for example, submissionBatchSize(1) with 101 queued events—this hard cap returns while items remain even if every post succeeds. ExceptionlessClient.close() invokes process() only once before shutting down the executor, so remaining in-memory events are lost and file-backed events are deferred until a future restart. Make the shutdown path repeat capped processing until the queue is empty or its deadline expires.

Useful? React with 👍 / 👎.

Comment on lines +72 to +75
Merged existing = merged.get(hash);
if (existing != null) {
existing.count += countOf(event);
context.cancel();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare full duplicate signatures instead of integer hashes

When distinct errors have the same 32-bit hash within the duplicate window, this lookup treats them as one signature; for example, otherwise identical exceptions with Java-colliding messages such as Aa and BB collide. Once a merged entry exists, subsequent occurrences of either error are cancelled and counted against that one event, corrupting the reported message/count distribution. Store and compare the complete signature, using its hash only for lookup.

Useful? React with 👍 / 👎.

There has only been a 0.1.0 release, so the rewrite ships as 0.9 (first
release v0.9.0-beta.1) ahead of 1.0. CI pre-release builds use alpha
instead of preview: Maven orders unknown qualifiers after the release, so
1.0.0-preview.1 would count as newer than 1.0.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ejsmith ejsmith changed the title Modernize to a first-class Exceptionless client (2.0) Modernize to a first-class Exceptionless client (0.9, toward 1.0) Oct 20, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 926d71c89f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

* <p>Set {@code exceptionless.enabled=false} to turn all of it off.
*/
@AutoConfiguration
@ConditionalOnBooleanProperty(name = "exceptionless.enabled", matchIfMissing = true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use a condition available throughout Spring Boot 3

Applications using Spring Boot 3.0–3.4 do not provide this annotation—Spring Boot's API documents ConditionalOnBooleanProperty as introduced in 3.5.0—so the starter's auto-configuration cannot load reliably on part of the advertised “Spring Boot 3” range. Use the older ConditionalOnProperty equivalent or explicitly require Spring Boot 3.5+.

Useful? React with 👍 / 👎.

Comment on lines +216 to +218
if (!config.isEnabled()) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep background processing available when re-enabling

When a valid-key client starts with enabled(false) and is later changed to enabled(true), this return means the queue and settings tasks were never scheduled. The setter remains usable after startup, so subsequent submissions are accepted into the queue but are never sent automatically; either schedule the disabled-aware tasks unconditionally or reject/reinitialize post-start enable transitions.

Useful? React with 👍 / 👎.

Comment on lines +147 to +150
int excess = index.size() - maxItems + 1;
for (int i = 0; i < excess; i++) {
Map.Entry<Key, Stored> oldest = index.pollFirstEntry();
deleteQuietly(directory.resolve(oldest.getValue().fileName()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Evict the oldest file only after persisting its replacement

When the persistent queue is full, the oldest entry is deleted before the new event has been written and moved into place. If that write or move then fails—for example because the filesystem is temporarily full—the catch also discards the temporary event, losing both the existing event and the new one; commit the replacement successfully before finalizing the eviction, or restore the evicted entry on failure.

Useful? React with 👍 / 👎.

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.

Modernize to a first-class Exceptionless client (0.9, toward 1.0)

3 participants