diff --git a/.agents/skills/exceptionless-java/SKILL.md b/.agents/skills/exceptionless-java/SKILL.md
new file mode 100644
index 0000000..3ef6eb9
--- /dev/null
+++ b/.agents/skills/exceptionless-java/SKILL.md
@@ -0,0 +1,94 @@
+---
+name: exceptionless-java
+description: Use this skill when a developer wants to install, configure, troubleshoot, or integrate the Exceptionless Java client in plain Java, Spring Boot (MVC or WebFlux), Jakarta Servlet containers, Logback/SLF4J, Log4j 2, or java.util.logging apps. Use it for API keys, startup, self-hosting, sending errors/logs/feature usage/404/custom events, custom properties, sessions, heartbeats, user identity, PII/data exclusions, plugins, project client configuration values (@@log, @@error, @@DataExclusions), queues, persistence, and shutdown behavior, even if they only ask "how do I wire up Exceptionless in Java?"
+---
+
+# Exceptionless Java Client
+
+Produce source-accurate setup code and guidance for the Exceptionless Java client (`com.exceptionless:*`,
+version 0.9 and later, Java 17+). Keep answers compact and copyable, with imports.
+
+## Pick the module
+
+| App | Dependency | Setup |
+| --- | --- | --- |
+| Spring Boot 3 or 4 | `exceptionless-spring-boot-starter` | `exceptionless.api-key` property; inject `ExceptionlessClient` |
+| Servlet container (Tomcat, Jetty...) | `exceptionless-servlet` | `ExceptionlessFilter` + `Exceptionless.startup(...)` (the filter passes requests through until the default client has started) |
+| Plain Java / CLI / desktop | `exceptionless-core` | `Exceptionless.startup("KEY")` |
+| Logback / SLF4J | `exceptionless-logback` | `com.exceptionless.logback.ExceptionlessAppender` |
+| Log4j 2 | `exceptionless-log4j2` | `` appender |
+| java.util.logging | `exceptionless-jul` | `com.exceptionless.jul.ExceptionlessHandler` |
+
+Always recommend importing `com.exceptionless:exceptionless-bom` to align versions.
+
+## Rules
+
+- Start once: `Exceptionless.startup("KEY")` or `Exceptionless.startup(c -> c.apiKey(...)...)`. With no
+ arguments it reads `exceptionless.*` system properties, `EXCEPTIONLESS_*` environment variables
+ (`EXCEPTIONLESS_API_KEY`, `EXCEPTIONLESS_SERVER_URL`) and `exceptionless.properties`.
+- In Spring Boot, never call `Exceptionless.startup`; the starter creates the client bean and makes it
+ the default. Customize with an `ExceptionlessConfigurationCustomizer` bean.
+ `exceptionless.logging.minimum-level` is a `LogLevel` (`trace` ... `off`, any case; aliases such as
+ `warning` are rejected) and `exceptionless.storage-path` a directory path.
+- The starter only wires Logback. For Log4j 2, add `exceptionless-log4j2` and the ``
+ appender to `log4j2.xml`; without `apiKey` it uses the starter's client.
+- `serverUrl(...)` also sets the config and heartbeat URLs; override those after it.
+- The API key, server URLs, storage path, proxy and TLS settings are locked once the client starts.
+- `submitException` / `createException` take any `Throwable`; causes and suppressed exceptions are included.
+- Builders end with `.submit()`. Submitting never blocks on the network and never throws.
+- Short-lived jobs: queued events are sent on JVM shutdown automatically; call
+ `Exceptionless.flush(Duration.ofSeconds(10))` when the process may be killed abruptly.
+- Logging appenders send `warn` and above by default (`minimumLevel`); the project's `@@log:`
+ settings override that in both directions. Appenders without `apiKey` use the default client once it
+ has started; messages logged earlier are not sent.
+- Reference ids must be 8-100 characters of letters, digits and dashes; error events get one automatically.
+- Use lowercase log levels in settings: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `off`.
+- Don't put application loggers under the `com.exceptionless` package; the integrations treat those as
+ the client's own diagnostics and skip them.
+- For another logging framework, build the appender on `com.exceptionless.logging.LogEventSupport`
+ (client selection, owned client lifecycle, recursion guard, level checks, event creation).
+
+## Privacy
+
+Private information (machine name, IPs, headers, cookies, query strings, form data, user names) is on by
+default. In production examples that collect request or user data, show `includePrivateInformation(false)`
+or the specific toggles, and `addDataExclusions("*password*", "*secret*", "*token*")`. Exclusions are
+wildcards, case-insensitive, applied at every depth. `Authorization`, `Cookie` and similar credential
+headers are never sent.
+
+## Project settings (client configuration values)
+
+| Key | Example | Effect |
+| --- | --- | --- |
+| `@@log:` | `@@log:com.example.*` = `info` | Minimum log level; most specific wildcard wins |
+| `@@error:` | `@@error:java.io.IOException` = `false` | Discard errors with that type in the cause chain |
+| `@@:` | `@@404:/favicon.ico` = `false` | Discard usage/404/custom events by source |
+| `@@DataExclusions` | `*password*,ssn` | Extra exclusions |
+| `@@IncludePrivateInformation` | `false` | Turn private info off |
+| `@@UserAgentBotPatterns` | `*bot*` | Ignore bot requests |
+
+## Troubleshooting
+
+- Nothing arrives: check `Exceptionless.getConfiguration().validate()` (empty when valid) and
+ `isEnabled()`. A missing/invalid key disables the client with a warning instead of throwing.
+- Enable client diagnostics: the client logs through `System.Logger` under `com.exceptionless`
+ (routed to SLF4J/Log4j/JUL by whatever bridge the app uses). Set that logger to `DEBUG`.
+- Self-hosted behind a redirect (http → https): the client reports redirects instead of following them;
+ use the final URL.
+- Events dropped with HTTP 402: the organization is over its plan limit; the client pauses for an hour.
+
+## Source anchors
+
+Verify behavior in:
+
+- `exceptionless-core/src/main/java/com/exceptionless/Exceptionless.java` (static default client)
+- `exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java`
+- `exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java`
+- `exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java`
+- `exceptionless-core/src/main/java/com/exceptionless/plugins/` (pipeline, exclusions, privacy)
+- `exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java` (status handling)
+- `exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/`
+- `exceptionless-servlet/src/main/java/com/exceptionless/servlet/ExceptionlessFilter.java`
+- `exceptionless-core/src/main/java/com/exceptionless/logging/LogEventSupport.java` (shared appender logic)
+- The appenders in `exceptionless-logback`, `exceptionless-log4j2`, `exceptionless-jul`
+- `README.md` and `samples/`
diff --git a/.agents/skills/exceptionless-java/agents/openai.yaml b/.agents/skills/exceptionless-java/agents/openai.yaml
new file mode 100644
index 0000000..9013907
--- /dev/null
+++ b/.agents/skills/exceptionless-java/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "Exceptionless Java"
+ short_description: "Configure the Exceptionless Java client"
+ default_prompt: "Use $exceptionless-java to configure, document, or troubleshoot the Exceptionless Java client."
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..d662421
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,23 @@
+version: 2
+updates:
+ - package-ecosystem: maven
+ directory: /
+ schedule:
+ interval: weekly
+ groups:
+ test-dependencies:
+ patterns: ["org.junit*", "org.assertj*", "org.awaitility*", "org.testcontainers*", "org.eclipse.jetty*"]
+ maven-plugins:
+ patterns: ["org.apache.maven.plugins*", "org.codehaus.mojo*", "org.jacoco*", "org.sonatype.central*", "com.diffplug.spotless*"]
+ # Integration targets (Logback, Log4j, the Servlet API, Spring Boot) are provided dependencies:
+ # applications bring their own version, so these only set what the integrations are compiled
+ # and tested against, and are bumped deliberately (e.g. alongside the -Pspring-boot-4 profile).
+ ignore:
+ - dependency-name: "ch.qos.logback:*"
+ - dependency-name: "org.apache.logging.log4j:*"
+ - dependency-name: "jakarta.servlet:*"
+ - dependency-name: "org.springframework.boot:*"
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: weekly
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..60b46f8
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,227 @@
+name: Build
+
+# Versions come from git tags (MinVer), like the other Exceptionless builds:
+# - A v* tag (v0.9.0-beta.1, v1.0.0) builds that version and publishes it to Maven Central.
+# - Other pushes build a pre-release version, such as 0.9.0-alpha.0.12 on main or
+# 0.9.0-alpha.my-branch.0.12 on a branch, and publish it to GitHub Packages.
+# Pre-release labels must be alpha, beta or rc: Maven orders unknown labels such as "preview"
+# after the release (1.0.0-preview.1 > 1.0.0).
+# - Pull requests build and test only.
+#
+# Required secrets for releases: CENTRAL_USERNAME and CENTRAL_PASSWORD (a Central Portal user
+# token), GPG_PRIVATE_KEY and GPG_PASSPHRASE.
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ # Never cancel a tag build; it may be publishing a release.
+ cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
+
+defaults:
+ run:
+ shell: bash
+
+env:
+ MAVEN_ARGS: --batch-mode --no-transfer-progress
+
+jobs:
+ version:
+ name: Version
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+
+ - name: Set up .NET for MinVer
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Compute version
+ id: version
+ run: |
+ # Pre-release versions from branches other than main, maintenance branches (1.x, 1.2),
+ # tags and pull requests carry the branch name.
+ branch="${GITHUB_REF##*/}"
+ if [[ "$GITHUB_REF" != refs/heads/* || "$branch" == main || "$branch" =~ ^[0-9]+\.(x|[0-9]+)$ ]]; then
+ branch=""
+ fi
+ branch="$(tr -c 'A-Za-z0-9-' '-' <<< "$branch" | sed -E 's/-+$//')"
+ suffix="${branch:+$branch.}"
+
+ dotnet tool install --global minver-cli --version 8.0.0
+ version="$(minver --tag-prefix v --default-pre-release-identifiers "alpha.${suffix}0" --minimum-major-minor 0.9)"
+
+ # After a pre-release tag (v1.1.0-beta.1) MinVer appends only the height; add the branch.
+ if [[ -n "$branch" && "$version" != *"$branch"* ]]; then
+ version="$(sed -E "s/\.([0-9]+)$/.${suffix}\1/" <<< "$version")"
+ fi
+
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ echo "### Version: $version" >> "$GITHUB_STEP_SUMMARY"
+ echo "Version: $version (ref: $GITHUB_REF, event: $GITHUB_EVENT_NAME)"
+
+ build:
+ name: JDK ${{ matrix.java }} on ${{ matrix.os }}
+ needs: version
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest]
+ java: [17, 21, 25]
+ include:
+ - os: windows-latest
+ java: 21
+ - os: macos-latest
+ java: 21
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Set up JDK ${{ matrix.java }}
+ uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
+ with:
+ distribution: temurin
+ java-version: ${{ matrix.java }}
+ cache: maven
+
+ - name: Build and test
+ run: ./mvnw verify -Drevision="$VERSION"
+ env:
+ VERSION: ${{ needs.version.outputs.version }}
+
+ - name: Upload test reports
+ if: failure()
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: test-reports-${{ matrix.os }}-jdk${{ matrix.java }}
+ path: '**/target/surefire-reports/'
+
+ spring-boot-4:
+ name: Spring Boot 4 compatibility
+ needs: version
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Set up JDK
+ uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
+ with:
+ distribution: temurin
+ java-version: 21
+ cache: maven
+
+ - name: Test the starter against Spring Boot 4
+ run: ./mvnw -pl exceptionless-spring-boot-starter -am verify -Pspring-boot-4 -Drevision="$VERSION"
+ env:
+ VERSION: ${{ needs.version.outputs.version }}
+
+ e2e:
+ name: End-to-end (real Exceptionless server)
+ needs: version
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ # Every push (so every published build) and pull requests labeled e2e.
+ if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'e2e')
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Set up JDK
+ uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
+ with:
+ distribution: temurin
+ java-version: 21
+ cache: maven
+
+ - name: Run end-to-end tests
+ run: ./mvnw -pl exceptionless-e2e-tests -am verify -De2e=true -Drevision="$VERSION"
+ env:
+ VERSION: ${{ needs.version.outputs.version }}
+
+ publish-ci:
+ name: Publish CI packages
+ needs: [version, build, spring-boot-4, e2e]
+ if: github.event_name != 'pull_request' && startsWith(github.ref, 'refs/heads/') && github.actor != 'dependabot[bot]'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Set up JDK
+ uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
+ with:
+ distribution: temurin
+ java-version: 21
+ cache: maven
+ server-id: github
+ server-username: GITHUB_ACTOR
+ server-password: GITHUB_TOKEN
+
+ - name: Publish to GitHub Packages
+ # A CI package is a convenience; failing to publish one doesn't fail the build.
+ continue-on-error: true
+ run: >-
+ ./mvnw deploy -DskipTests -Drevision="$VERSION"
+ -DaltDeploymentRepository="github::https://maven.pkg.github.com/$GITHUB_REPOSITORY"
+ env:
+ VERSION: ${{ needs.version.outputs.version }}
+ GITHUB_TOKEN: ${{ github.token }}
+
+ release:
+ name: Publish to Maven Central
+ needs: [version, build, spring-boot-4, e2e]
+ if: startsWith(github.ref, 'refs/tags/v')
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - name: Check the version matches the tag
+ run: |
+ if [[ "$VERSION" != "${GITHUB_REF_NAME#v}" ]]; then
+ echo "::error::Tag $GITHUB_REF_NAME is not a version tag (computed version: $VERSION)."
+ exit 1
+ fi
+ env:
+ VERSION: ${{ needs.version.outputs.version }}
+
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Set up JDK
+ uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
+ with:
+ distribution: temurin
+ java-version: 21
+ cache: maven
+ server-id: central
+ server-username: CENTRAL_USERNAME
+ server-password: CENTRAL_PASSWORD
+ gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }}
+ gpg-passphrase: GPG_PASSPHRASE
+
+ - name: Publish to Maven Central
+ run: ./mvnw deploy -Prelease -DskipTests -Drevision="$VERSION"
+ env:
+ VERSION: ${{ needs.version.outputs.version }}
+ CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
+ CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
+ GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml
deleted file mode 100644
index 8b7586b..0000000
--- a/.github/workflows/greetings.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-name: Greetings
-
-on: [pull_request, issues]
-
-jobs:
- greeting:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/first-interaction@v1
- with:
- repo-token: ${{ secrets.GITHUB_TOKEN }}
- issue-message: 'Thanks for reporting this issue. We will get back to you in a while'
- pr-message: 'Great Job for your first PR in this repository'
diff --git a/.github/workflows/maven-publish.yml b/.github/workflows/maven-publish.yml
deleted file mode 100644
index c3f4855..0000000
--- a/.github/workflows/maven-publish.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-# This workflow will build a package using Maven and then publish it to GitHub packages when a release is created
-# For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#apache-maven-with-a-settings-path
-
-name: Maven Package
-
-on:
- release:
- types: [created]
-
-jobs:
- build:
-
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v2
- - name: Set up JDK 11
- uses: actions/setup-java@v2
- with:
- java-version: '11'
- distribution: 'adopt'
- server-id: github # Value of the distributionManagement/repository/id field of the pom.xml
- settings-path: ${{ github.workspace }} # location for the settings.xml file
-
- - name: Build with Maven
- run: mvn -B package --file pom.xml
-
- - name: Publish to GitHub Packages Apache Maven
- run: mvn deploy -s $GITHUB_WORKSPACE/settings.xml
- env:
- GITHUB_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
deleted file mode 100644
index 61064f4..0000000
--- a/.github/workflows/maven.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-# This workflow will build a Java project with Maven
-# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven
-
-name: Java CI with Maven
-
-on:
- push:
- branches: [ main ]
- pull_request:
- branches: [ main ]
-
-jobs:
- build:
-
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v2
- - name: Set up JDK 11
- uses: actions/setup-java@v2
- with:
- java-version: '11'
- distribution: 'adopt'
- - name: Build with Maven
- run: mvn -B package --file pom.xml
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 6725e75..cd07e5e 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -2,18 +2,22 @@ name: Mark stale issues and pull requests
on:
schedule:
- - cron: "30 1 * * *"
+ - cron: "30 1 * * *"
+
+permissions: {}
jobs:
stale:
-
runs-on: ubuntu-latest
-
+ timeout-minutes: 10
+ permissions:
+ issues: write
+ pull-requests: write
steps:
- - uses: actions/stale@v3
- with:
- repo-token: ${{ secrets.GITHUB_TOKEN }}
- stale-issue-message: 'This issue is stale'
- stale-pr-message: 'This PR is stale'
- stale-issue-label: 'stale'
- stale-pr-label: 'stale'
+ - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
+ with:
+ stale-issue-message: "This issue is stale because it has been open 60 days with no activity."
+ stale-pr-message: "This pull request is stale because it has been open 60 days with no activity."
+ stale-issue-label: stale
+ stale-pr-label: stale
+ exempt-issue-labels: pinned,security
diff --git a/.gitignore b/.gitignore
index 9dc7c14..7f43e82 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,4 +24,7 @@ hs_err_pid*
.idea/
*.iml
-target/
\ No newline at end of file
+target/
+# Maven
+.flattened-pom.xml
+!.mvn/wrapper/maven-wrapper.jar
diff --git a/.mvn/jvm.config b/.mvn/jvm.config
new file mode 100644
index 0000000..32599ce
--- /dev/null
+++ b/.mvn/jvm.config
@@ -0,0 +1,10 @@
+--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
+--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
+--add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
+--add-exports jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
+--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
+--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
+--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
+--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
+--add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
+--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..216df05
--- /dev/null
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index 8e55cab..0d95f06 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -1,6 +1,5 @@
{
"recommendations": [
- "vscjava.vscode-java-pack",
- "redhat.fabric8-analytics"
+ "vscjava.vscode-java-pack"
]
-}
\ No newline at end of file
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index a8d8f9a..24818f6 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,6 +1,9 @@
{
- "telemetry.enableTelemetry": false,
+ "java.configuration.updateBuildConfiguration": "automatic",
+ "java.compile.nullAnalysis.mode": "automatic",
"cSpell.words": [
- "Appender"
+ "Appender",
+ "exceptionless",
+ "jspecify"
]
-}
\ No newline at end of file
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index ed46e28..2405083 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -1,19 +1,17 @@
{
- // See https://go.microsoft.com/fwlink/?LinkId=733558
- // for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "verify",
"type": "shell",
- "command": "mvn -B verify",
+ "command": "./mvnw -B verify",
"group": "build"
},
{
- "label": "test",
+ "label": "test core",
"type": "shell",
- "command": "mvn -B test",
+ "command": "./mvnw -B -pl exceptionless-core test",
"group": "test"
}
]
-}
\ No newline at end of file
+}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6d5f6a2..70c6e0a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,7 +1,60 @@
# Contributing
-1. Use `google-java-format` for code formatting.
-2. Use wrapper classes instead of primitives. For example, use `Integer` in place of `int`. It helps us in distinguishing, when a value is not set.
-3. Use `@Builder` based object construction instead of `new`.
-4. Using `lombok` annotations for any new classes.
-5. All hardcoded constants appear as a `private static final` variables at the top of the class.
+Thanks for helping improve the Exceptionless Java client!
+
+## Building
+
+You need JDK 17 or newer. The Maven wrapper downloads the right Maven version.
+
+```bash
+./mvnw verify # build and run all tests
+./mvnw -pl exceptionless-core verify # one module
+./mvnw spotless:apply # format the code
+```
+
+End-to-end tests run the client against a real Exceptionless server in Docker. They are skipped
+unless you ask for them:
+
+```bash
+./mvnw -pl exceptionless-e2e-tests -am verify -De2e=true
+```
+
+Test the Spring Boot starter against Spring Boot 4 with `-Pspring-boot-4`.
+
+Test helpers shared between modules (`FakeServer`, `TestClients`) live in
+`exceptionless-core/src/testFixtures/java`. A module compiles them into its tests by declaring
+`build-helper-maven-plugin` in its `pom.xml`; they are not published.
+
+## Guidelines
+
+- `exceptionless-core` has **no runtime dependencies**. Keep it that way: use the JDK (`java.net.http`,
+ `System.Logger`) and the small JSON writer in `com.exceptionless.json`.
+- Integrations depend on their framework with `provided` scope so applications choose the version.
+- Never throw from a code path that runs inside the application (submitting, logging appenders,
+ filters). Log through `com.exceptionless.internal.Log` and carry on.
+- Never block the caller on the network. Network calls run on the client's background thread.
+- Everything sent to the server must match the server's snake_case models; `WireContractTest` and the
+ end-to-end tests guard this.
+- Public API gets javadoc. Packages are `@NullMarked` (JSpecify); mark nullable types with `@Nullable`.
+- Formatting is google-java-format, enforced by Spotless.
+- On JDK 21 and newer the build also runs [Error Prone](https://errorprone.info) and
+ [NullAway](https://github.com/uber/NullAway) on the main sources, with warnings as errors. Fix
+ findings rather than suppressing them; when a suppression is right, say why next to it.
+- Write idiomatic Java 17: records for values, sealed types for closed results, `java.time` types
+ (`Duration`, `InstantSource`) instead of numbers, immutable collections from getters, and plain
+ JavaBeans (`void` setters) for the mutable models.
+
+## Versions and releases
+
+Versions come from git tags through [MinVer](https://github.com/adamralph/minver), the same as the
+other Exceptionless builds. Local builds are `0.9.0-SNAPSHOT`; CI passes the computed version as
+`-Drevision`.
+
+- To release, push a tag such as `v0.9.0-beta.1` or `v1.0.0`, or create a GitHub release with one. The
+ Build workflow tests that commit and publishes every module to Maven Central under the tag's version.
+- Every other push publishes a pre-release build to GitHub Packages, versioned from the latest tag:
+ `0.9.0-alpha.0.12` on `main`, `0.9.0-alpha.my-branch.0.12` on a branch.
+- Pre-release tags use `alpha`, `beta` or `rc` (`v1.0.0-rc.1`). Maven sorts other labels, such as
+ `preview`, after the release, so `1.0.0-preview.1` would count as newer than `1.0.0`.
+- Pull requests build and test only. Add the `e2e` label to also run the end-to-end tests; pushes
+ always run them.
diff --git a/MIGRATION.md b/MIGRATION.md
new file mode 100644
index 0000000..42b77c3
--- /dev/null
+++ b/MIGRATION.md
@@ -0,0 +1,41 @@
+# Migrating from 0.1 to 0.9
+
+0.9 is a rewrite. The 0.1 client sent data in a format the current Exceptionless server no longer
+reads (stack traces, environment and request details were dropped), so upgrading is strongly
+recommended.
+
+## Dependencies
+
+| 0.1 | 0.9 |
+| --- | --- |
+| `com.exceptionless:exceptionless-client` | `com.exceptionless:exceptionless-core` (plus integrations, see the README) |
+| Java 11 | Java 17 |
+| OkHttp, Jackson, Logback, Lombok (transitive) | none |
+
+Remove any OkHttp version pins you added for the old client.
+
+## Packages and types
+
+| 0.1 | 0.9 |
+| --- | --- |
+| `com.exceptionless.exceptionlessclient.ExceptionlessClient` | `com.exceptionless.ExceptionlessClient` |
+| `ExceptionlessClient.from(apiKey, serverUrl)` | `ExceptionlessClient.create(c -> c.apiKey(apiKey).serverUrl(serverUrl))` or `Exceptionless.startup(...)` |
+| `Configuration.builder()...build()` | `new ExceptionlessConfiguration().apiKey(...)...` (fluent setters) |
+| `client.submitException(Exception)` | `client.submitException(Throwable)` |
+| `client.createError()` / `createLog(...)` + `.build()` + `submitEvent(EventPluginContext.from(...))` | `client.createException(e)` / `createLog(...)` + `.submit()` |
+| `EventPluginIF` | `com.exceptionless.plugins.EventPlugin`, or `EventPlugin.of(name, priority, ctx -> ...)` for a lambda |
+| `EventQueueIF`, `SubmissionClientIF`, `SettingsClientIF`, `StorageProviderIF` | `EventQueue`, `SubmissionClient`, `QueueStorage` / `KeyValueStorage` |
+| `PrivateInformationInclusions` | `config.includePrivateInformation(...)`, `includeHeaders(...)`, `includeCookies(...)`, ... |
+| `LogCapturerIF` | Use a logging integration (`exceptionless-logback`, `-log4j2`, `-jul`), or write one with `com.exceptionless.logging.LogEventSupport`. |
+
+## Behavior changes
+
+- The client never keeps the JVM alive, sends queued events on shutdown, and reports uncaught
+ exceptions (turn off with `captureUncaughtExceptions(false)`).
+- The client no longer adds an appender to the Logback root logger. Add `ExceptionlessAppender`
+ yourself, or use the Spring Boot starter.
+- Private information (machine name, IP address, headers, cookies, query strings, form data) is
+ included by default, like the other Exceptionless clients. Use
+ `includePrivateInformation(false)` to opt out.
+- A missing or invalid API key disables the client (with a warning) instead of throwing.
+- The API key is sent in the `Authorization` header instead of the query string.
diff --git a/README.md b/README.md
index 96605a1..03a41b1 100644
--- a/README.md
+++ b/README.md
@@ -1,136 +1,298 @@
# Exceptionless.Java
-The definition of the word exceptionless is: to be without exception. [Exceptionless](https://exceptionless.io) provides
-real-time error reporting for your Java apps. It organizes the gathered information into simple actionable data that
-will help your app become exceptionless!
+[](https://github.com/exceptionless/Exceptionless.Java/actions/workflows/build.yml)
+[](https://central.sonatype.com/namespace/com.exceptionless)
-## Using Exceptionless
+The official [Exceptionless](https://exceptionless.com) client for Java. Report errors, logs, feature
+usage, 404s and user sessions from any JVM application, with first-class support for Spring Boot,
+Servlet containers, Logback, Log4j 2 and `java.util.logging`.
-Refer to the Exceptionless documentation here: [Exceptionless Docs](http://docs.exceptionless.io).
+- **Zero dependencies** in the core library (Java 17+). No logging framework, HTTP client or JSON
+ library is forced on your application.
+- **Never blocks, never throws.** Events are queued and sent in batches on a single daemon thread.
+- **Nothing lost on exit.** Queued events are sent when the JVM shuts down; uncaught exceptions are
+ reported automatically. Optionally persist the queue to disk.
+- **Server-driven.** Log levels, data exclusions and event filters set in your project settings
+ apply instantly, without a redeploy.
-This project is available to install through Maven.
-```
-
-
+## Install
+
+Use the BOM to keep module versions aligned:
+
+```xml
+
+
+
+ com.exceptionless
+ exceptionless-bom
+ 0.9.0-beta.1
+ pom
+ import
+
+
+
+
+
+
+ com.exceptionless
- exceptionless-client
- 0.1.0
-
+ exceptionless-spring-boot-starter
+
+
+
+
+ com.exceptionless
+ exceptionless-core
+
+
+```
+
+Gradle:
+
+```kotlin
+implementation(platform("com.exceptionless:exceptionless-bom:0.9.0-beta.1"))
+implementation("com.exceptionless:exceptionless-core")
```
-Check the latest version of the library [here](https://mvnrepository.com/artifact/com.exceptionless/exceptionless-client)
-## Show me the code
+| Module | Use it for |
+| --- | --- |
+| `exceptionless-core` | The client. Plain Java, CLI and desktop apps. |
+| `exceptionless-spring-boot-starter` | Spring Boot 3 and 4 (MVC and WebFlux): auto-configuration, request capture, Logback. |
+| `exceptionless-servlet` | Any Jakarta Servlet 6 container: unhandled errors and 404s with request details. |
+| `exceptionless-logback` | Logback appender (SLF4J). |
+| `exceptionless-log4j2` | Log4j 2 appender. |
+| `exceptionless-jul` | `java.util.logging` handler. |
+
+## Quick start
+
+Get an API key from your project's settings, then:
```java
-class ExampleApp {
- public static void main(String[] args) {
- private static final ExceptionlessClient client =
- ExceptionlessClient.from(
- System.getenv("EXCEPTIONLESS_SAMPLE_APP_API_KEY"),
- System.getenv("EXCEPTIONLESS_SAMPLE_APP_SERVER_URL"));
-
- // Submit different events using submitXXX methods
- client.submitLog("Test log");
-
- // Submit custom methods using our createXXX methods
- client.submitEvent(
- EventPluginContext.from(
- client.createLog("test-log").referenceId("test-reference-id").build()));
+import com.exceptionless.Exceptionless;
+
+public class App {
+ public static void main(String[] args) {
+ Exceptionless.startup("YOUR_API_KEY");
+
+ try {
+ run();
+ } catch (Exception e) {
+ Exceptionless.submitException(e);
}
+ }
}
```
-**Builder Pattern**
+That's it: uncaught exceptions are reported too, and queued events are sent before the JVM exits.
+
+You can also leave the key out of your code. `Exceptionless.startup()` reads, in order of precedence,
+`exceptionless.*` system properties, `EXCEPTIONLESS_*` environment variables, and an
+`exceptionless.properties` file on the class path:
+
+```bash
+EXCEPTIONLESS_API_KEY=your-key EXCEPTIONLESS_SERVER_URL=https://ex.example.com java -jar app.jar
+```
+
+### Spring Boot
+
+Add `exceptionless-spring-boot-starter` and set your API key:
+
+```yaml
+exceptionless:
+ api-key: ${EXCEPTIONLESS_API_KEY}
+ # server-url: https://exceptionless.example.com # self hosted
+ # use-sessions: true
+ # storage-path: /var/lib/myapp/exceptionless # persist queued events across restarts
+ # logging:
+ # minimum-level: warn # trace, debug, info, warn, error, fatal or off
+```
+
+Unhandled controller exceptions (MVC and WebFlux) and 404s are reported with the request details,
+Logback warnings and errors are sent as log events, and the `ExceptionlessClient` bean is available
+for injection. Customize the configuration with an `ExceptionlessConfigurationCustomizer` bean.
+
+The starter only adds the Logback appender (when Logback is the logging system). With Log4j 2, add
+`exceptionless-log4j2` and its `` appender to your `log4j2.xml` yourself; without
+an `apiKey` it sends through the starter's client. If Logback reloads its configuration
+(`scan="true"`), the appender is added again; if your `logback-spring.xml` declares its own
+`ExceptionlessAppender`, the starter leaves the root logger alone.
+
+## Sending events
+
+```java
+// Errors (any Throwable, including its causes and suppressed exceptions)
+Exceptionless.submitException(e);
+
+// Logs, with an optional source (usually the logger name) and level
+Exceptionless.submitLog("Order service started");
+Exceptionless.submitLog("com.example.OrderService", "Payment retry", LogLevel.WARN);
+
+// Feature usage and 404s
+Exceptionless.submitFeatureUsage("Export to CSV");
+Exceptionless.submitNotFound("/products/42");
+```
+
+Every `create*` method returns a fluent builder for adding context:
-We love our builders!!! This project heavily utilized the use of builders instead of traditional object creation
-using `new`. We do this with the help of Project Lombok's `@Builder` annotation. Read more about the
-project [here](https://projectlombok.org/features/all). Read more about the
-annotation [here](https://projectlombok.org/features/Builder). Read more about the builder
-pattern [here](https://refactoring.guru/design-patterns/builder).
+```java
+Exceptionless.createException(e)
+ .addTags("checkout", "payments")
+ .setProperty("order", order) // any object; serialized via its getters
+ .setUserIdentity(user.getId(), user.getName())
+ .setUserDescription(user.getEmail(), "I clicked Pay twice")
+ .setReferenceId("order-" + order.getId()) // look the event up later
+ .setManualStackingKey("payment-declined") // group these yourself
+ .setGeo(43.65, -96.75)
+ .setValue(order.getTotal())
+ .markAsCritical()
+ .submit();
+```
-_Example: Customizing your Event Queue implementation_
+Show users a reference id they can quote to support, and attach their description later:
+```java
+String id = Exceptionless.getLastReferenceId();
+Exceptionless.getDefault().updateUserEmailAndDescription(id, "ann@example.com", "It broke");
```
-EventQueueIF queue = //get your implementation
-Configuration configuration = Configuration.builder()
- .serverUrl("http://your-server-url")
- .apiKey("your-api-key")
- .queue(queue)
- .build();
-ExceptionlessClient client = ExceptionlessClient.builder().configuration(configuration).build();
+
+## Configuration
+
+```java
+Exceptionless.startup(config -> config
+ .apiKey("YOUR_API_KEY")
+ .serverUrl("https://exceptionless.example.com") // self hosted
+ .version("1.4.2") // or read from your jar's manifest
+ .addDefaultTags("backend")
+ .addDefaultData("region", "us-east-1")
+ .userIdentity("service-account") // desktop/CLI apps
+ .addDataExclusions("*password*", "*secret*") // never sent, at any depth
+ .includePrivateInformation(false) // no IPs, machine name, headers, cookies...
+ .useFileStorage(Path.of("/var/lib/myapp/exceptionless")) // survive restarts
+ .useSessions());
```
-In this library we have made sure that all the values which are not set by builders fallback to reasonable defaults. So
-don't feel the pressure to supply values for all the fields. **Note:** Whenever customizing the client
-using `Configuration` never forget to supply your `serverUrl` and `apiKey` using a `Configuration` object as
-shown above.
+| Option | Default | |
+| --- | --- | --- |
+| `apiKey` | | Required. |
+| `serverUrl` | `https://collector.exceptionless.io` | Also sets the config and heartbeat URLs. |
+| `version` | jar `Implementation-Version` | Sent as `@version`. |
+| `enabled` | `true` | |
+| `includePrivateInformation` | `true` | Master switch; `includeHeaders`, `includeCookies`, `includeIpAddress`, ... individually. |
+| `addDataExclusions` | | Wildcards (`*password*`). Merged with the project's `@@DataExclusions`. |
+| `addUserAgentBotPatterns` | | Wildcards; events from matching requests are discarded. |
+| `defaultMinLogLevel` | none | Minimum log level when no `@@log` setting matches. |
+| `useReferenceIds` | `true` | Give each error a reference id. |
+| `useSessions` | off | Session start/end and heartbeats. |
+| `useFileStorage` | in memory | Persist queued events and settings. |
+| `submissionBatchSize` | 50 | |
+| `queueProcessInterval` | 10s | |
+| `queueMaxAttempts` / `queueMaxAge` | 3 / 2 days | |
+| `captureUncaughtExceptions` | `true` | Default uncaught exception handler (chains to any existing one). |
+| `registerShutdownHook` | `true` | Send queued events on JVM exit. |
+| `proxy`, `sslContext`, `httpClient` | | Network customization. |
-## Getting Started (Development)
+### Project settings
-### Intellij
-1. You will need to install:
- * [Intellij](https://www.jetbrains.com/idea/download/)
- * **Note:** In Intellij you can directly [download JDK 11 from the IDE](https://www.jetbrains.com/help/idea/sdk.html#jdk-from-ide). Intellij also comes with a bundled Maven installation and built-in Lombok support.
-2. Clone the repo
-3. Verify your setup using `mvn clean verify`
+These settings in your project's **Settings → Client Configuration** apply to running applications
+within a couple of minutes:
-### VSCode
-1. You will need to install:
- * [VSCode](https://code.visualstudio.com/download)
- * [JDK 11](https://www.oracle.com/ie/java/technologies/javase-downloads.html). Make sure that your `java --version` and `$JAVA_HOME` environment variable point to the correct jdk.
- * [VSCode Java extension pack](https://marketplace.visualstudio.com/items?itemName=vscjava.vscode-java-pack)
- * [VSCode Lombok extension](https://marketplace.visualstudio.com/items?itemName=GabrielBB.vscode-lombok)
-2. Clone the repo
-3. Verify your setup using `mvn clean verify`
+| Setting | Example | Effect |
+| --- | --- | --- |
+| `@@log:` | `@@log:com.example.*` = `info` | Minimum level for log events (wildcards; most specific wins). |
+| `@@error:` | `@@error:java.io.IOException` = `false` | Discard errors of that type (anywhere in the cause chain). |
+| `@@:` | `@@404:/favicon.ico` = `false` | Discard other event types by source. |
+| `@@DataExclusions` | `*password*,ssn` | Extra data exclusions. |
+| `@@IncludePrivateInformation` | `false` | Turn off private information. |
+| `@@UserAgentBotPatterns` | `*bot*,*crawler*` | Ignore requests from bots. |
+## Logging integrations
-## Spring Boot Users
+Logging integrations send warnings and errors (configurable; the project's `@@log` settings take
+precedence). Without their own `apiKey` they use the default client started by
+`Exceptionless.startup()`.
-You can observe `NoClassDefFoundError` in your Spring-boot apps because Spring-boot uses v3 of `OkHttpClient` while this
-client uses v4. In that case you have to explicitly declare v4 of the library in you `pom.xml/build.gradle`.
+**Logback** (`exceptionless-logback`):
```xml
+
+ warn
+
+
+
+
+```
-
-
- com.exceptionless
- exceptionless-client
- 0.1.0
-
-
- org.springframework.boot
- spring-boot-starter-web
-
-
-
- com.squareup.okhttp3
- okhttp
- 4.9.1
-
-
+**Log4j 2** (`exceptionless-log4j2`):
+
+```xml
+
+
+
+
+
+
+```
+
+**java.util.logging** (`exceptionless-jul`):
+
+```properties
+handlers=java.util.logging.ConsoleHandler, com.exceptionless.jul.ExceptionlessHandler
+com.exceptionless.jul.ExceptionlessHandler.level=WARNING
+```
+
+MDC / ThreadContext values (and Log4j `MapMessage` fields) become event properties, markers become
+tags, and logged exceptions become error events. Appending never throws: the first failure after
+the appender starts is reported to the framework's status output (Logback status, Log4j's error
+handler, JUL's `ErrorManager`). Log4j 2 appenders with `ignoreExceptions="false"` also rethrow
+failures as `AppenderLoggingException`, for `Failover` appenders.
+
+To integrate another logging framework, build on `com.exceptionless.logging.LogEventSupport` (in
+`exceptionless-core`): it implements the client selection (explicit client, a client owned for an
+`apiKey`, or the started default client), the filtering of the client's own diagnostics and of
+recursive logging, the level checks and the error/log event creation the bundled integrations share.
+
+## Servlet containers
+
+```java
+// Programmatically, or declare it in web.xml
+servletContext.addFilter("exceptionless", new ExceptionlessFilter()).addMappingForUrlPatterns(null, false, "/*");
```
-## VSCode Users
+Unhandled exceptions and 404s are reported with the request (method, path, headers, cookies, query
+string, form fields, user) after your privacy settings and data exclusions are applied. Errors logged
+while handling a request get the request details too.
+
+Without a client (`new ExceptionlessFilter()`), the filter looks up the default client on every
+request and only uses it once `Exceptionless.startup(...)` has run; until then requests pass through
+untouched. Pass a client (`new ExceptionlessFilter(client)`) to report to a specific one.
+
+## Plugins and hooks
-We are using [Project Lombok](https://projectlombok.org/) to automatically generate a lot of code. Intellij can give you IntelliSense by default but for VSCode you may have to install [this extension](https://marketplace.visualstudio.com/items?itemName=GabrielBB.vscode-lombok).
+```java
+config.addPlugin("tenant", 15, ctx -> ctx.getEvent().addTags(Tenant.current()));
+
+client.addSubmittingEventHandler(e -> {
+ if (e.getEvent().getMessage() != null && e.getEvent().getMessage().contains("healthcheck")) {
+ e.cancel();
+ }
+});
+
+config.addEventExclusion(event -> event.isNotFound() && event.getSource().endsWith(".map"));
+```
-## General Data Protection Regulation
+## Upgrading from 0.1
-By default the Exceptionless Client will report all available metadata including potential PII data. You can fine tune
-the collection of information via Data Exclusions or turning off collection completely.
+0.9 is a rewrite; see [MIGRATION.md](MIGRATION.md).
-Please visit
-the [docs](https://exceptionless.com/docs/clients/javascript/client-configuration/#general-data-protection-regulation)
-for detailed information on how to configure the client to meet your requirements.
+## Samples
-## Support
+- [`samples/console`](samples/console): a plain Java app.
+- [`samples/spring-boot`](samples/spring-boot): a Spring Boot web app.
-If you need help, please contact us via in-app
-support, [open an issue](https://github.com/exceptionless/Exceptionless.Java/issues/new)
-or [join our chat on Discord](https://discord.gg/6HxgFCx). We’re always here to help if you have any questions!
+## Contributing
-## Thanks
+See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and pull requests are welcome!
-Thanks to all the people who have contributed!
+## License
-[](https://github.com/exceptionless/Exceptionless.Java/graphs/contributors)
+[Apache 2.0](LICENSE)
diff --git a/exceptionless-bom/pom.xml b/exceptionless-bom/pom.xml
new file mode 100644
index 0000000..7e73bf4
--- /dev/null
+++ b/exceptionless-bom/pom.xml
@@ -0,0 +1,66 @@
+
+
+ 4.0.0
+
+
+ com.exceptionless
+ exceptionless-parent
+ ${revision}
+
+
+ exceptionless-bom
+ pom
+ Exceptionless BOM
+ Bill of materials that aligns the versions of all Exceptionless Java modules.
+
+
+
+
+ com.exceptionless
+ exceptionless-core
+ ${project.version}
+
+
+ com.exceptionless
+ exceptionless-jul
+ ${project.version}
+
+
+ com.exceptionless
+ exceptionless-logback
+ ${project.version}
+
+
+ com.exceptionless
+ exceptionless-log4j2
+ ${project.version}
+
+
+ com.exceptionless
+ exceptionless-servlet
+ ${project.version}
+
+
+ com.exceptionless
+ exceptionless-spring-boot-starter
+ ${project.version}
+
+
+
+
+
+
+
+ org.codehaus.mojo
+ flatten-maven-plugin
+
+ bom
+
+ remove
+ keep
+
+
+
+
+
+
diff --git a/exceptionless-core/pom.xml b/exceptionless-core/pom.xml
new file mode 100644
index 0000000..b509aad
--- /dev/null
+++ b/exceptionless-core/pom.xml
@@ -0,0 +1,78 @@
+
+
+ 4.0.0
+
+
+ com.exceptionless
+ exceptionless-parent
+ ${revision}
+
+
+ exceptionless-core
+ Exceptionless Core
+ Dependency-free Exceptionless client for Java: errors, logs, feature usage, 404s and sessions.
+
+
+ com.exceptionless.core
+
+
+
+
+ org.jspecify
+ jspecify
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+ org.awaitility
+ awaitility
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ default-testCompile
+
+
+ --add-modules=jdk.httpserver
+ --add-reads=com.exceptionless.core=jdk.httpserver
+
+
+
+
+
+
+ org.codehaus.mojo
+ build-helper-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ @{argLine} --add-modules=jdk.httpserver --add-reads=com.exceptionless.core=jdk.httpserver
+
+
+
+
+
+ src/main/resources
+ true
+
+
+
+
diff --git a/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java b/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java
new file mode 100644
index 0000000..1f1307c
--- /dev/null
+++ b/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java
@@ -0,0 +1,324 @@
+package com.exceptionless;
+
+import com.exceptionless.internal.DataNormalizer;
+import com.exceptionless.internal.Exclusions;
+import com.exceptionless.internal.Ids;
+import com.exceptionless.internal.Strings;
+import com.exceptionless.json.JsonSerializable;
+import com.exceptionless.models.Event;
+import com.exceptionless.models.EventTags;
+import com.exceptionless.models.ManualStackingInfo;
+import com.exceptionless.models.RequestInfo;
+import com.exceptionless.models.UserDescription;
+import com.exceptionless.models.UserInfo;
+import com.exceptionless.plugins.ContextData;
+import com.exceptionless.plugins.EventPluginContext;
+import java.math.BigDecimal;
+import java.time.OffsetDateTime;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Builds an event fluently and submits it:
+ *
+ *
+ *
+ * Builders are not thread-safe; create one per event.
+ */
+public final class EventBuilder {
+ private final ExceptionlessClient client;
+ private final Event event;
+ private final ContextData contextData;
+
+ EventBuilder(ExceptionlessClient client, Event event, ContextData contextData) {
+ this.client = client;
+ this.event = event;
+ this.contextData = contextData;
+ }
+
+ /** {@return the event being built} */
+ public Event getEvent() {
+ return event;
+ }
+
+ /** Data available to plugins but not sent to the server. */
+ public ContextData getContextData() {
+ return contextData;
+ }
+
+ public EventBuilder setType(@Nullable String type) {
+ if (!Strings.isBlank(type)) {
+ event.setType(type);
+ }
+ return this;
+ }
+
+ /** The source: the logger name for logs, the feature for usage, the path for 404s. */
+ public EventBuilder setSource(@Nullable String source) {
+ if (!Strings.isBlank(source)) {
+ event.setSource(source);
+ }
+ return this;
+ }
+
+ public EventBuilder setMessage(@Nullable String message) {
+ if (!Strings.isBlank(message)) {
+ event.setMessage(message);
+ }
+ return this;
+ }
+
+ /** Sets the log level ({@code @level}). */
+ public EventBuilder setLevel(@Nullable LogLevel level) {
+ event.setLevel(level == null ? null : level.value());
+ return this;
+ }
+
+ public EventBuilder setDate(OffsetDateTime date) {
+ event.setDate(date);
+ return this;
+ }
+
+ /**
+ * Sets a unique reference id for this occurrence, for example to show users and look the event up
+ * later. Must be 8-100 characters of letters, digits and dashes.
+ *
+ * @throws IllegalArgumentException if the id is invalid
+ */
+ public EventBuilder setReferenceId(String referenceId) {
+ if (!Ids.isValidReferenceId(referenceId)) {
+ throw new IllegalArgumentException(
+ "Reference id must be 8-100 characters of letters, digits and dashes: " + referenceId);
+ }
+ event.setReferenceId(referenceId);
+ return this;
+ }
+
+ /**
+ * Links this event to another event by reference id (stored as {@code @ref:}).
+ *
+ * @throws IllegalArgumentException if the name is blank or the id is invalid
+ */
+ public EventBuilder setEventReference(String name, String referenceId) {
+ if (Strings.isBlank(name)) {
+ throw new IllegalArgumentException("A reference name is required");
+ }
+ if (!Ids.isValidReferenceId(referenceId)) {
+ throw new IllegalArgumentException(
+ "Reference id must be 8-100 characters of letters, digits and dashes: " + referenceId);
+ }
+ event.setEventReference(name, referenceId);
+ return this;
+ }
+
+ /**
+ * Sets where the event happened.
+ *
+ * @throws IllegalArgumentException if the coordinates are out of range
+ */
+ public EventBuilder setGeo(double latitude, double longitude) {
+ if (latitude < -90.0 || latitude > 90.0) {
+ throw new IllegalArgumentException("Latitude must be between -90 and 90: " + latitude);
+ }
+ if (longitude < -180.0 || longitude > 180.0) {
+ throw new IllegalArgumentException("Longitude must be between -180 and 180: " + longitude);
+ }
+ event.setGeo(
+ BigDecimal.valueOf(latitude).toPlainString()
+ + ","
+ + BigDecimal.valueOf(longitude).toPlainString());
+ return this;
+ }
+
+ /** Sets a numeric value, e.g. a duration or amount, that the server aggregates. */
+ public EventBuilder setValue(@Nullable Number value) {
+ if (value == null) {
+ event.setValue(null);
+ } else if (value instanceof BigDecimal decimal) {
+ event.setValue(decimal);
+ } else if (value instanceof Double || value instanceof Float) {
+ double d = value.doubleValue();
+ if (!Double.isNaN(d) && !Double.isInfinite(d)) {
+ event.setValue(BigDecimal.valueOf(d));
+ }
+ } else {
+ event.setValue(new BigDecimal(value.toString()));
+ }
+ return this;
+ }
+
+ /** Sets how many times this event happened. */
+ public EventBuilder setCount(int count) {
+ event.setCount(count);
+ return this;
+ }
+
+ public EventBuilder addTags(String... tags) {
+ event.addTags(tags);
+ return this;
+ }
+
+ public EventBuilder addTags(Collection tags) {
+ event.addTags(tags);
+ return this;
+ }
+
+ /** Marks the event as critical. Critical events can trigger notifications. */
+ public EventBuilder markAsCritical() {
+ return markAsCritical(true);
+ }
+
+ public EventBuilder markAsCritical(boolean critical) {
+ if (critical) {
+ event.addTags(EventTags.CRITICAL);
+ } else {
+ event.removeTags(EventTags.CRITICAL);
+ }
+ return this;
+ }
+
+ /** Sets the application version ({@code @version}) for this event. */
+ public EventBuilder setVersion(@Nullable String version) {
+ event.setVersion(Strings.stripToNull(version));
+ return this;
+ }
+
+ public EventBuilder setUserIdentity(@Nullable String identity) {
+ return setUserIdentity(identity, null);
+ }
+
+ /** Sets the user the event happened to. Used for user tracking and sessions. */
+ public EventBuilder setUserIdentity(@Nullable String identity, @Nullable String name) {
+ if (Strings.isBlank(identity) && Strings.isBlank(name)) {
+ return this;
+ }
+ return setUserIdentity(new UserInfo(identity, name));
+ }
+
+ public EventBuilder setUserIdentity(@Nullable UserInfo user) {
+ event.setUserInfo(user);
+ return this;
+ }
+
+ /** Sets what the user said they were doing when the error happened. */
+ public EventBuilder setUserDescription(
+ @Nullable String emailAddress, @Nullable String description) {
+ if (Strings.isBlank(emailAddress) && Strings.isBlank(description)) {
+ return this;
+ }
+ event.setUserDescription(new UserDescription(emailAddress, description));
+ return this;
+ }
+
+ /** Groups all events with the same key into one stack, instead of the automatic stacking. */
+ public EventBuilder setManualStackingKey(String key) {
+ return setManualStackingKey(key, null);
+ }
+
+ public EventBuilder setManualStackingKey(String key, @Nullable String title) {
+ if (!Strings.isBlank(key)) {
+ event.setManualStackingInfo(ManualStackingInfo.ofKey(key, title));
+ }
+ return this;
+ }
+
+ /** Groups all events with the same signature data into one stack. */
+ public EventBuilder setManualStackingInfo(
+ Map signatureData, @Nullable String title) {
+ if (!signatureData.isEmpty()) {
+ event.setManualStackingInfo(new ManualStackingInfo(title, Map.copyOf(signatureData)));
+ }
+ return this;
+ }
+
+ /**
+ * Adds a custom property. Objects are converted to JSON using their public getters (up to 5
+ * levels deep). Names matching the data exclusions are skipped.
+ */
+ public EventBuilder setProperty(String name, @Nullable Object value) {
+ return setProperty(name, value, DataNormalizer.DEFAULT_MAX_DEPTH);
+ }
+
+ /**
+ * Adds a custom property.
+ *
+ * @param maxDepth how many levels of nested objects to include
+ * @param excludedPropertyNames extra property names (wildcards supported) to leave out
+ */
+ public EventBuilder setProperty(
+ String name, @Nullable Object value, int maxDepth, String... excludedPropertyNames) {
+ if (Strings.isBlank(name) || value == null) {
+ return this;
+ }
+ Exclusions excluded =
+ client
+ .getConfiguration()
+ .compiledDataExclusions()
+ .plus(Exclusions.of(List.of(excludedPropertyNames)));
+ if (excluded.test(name)) {
+ return this;
+ }
+ Object normalized =
+ value instanceof JsonSerializable
+ ? value
+ : DataNormalizer.normalize(value, maxDepth, excluded);
+ if (normalized != null) {
+ event.getData().put(name, normalized);
+ }
+ return this;
+ }
+
+ /** Sets the exception for this event. Its type is set to {@code error}. */
+ public EventBuilder setException(@Nullable Throwable exception) {
+ contextData.setException(exception);
+ return this;
+ }
+
+ /** Attaches the request the event happened during. Privacy settings are applied later. */
+ public EventBuilder setRequestInfo(@Nullable RequestInfo request) {
+ contextData.setRequestInfo(request);
+ return this;
+ }
+
+ /** Records how the event was captured, e.g. {@code servletFilter}. */
+ public EventBuilder setSubmissionMethod(@Nullable String submissionMethod) {
+ contextData.setSubmissionMethod(submissionMethod);
+ return this;
+ }
+
+ /** Marks the error as unhandled (it crashed a thread or request). */
+ public EventBuilder markAsUnhandledError(@Nullable String submissionMethod) {
+ contextData.markAsUnhandledError();
+ if (submissionMethod != null) {
+ contextData.setSubmissionMethod(submissionMethod);
+ }
+ return this;
+ }
+
+ /** Puts a value in the context data, which plugins can read but is not sent to the server. */
+ public EventBuilder setContextProperty(String name, @Nullable Object value) {
+ contextData.put(name, value);
+ return this;
+ }
+
+ /**
+ * Runs the event through the plugins and queues it. Returns the plugin context; check {@link
+ * EventPluginContext#isCancelled()} to see whether it was discarded.
+ */
+ public EventPluginContext submit() {
+ return client.submitEvent(event, contextData);
+ }
+
+ @Override
+ public String toString() {
+ return "EventBuilder{" + event + "}";
+ }
+}
diff --git a/exceptionless-core/src/main/java/com/exceptionless/Exceptionless.java b/exceptionless-core/src/main/java/com/exceptionless/Exceptionless.java
new file mode 100644
index 0000000..73f4636
--- /dev/null
+++ b/exceptionless-core/src/main/java/com/exceptionless/Exceptionless.java
@@ -0,0 +1,187 @@
+package com.exceptionless;
+
+import java.time.Duration;
+import java.util.function.Consumer;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The shared default {@link ExceptionlessClient}. Most applications only need this class:
+ *
+ *
The default client is configured from the environment ({@code exceptionless.properties},
+ * {@code EXCEPTIONLESS_API_KEY}/{@code EXCEPTIONLESS_SERVER_URL} and {@code exceptionless.*} system
+ * properties), so {@code Exceptionless.startup()} with no arguments is enough when the API key is
+ * provided that way. Logging integrations without their own API key use this client.
+ */
+public final class Exceptionless {
+ private static final Object LOCK = new Object();
+ private static volatile @Nullable ExceptionlessClient defaultClient;
+
+ private Exceptionless() {}
+
+ /** {@return the default client, created (but not started) on first use} */
+ public static ExceptionlessClient getDefault() {
+ ExceptionlessClient client = defaultClient;
+ if (client == null) {
+ synchronized (LOCK) {
+ client = defaultClient;
+ if (client == null) {
+ client = new ExceptionlessClient(ExceptionlessConfiguration.fromEnvironment());
+ defaultClient = client;
+ }
+ }
+ }
+ return client;
+ }
+
+ /** True when a default client exists (it may not have started yet). */
+ public static boolean hasDefault() {
+ return defaultClient != null;
+ }
+
+ /**
+ * Replaces the default client, for example with one created by a dependency injection container.
+ * The previous default client is not closed.
+ */
+ public static void setDefault(@Nullable ExceptionlessClient client) {
+ synchronized (LOCK) {
+ defaultClient = client;
+ }
+ }
+
+ /** Starts the default client using the configuration from the environment. */
+ public static ExceptionlessClient startup() {
+ return getDefault().startup();
+ }
+
+ /** Starts the default client for the project with the given API key. */
+ public static ExceptionlessClient startup(String apiKey) {
+ return startup(config -> config.apiKey(apiKey));
+ }
+
+ /**
+ * Configures and starts the default client.
+ *
+ *
If the default client was already started without a valid configuration (for example because
+ * something used it before the application configured it), it is replaced.
+ *
+ * @throws IllegalStateException if the default client is already running with a valid
+ * configuration
+ */
+ public static ExceptionlessClient startup(Consumer configure) {
+ synchronized (LOCK) {
+ ExceptionlessClient client = getDefault();
+ if (client.isStarted()) {
+ if (client.getConfiguration().isEnabled()) {
+ throw new IllegalStateException(
+ "The default Exceptionless client has already started; configure it before using it");
+ }
+ client.close();
+ client = new ExceptionlessClient(ExceptionlessConfiguration.fromEnvironment());
+ defaultClient = client;
+ }
+ configure.accept(client.getConfiguration());
+ return client.startup();
+ }
+ }
+
+ /** Closes the default client, sending queued events. A new one is created on next use. */
+ public static void shutdown() {
+ ExceptionlessClient client;
+ synchronized (LOCK) {
+ client = defaultClient;
+ defaultClient = null;
+ }
+ if (client != null) {
+ client.close();
+ }
+ }
+
+ /** Sends queued events and waits up to {@code timeout}. */
+ public static boolean flush(Duration timeout) {
+ return getDefault().flush(timeout);
+ }
+
+ public static ExceptionlessConfiguration getConfiguration() {
+ return getDefault().getConfiguration();
+ }
+
+ public static EventBuilder createEvent() {
+ return getDefault().createEvent();
+ }
+
+ public static EventBuilder createException(Throwable exception) {
+ return getDefault().createException(exception);
+ }
+
+ public static EventBuilder createUnhandledException(
+ Throwable exception, @Nullable String submissionMethod) {
+ return getDefault().createUnhandledException(exception, submissionMethod);
+ }
+
+ public static EventBuilder createLog(String message) {
+ return getDefault().createLog(message);
+ }
+
+ public static EventBuilder createLog(@Nullable String source, String message) {
+ return getDefault().createLog(source, message);
+ }
+
+ public static EventBuilder createLog(
+ @Nullable String source, String message, @Nullable LogLevel level) {
+ return getDefault().createLog(source, message, level);
+ }
+
+ public static EventBuilder createFeatureUsage(String feature) {
+ return getDefault().createFeatureUsage(feature);
+ }
+
+ public static EventBuilder createNotFound(String resource) {
+ return getDefault().createNotFound(resource);
+ }
+
+ public static void submitException(Throwable exception) {
+ getDefault().submitException(exception);
+ }
+
+ public static void submitUnhandledException(
+ Throwable exception, @Nullable String submissionMethod) {
+ getDefault().submitUnhandledException(exception, submissionMethod);
+ }
+
+ public static void submitLog(String message) {
+ getDefault().submitLog(message);
+ }
+
+ public static void submitLog(@Nullable String source, String message) {
+ getDefault().submitLog(source, message);
+ }
+
+ public static void submitLog(@Nullable String source, String message, @Nullable LogLevel level) {
+ getDefault().submitLog(source, message, level);
+ }
+
+ public static void submitFeatureUsage(String feature) {
+ getDefault().submitFeatureUsage(feature);
+ }
+
+ public static void submitNotFound(String resource) {
+ getDefault().submitNotFound(resource);
+ }
+
+ public static @Nullable String getLastReferenceId() {
+ return getDefault().getLastReferenceId();
+ }
+}
diff --git a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java
new file mode 100644
index 0000000..301348e
--- /dev/null
+++ b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java
@@ -0,0 +1,799 @@
+package com.exceptionless;
+
+import com.exceptionless.internal.DaemonThreadFactory;
+import com.exceptionless.internal.Ids;
+import com.exceptionless.internal.Log;
+import com.exceptionless.internal.SettingsManager;
+import com.exceptionless.internal.Strings;
+import com.exceptionless.models.Event;
+import com.exceptionless.models.EventTypes;
+import com.exceptionless.models.KnownDataKeys;
+import com.exceptionless.models.UserDescription;
+import com.exceptionless.models.UserInfo;
+import com.exceptionless.plugins.ContextData;
+import com.exceptionless.plugins.EventPlugin;
+import com.exceptionless.plugins.EventPluginContext;
+import com.exceptionless.plugins.HeartbeatPlugin;
+import com.exceptionless.queue.DefaultEventQueue;
+import com.exceptionless.queue.EventQueue;
+import com.exceptionless.storage.FileKeyValueStorage;
+import com.exceptionless.storage.FileQueueStorage;
+import com.exceptionless.storage.InMemoryKeyValueStorage;
+import com.exceptionless.storage.InMemoryQueueStorage;
+import com.exceptionless.storage.KeyValueStorage;
+import com.exceptionless.storage.QueueStorage;
+import com.exceptionless.submission.DefaultSubmissionClient;
+import com.exceptionless.submission.SubmissionClient;
+import com.exceptionless.submission.SubmissionResponse;
+import java.nio.file.Path;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Sends events to Exceptionless.
+ *
+ *
Submitting never blocks on the network: events run through the plugins on the calling thread,
+ * are queued, and are sent in batches by a single background daemon thread. Closing the client (or
+ * JVM shutdown) sends whatever is still queued.
+ *
+ *
The client starts on first use, or explicitly with {@link #startup()}. All methods are
+ * thread-safe.
+ */
+public final class ExceptionlessClient implements AutoCloseable {
+ private static final Log LOG = Log.get(ExceptionlessClient.class);
+ private static final ThreadLocal SUBMITTING = new ThreadLocal<>();
+
+ private enum State {
+ NEW,
+ STARTED,
+ CLOSED
+ }
+
+ private final ExceptionlessConfiguration config;
+ private final Clock clock;
+ private final Object lifecycleLock = new Object();
+ private volatile State state = State.NEW;
+
+ private volatile @Nullable ScheduledThreadPoolExecutor scheduler;
+ private volatile @Nullable EventQueue queue;
+ private volatile @Nullable SettingsManager settingsManager;
+ private volatile @Nullable SubmissionClient submissionClient;
+ private volatile @Nullable String installId;
+ private volatile @Nullable String lastReferenceId;
+
+ private final Set startedPlugins =
+ Collections.synchronizedSet(Collections.newSetFromMap(new IdentityHashMap<>()));
+ private final List> submittingHandlers = new CopyOnWriteArrayList<>();
+ private final List> submittedHandlers = new CopyOnWriteArrayList<>();
+
+ private @Nullable Thread shutdownHook;
+ private volatile boolean shuttingDownFromHook;
+ private Thread.@Nullable UncaughtExceptionHandler installedHandler;
+ private Thread.@Nullable UncaughtExceptionHandler previousHandler;
+
+ /** Creates a client. It starts automatically when first used. */
+ public ExceptionlessClient(ExceptionlessConfiguration configuration) {
+ // Event dates carry the local UTC offset, like the other Exceptionless clients.
+ this(configuration, Clock.system(ZoneId.systemDefault()));
+ }
+
+ /** For tests: {@code clock} dates new events, in its time zone. */
+ ExceptionlessClient(ExceptionlessConfiguration configuration, Clock clock) {
+ this.config = configuration;
+ this.clock = clock;
+ }
+
+ /**
+ * Creates and starts a client configured from the environment (see {@link
+ * ExceptionlessConfiguration#fromEnvironment()}) and then {@code configure}.
+ */
+ public static ExceptionlessClient create(Consumer configure) {
+ ExceptionlessConfiguration config = ExceptionlessConfiguration.fromEnvironment();
+ configure.accept(config);
+ return new ExceptionlessClient(config).startup();
+ }
+
+ /** Creates and starts a client for the project with the given API key. */
+ public static ExceptionlessClient create(String apiKey) {
+ return create(c -> c.apiKey(apiKey));
+ }
+
+ public ExceptionlessConfiguration getConfiguration() {
+ return config;
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Lifecycle
+
+ /**
+ * Starts background processing: sending queued events, refreshing project settings, the uncaught
+ * exception handler and shutdown hook (when enabled) and sessions (when enabled). Safe to call
+ * more than once.
+ */
+ public ExceptionlessClient startup() {
+ boolean startSession;
+ synchronized (lifecycleLock) {
+ if (state != State.NEW) {
+ return this;
+ }
+ config.lock();
+ initialize();
+ state = State.STARTED;
+ startSession = config.isEnabled() && config.isSessionsEnabled();
+ }
+ if (startSession) {
+ submitSessionStart();
+ }
+ return this;
+ }
+
+ private void initialize() {
+ List problems = config.validate();
+ if (!problems.isEmpty()) {
+ LOG.warn("Exceptionless is disabled: " + String.join("; ", problems));
+ }
+
+ QueueStorage queueStorage;
+ KeyValueStorage stateStorage;
+ Path storagePath = config.getStoragePath();
+ QueueStorage fileQueue = null;
+ KeyValueStorage fileState = null;
+ if (storagePath != null) {
+ Path directory = storagePath.resolve(storageFolder(config.getApiKey()));
+ try {
+ fileQueue = new FileQueueStorage(directory.resolve("queue"), config.getMaxQueueSize());
+ fileState = new FileKeyValueStorage(directory);
+ } catch (RuntimeException e) {
+ LOG.error("Unable to use storage path " + directory + "; keeping events in memory", e);
+ }
+ }
+ if (fileQueue != null && fileState != null) {
+ queueStorage = fileQueue;
+ stateStorage = fileState;
+ } else {
+ queueStorage = new InMemoryQueueStorage(config.getMaxQueueSize());
+ stateStorage = new InMemoryKeyValueStorage();
+ }
+
+ String id = stateStorage.read("install-id");
+ if (id == null || id.isBlank()) {
+ id = Ids.newGuid();
+ stateStorage.write("install-id", id);
+ }
+ installId = id.strip();
+
+ SubmissionClient client = config.getSubmissionClient();
+ if (client == null) {
+ client = new DefaultSubmissionClient(config);
+ }
+ submissionClient = client;
+
+ SettingsManager settings = new SettingsManager(config, client, stateStorage);
+ boolean loadedSettings = settings.loadSavedSettings();
+ settingsManager = settings;
+
+ EventQueue eventQueue = new DefaultEventQueue(config, client, queueStorage);
+ eventQueue.onEventsPosted((count, response) -> settings.checkVersion(response.configVersion()));
+ queue = eventQueue;
+
+ ScheduledThreadPoolExecutor executor =
+ new ScheduledThreadPoolExecutor(
+ 1, new DaemonThreadFactory(DaemonThreadFactory.CLIENT_THREAD_NAME));
+ executor.setRemoveOnCancelPolicy(true);
+ executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
+ executor.setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
+ scheduler = executor;
+
+ if (!config.isEnabled()) {
+ return;
+ }
+
+ Duration interval = config.getQueueProcessInterval();
+ Duration initialDelay =
+ interval.compareTo(Duration.ofSeconds(2)) < 0 ? interval : Duration.ofSeconds(2);
+ scheduleWithFixedDelay(eventQueue::process, initialDelay, interval);
+
+ Duration settingsDelay = Duration.ofSeconds(loadedSettings ? 15 : 5);
+ Duration settingsInterval = config.getUpdateSettingsWhenIdleInterval();
+ if (settingsInterval.isZero()) {
+ schedule(settings::updateSettings, settingsDelay);
+ } else {
+ scheduleWithFixedDelay(settings::updateSettings, settingsDelay, settingsInterval);
+ }
+
+ if (config.isCaptureUncaughtExceptions()) {
+ installUncaughtExceptionHandler();
+ }
+ if (config.isRegisterShutdownHook()) {
+ Thread hook =
+ new Thread(
+ () -> {
+ shuttingDownFromHook = true;
+ close();
+ },
+ DaemonThreadFactory.CLIENT_THREAD_NAME + "-shutdown");
+ try {
+ Runtime.getRuntime().addShutdownHook(hook);
+ shutdownHook = hook;
+ } catch (IllegalStateException e) {
+ // The JVM is already shutting down.
+ }
+ }
+ }
+
+ static String storageFolder(@Nullable String apiKey) {
+ if (apiKey == null || apiKey.isBlank()) {
+ return "default";
+ }
+ String prefix = apiKey.length() > 8 ? apiKey.substring(0, 8) : apiKey;
+ return prefix.replaceAll("[^A-Za-z0-9_-]", "_");
+ }
+
+ private void installUncaughtExceptionHandler() {
+ Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler();
+ Thread.UncaughtExceptionHandler handler =
+ (thread, error) -> {
+ try {
+ createUnhandledException(error, "uncaughtExceptionHandler")
+ .setProperty("thread", thread.getName())
+ .submit();
+ } catch (Throwable ignored) {
+ // Never let reporting prevent the normal handling.
+ }
+ if (previous != null) {
+ previous.uncaughtException(thread, error);
+ } else {
+ System.err.print("Exception in thread \"" + thread.getName() + "\" ");
+ error.printStackTrace(System.err);
+ }
+ };
+ Thread.setDefaultUncaughtExceptionHandler(handler);
+ installedHandler = handler;
+ previousHandler = previous;
+ }
+
+ /**
+ * Whether the default handler is still the one this client installed. Identity is what matters:
+ * if the application replaced it since, the replacement must be left alone.
+ */
+ @SuppressWarnings("ReferenceEquality")
+ private boolean isOurUncaughtExceptionHandlerInstalled() {
+ return installedHandler != null
+ && Thread.getDefaultUncaughtExceptionHandler() == installedHandler;
+ }
+
+ /** {@return whether the client has started and is not yet closed} */
+ public boolean isStarted() {
+ return state == State.STARTED;
+ }
+
+ /**
+ * Ends the session (when sessions are enabled), sends queued events (waiting up to the configured
+ * {@link ExceptionlessConfiguration#getShutdownTimeout() shutdown timeout}) and stops the
+ * background thread. Events submitted afterwards are discarded.
+ */
+ @Override
+ public void close() {
+ synchronized (lifecycleLock) {
+ if (state != State.STARTED) {
+ state = State.CLOSED;
+ return;
+ }
+ state = State.CLOSED;
+ }
+ long deadline = System.nanoTime() + config.getShutdownTimeout().toNanos();
+
+ Thread hook = shutdownHook;
+ if (hook != null && !shuttingDownFromHook) {
+ try {
+ Runtime.getRuntime().removeShutdownHook(hook);
+ } catch (IllegalStateException e) {
+ // Already shutting down.
+ }
+ }
+ if (isOurUncaughtExceptionHandlerInstalled()) {
+ Thread.setDefaultUncaughtExceptionHandler(previousHandler);
+ }
+
+ ScheduledThreadPoolExecutor executor = scheduler;
+ if (executor == null) {
+ return;
+ }
+ if (config.isEnabled()) {
+ if (config.isSessionsEnabled()) {
+ String identifier = sessionIdentifier(null);
+ if (identifier != null) {
+ await(runAsync(() -> sendHeartbeat(identifier, true)), deadline);
+ }
+ }
+ for (EventPlugin plugin : config.getPlugins()) {
+ if (startedPlugins.contains(plugin)) {
+ try {
+ plugin.suspend(this);
+ } catch (RuntimeException e) {
+ LOG.warn("Plugin " + plugin.getName() + " failed to suspend", e);
+ }
+ }
+ }
+ EventQueue eventQueue = queue;
+ if (eventQueue != null) {
+ await(runAsync(eventQueue::process), deadline);
+ }
+ }
+ executor.shutdownNow();
+ }
+
+ private static void await(Future> future, long deadlineNanos) {
+ long remaining = deadlineNanos - System.nanoTime();
+ try {
+ future.get(Math.max(0, remaining), TimeUnit.NANOSECONDS);
+ } catch (TimeoutException e) {
+ LOG.warn("Timed out sending queued events during shutdown");
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException e) {
+ LOG.warn("Error during shutdown", e.getCause());
+ }
+ }
+
+ private boolean ensureStarted() {
+ State current = state;
+ if (current == State.STARTED) {
+ return true;
+ }
+ if (current == State.NEW) {
+ startup();
+ return state == State.STARTED;
+ }
+ return false;
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Creating events
+
+ /** Creates an event of any type. */
+ public EventBuilder createEvent() {
+ Event event = new Event();
+ event.setDate(OffsetDateTime.now(clock));
+ return new EventBuilder(this, event, new ContextData());
+ }
+
+ /** Creates an error event for an exception. */
+ public EventBuilder createException(Throwable exception) {
+ return createEvent().setException(exception);
+ }
+
+ /**
+ * Creates an error event for an exception that crashed a thread or request.
+ *
+ * @param submissionMethod how it was captured, e.g. {@code servletFilter}
+ */
+ public EventBuilder createUnhandledException(
+ Throwable exception, @Nullable String submissionMethod) {
+ return createException(exception).markAsUnhandledError(submissionMethod);
+ }
+
+ public EventBuilder createLog(String message) {
+ return createEvent().setType(EventTypes.LOG).setMessage(message);
+ }
+
+ /** Creates a log event. The source is usually the logger name. */
+ public EventBuilder createLog(@Nullable String source, String message) {
+ return createLog(message).setSource(source);
+ }
+
+ public EventBuilder createLog(@Nullable String source, String message, @Nullable LogLevel level) {
+ return createLog(source, message).setLevel(level);
+ }
+
+ /** Creates a feature usage event, for tracking how often a feature is used. */
+ public EventBuilder createFeatureUsage(String feature) {
+ return createEvent().setType(EventTypes.FEATURE_USAGE).setSource(feature);
+ }
+
+ /** Creates a 404 (not found) event for a resource or path. */
+ public EventBuilder createNotFound(String resource) {
+ return createEvent().setType(EventTypes.NOT_FOUND).setSource(resource);
+ }
+
+ /** Creates a session start event. Sessions are tied to the user identity or a session id. */
+ public EventBuilder createSessionStart() {
+ return createEvent().setType(EventTypes.SESSION);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Submitting events
+
+ public void submitException(Throwable exception) {
+ createException(exception).submit();
+ }
+
+ public void submitUnhandledException(Throwable exception, @Nullable String submissionMethod) {
+ createUnhandledException(exception, submissionMethod).submit();
+ }
+
+ public void submitLog(String message) {
+ createLog(message).submit();
+ }
+
+ public void submitLog(@Nullable String source, String message) {
+ createLog(source, message).submit();
+ }
+
+ public void submitLog(@Nullable String source, String message, @Nullable LogLevel level) {
+ createLog(source, message, level).submit();
+ }
+
+ public void submitFeatureUsage(String feature) {
+ createFeatureUsage(feature).submit();
+ }
+
+ public void submitNotFound(String resource) {
+ createNotFound(resource).submit();
+ }
+
+ public void submitSessionStart() {
+ createSessionStart().submit();
+ }
+
+ /**
+ * Runs an event through the plugins and queues it.
+ *
+ * @return the plugin context; {@link EventPluginContext#isCancelled()} tells whether the event
+ * was discarded
+ */
+ public EventPluginContext submitEvent(Event event, @Nullable ContextData contextData) {
+ ContextData data = contextData != null ? contextData : new ContextData();
+ EventPluginContext context = new EventPluginContext(this, event, data);
+ if (!ensureStarted() || !config.isEnabled()) {
+ context.cancel();
+ return context;
+ }
+ if (SUBMITTING.get() != null) {
+ // Reentrant submission (e.g. a plugin logging through an Exceptionless appender).
+ context.cancel();
+ return context;
+ }
+ SUBMITTING.set(true);
+ try {
+ if (event.getDate() == null) {
+ event.setDate(OffsetDateTime.now(clock));
+ }
+ runPlugins(context);
+ if (context.isCancelled()) {
+ return context;
+ }
+ if (event.getType() == null) {
+ event.setType(EventTypes.LOG);
+ }
+ SubmittingEvent submitting = new SubmittingEvent(this, event, data);
+ for (Consumer handler : submittingHandlers) {
+ try {
+ handler.accept(submitting);
+ } catch (RuntimeException e) {
+ LOG.warn("SubmittingEvent handler failed", e);
+ }
+ }
+ if (submitting.isCancelled()) {
+ context.cancel();
+ return context;
+ }
+ EventQueue eventQueue = queue;
+ if (eventQueue == null) {
+ context.cancel();
+ return context;
+ }
+ eventQueue.enqueue(event);
+ if (event.getReferenceId() != null) {
+ lastReferenceId = event.getReferenceId();
+ }
+ for (Consumer handler : submittedHandlers) {
+ try {
+ handler.accept(event);
+ } catch (RuntimeException e) {
+ LOG.warn("SubmittedEvent handler failed", e);
+ }
+ }
+ return context;
+ } finally {
+ SUBMITTING.remove();
+ }
+ }
+
+ private void runPlugins(EventPluginContext context) {
+ for (EventPlugin plugin : config.getPlugins()) {
+ if (context.isCancelled()) {
+ return;
+ }
+ if (!startedPlugins.contains(plugin)) {
+ synchronized (startedPlugins) {
+ if (startedPlugins.add(plugin)) {
+ try {
+ plugin.startup(this);
+ } catch (RuntimeException e) {
+ LOG.warn("Plugin " + plugin.getName() + " failed to start", e);
+ }
+ }
+ }
+ }
+ try {
+ plugin.run(context);
+ } catch (RuntimeException | LinkageError e) {
+ LOG.error("Plugin " + plugin.getName() + " failed; continuing with the next plugin", e);
+ }
+ }
+ }
+
+ /**
+ * Adds a handler called after the plugins run and before the event is queued. Handlers can change
+ * the event or {@link SubmittingEvent#cancel() cancel} it.
+ */
+ public ExceptionlessClient addSubmittingEventHandler(Consumer handler) {
+ submittingHandlers.add(handler);
+ return this;
+ }
+
+ /** Adds a handler called after an event is queued. */
+ public ExceptionlessClient addSubmittedEventHandler(Consumer handler) {
+ submittedHandlers.add(handler);
+ return this;
+ }
+
+ /** {@return the reference id of the last error submitted, to show to users} */
+ public @Nullable String getLastReferenceId() {
+ return lastReferenceId;
+ }
+
+ /**
+ * Whether a log message from {@code source} at {@code level} would be sent according to the
+ * project's {@code @@log} settings, using {@code defaultMinimum} when no setting matches. Logging
+ * integrations call this before building events.
+ */
+ public boolean isLogLevelEnabled(
+ @Nullable String source, LogLevel level, @Nullable LogLevel defaultMinimum) {
+ return config.isLogLevelEnabled(source, level, defaultMinimum);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Sessions and user descriptions
+
+ /**
+ * Sends a session heartbeat for the given session id or user identity (default: the current
+ * session or the configured user).
+ */
+ public CompletableFuture submitSessionHeartbeat(
+ @Nullable String sessionIdOrUserId) {
+ String identifier = sessionIdentifier(sessionIdOrUserId);
+ if (identifier == null) {
+ return CompletableFuture.completedFuture(
+ SubmissionResponse.notSent("No session or user identity"));
+ }
+ return supplyAsync(() -> sendHeartbeat(identifier, false));
+ }
+
+ /** Ends a session (default: the current session or the configured user). */
+ public CompletableFuture submitSessionEnd(
+ @Nullable String sessionIdOrUserId) {
+ String identifier = sessionIdentifier(sessionIdOrUserId);
+ if (identifier == null) {
+ return CompletableFuture.completedFuture(
+ SubmissionResponse.notSent("No session or user identity"));
+ }
+ if (identifier.equals(config.getCurrentSessionIdentifier())) {
+ config.currentSessionIdentifier(null);
+ }
+ for (EventPlugin plugin : config.getPlugins()) {
+ if (plugin instanceof HeartbeatPlugin heartbeat) {
+ heartbeat.stop();
+ }
+ }
+ return supplyAsync(() -> sendHeartbeat(identifier, true));
+ }
+
+ private @Nullable String sessionIdentifier(@Nullable String explicit) {
+ if (!Strings.isBlank(explicit)) {
+ return explicit;
+ }
+ String current = config.getCurrentSessionIdentifier();
+ if (current != null) {
+ return current;
+ }
+ if (config.getDefaultData().get(KnownDataKeys.USER_INFO) instanceof UserInfo user
+ && !Strings.isBlank(user.identity())) {
+ return user.identity();
+ }
+ return null;
+ }
+
+ private SubmissionResponse sendHeartbeat(String identifier, boolean close) {
+ SubmissionClient client = submissionClient;
+ if (client == null || !config.isEnabled()) {
+ return SubmissionResponse.notSent("Exceptionless is disabled");
+ }
+ return client.sendHeartbeat(identifier, close);
+ }
+
+ /**
+ * Attaches a user's email address and description to a submitted event, for example from an error
+ * page where the user explains what they were doing.
+ */
+ public CompletableFuture updateUserEmailAndDescription(
+ String referenceId, @Nullable String emailAddress, @Nullable String description) {
+ if (!Ids.isValidReferenceId(referenceId)) {
+ throw new IllegalArgumentException("Invalid reference id: " + referenceId);
+ }
+ UserDescription userDescription = new UserDescription(emailAddress, description);
+ return supplyAsync(
+ () -> {
+ SubmissionClient client = submissionClient;
+ if (client == null || !config.isEnabled()) {
+ return SubmissionResponse.notSent("Exceptionless is disabled");
+ }
+ SubmissionResponse response = client.postUserDescription(referenceId, userDescription);
+ SettingsManager settings = settingsManager;
+ if (settings != null) {
+ settings.checkVersion(response.configVersion());
+ }
+ return response;
+ });
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Queue and background work
+
+ /** {@return the event queue} */
+ public EventQueue getQueue() {
+ ensureStarted();
+ EventQueue eventQueue = queue;
+ if (eventQueue == null) {
+ throw new IllegalStateException("The client has been closed");
+ }
+ return eventQueue;
+ }
+
+ /** Sends queued events now, on the background thread. */
+ public CompletableFuture processQueue() {
+ if (!ensureStarted()) {
+ return CompletableFuture.completedFuture(null);
+ }
+ EventQueue eventQueue = queue;
+ return eventQueue == null
+ ? CompletableFuture.completedFuture(null)
+ : runAsync(eventQueue::process);
+ }
+
+ /**
+ * Sends queued events and waits for them to be sent.
+ *
+ * @return true when the queue is empty afterwards
+ */
+ public boolean flush(Duration timeout) {
+ CompletableFuture future = processQueue();
+ try {
+ future.get(timeout.toNanos(), TimeUnit.NANOSECONDS);
+ } catch (TimeoutException e) {
+ return false;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return false;
+ } catch (ExecutionException e) {
+ return false;
+ }
+ EventQueue eventQueue = queue;
+ return eventQueue == null || eventQueue.size() == 0;
+ }
+
+ /** Fetches the latest project settings now. */
+ public CompletableFuture updateSettings() {
+ if (!ensureStarted()) {
+ return CompletableFuture.completedFuture(null);
+ }
+ SettingsManager settings = settingsManager;
+ return settings == null
+ ? CompletableFuture.completedFuture(null)
+ : runAsync(settings::updateSettings);
+ }
+
+ /** {@return a unique id for this installation, persisted when file storage is used} */
+ public @Nullable String getInstallId() {
+ ensureStarted();
+ return installId;
+ }
+
+ /**
+ * Runs a task periodically on the client's background thread. For plugins. The task stops when
+ * the client is closed.
+ */
+ public ScheduledFuture> scheduleAtFixedRate(
+ Runnable task, Duration initialDelay, Duration period) {
+ ensureStarted();
+ ScheduledThreadPoolExecutor executor = requireScheduler();
+ return executor.scheduleAtFixedRate(
+ safe(task), initialDelay.toMillis(), Math.max(1, period.toMillis()), TimeUnit.MILLISECONDS);
+ }
+
+ // The futures of the client's own tasks aren't needed: the tasks end when close() shuts the
+ // executor down, and safe() logs their failures.
+ @SuppressWarnings("FutureReturnValueIgnored")
+ private void scheduleWithFixedDelay(Runnable task, Duration initialDelay, Duration delay) {
+ requireScheduler()
+ .scheduleWithFixedDelay(
+ safe(task),
+ initialDelay.toMillis(),
+ Math.max(1, delay.toMillis()),
+ TimeUnit.MILLISECONDS);
+ }
+
+ @SuppressWarnings("FutureReturnValueIgnored") // see scheduleWithFixedDelay
+ private void schedule(Runnable task, Duration delay) {
+ requireScheduler().schedule(safe(task), delay.toMillis(), TimeUnit.MILLISECONDS);
+ }
+
+ private ScheduledThreadPoolExecutor requireScheduler() {
+ ScheduledThreadPoolExecutor executor = scheduler;
+ if (executor == null) {
+ throw new IllegalStateException("The client has not started");
+ }
+ return executor;
+ }
+
+ private CompletableFuture runAsync(Runnable task) {
+ return supplyAsync(
+ () -> {
+ task.run();
+ return null;
+ });
+ }
+
+ private CompletableFuture supplyAsync(Supplier task) {
+ ensureStarted();
+ ScheduledThreadPoolExecutor executor = scheduler;
+ if (executor == null || executor.isShutdown()) {
+ return CompletableFuture.failedFuture(new IllegalStateException("The client is not running"));
+ }
+ try {
+ return CompletableFuture.supplyAsync(task, executor);
+ } catch (RejectedExecutionException e) {
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
+ private static Runnable safe(Runnable task) {
+ return () -> {
+ try {
+ task.run();
+ } catch (RuntimeException e) {
+ LOG.error("Background task failed", e);
+ }
+ };
+ }
+}
diff --git a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java
new file mode 100644
index 0000000..e3c329d
--- /dev/null
+++ b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java
@@ -0,0 +1,984 @@
+package com.exceptionless;
+
+import com.exceptionless.internal.Exclusions;
+import com.exceptionless.internal.Log;
+import com.exceptionless.internal.Strings;
+import com.exceptionless.models.Event;
+import com.exceptionless.models.KnownDataKeys;
+import com.exceptionless.models.UserInfo;
+import com.exceptionless.plugins.CancelSessionsWithNoUserPlugin;
+import com.exceptionless.plugins.ConfigurationDefaultsPlugin;
+import com.exceptionless.plugins.DuplicateCheckerPlugin;
+import com.exceptionless.plugins.EnvironmentInfoPlugin;
+import com.exceptionless.plugins.ErrorPlugin;
+import com.exceptionless.plugins.EventExclusionPlugin;
+import com.exceptionless.plugins.EventPlugin;
+import com.exceptionless.plugins.EventPluginContext;
+import com.exceptionless.plugins.HeartbeatPlugin;
+import com.exceptionless.plugins.IgnoreUserAgentPlugin;
+import com.exceptionless.plugins.ReferenceIdPlugin;
+import com.exceptionless.plugins.RequestInfoPlugin;
+import com.exceptionless.plugins.SessionIdManagementPlugin;
+import com.exceptionless.plugins.SubmissionMethodPlugin;
+import com.exceptionless.plugins.VersionPlugin;
+import com.exceptionless.settings.ClientSettings;
+import com.exceptionless.submission.SubmissionClient;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.ProxySelector;
+import java.net.http.HttpClient;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import javax.net.ssl.SSLContext;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Configuration for an {@link ExceptionlessClient}. All setters return {@code this} so the
+ * configuration can be built fluently:
+ *
+ *
The connection settings (API key and server URLs) can't be changed once the client has
+ * started. Everything else can be changed at any time and is thread-safe.
+ */
+public final class ExceptionlessConfiguration {
+ public static final String DEFAULT_SERVER_URL = "https://collector.exceptionless.io";
+ public static final String DEFAULT_CONFIG_SERVER_URL = "https://config.exceptionless.io";
+ public static final String DEFAULT_HEARTBEAT_SERVER_URL = "https://heartbeat.exceptionless.io";
+
+ static final Duration MIN_UPDATE_SETTINGS_INTERVAL = Duration.ofMinutes(2);
+ static final Duration MIN_HEARTBEAT_INTERVAL = Duration.ofSeconds(30);
+
+ private static final Log LOG = Log.get(ExceptionlessConfiguration.class);
+
+ private volatile @Nullable String apiKey;
+ private volatile boolean apiKeyValid;
+ private volatile String serverUrl = DEFAULT_SERVER_URL;
+ private volatile String configServerUrl = DEFAULT_CONFIG_SERVER_URL;
+ private volatile String heartbeatServerUrl = DEFAULT_HEARTBEAT_SERVER_URL;
+ private volatile boolean enabled = true;
+ private volatile boolean locked;
+ private volatile @Nullable String version;
+
+ private final Set defaultTags = new CopyOnWriteArraySet<>();
+ private final Map defaultData = new ConcurrentHashMap<>();
+ private final Map settings = new ConcurrentHashMap<>();
+ private final List dataExclusions = new CopyOnWriteArrayList<>();
+ private final List userAgentBotPatterns = new CopyOnWriteArrayList<>();
+ private final List> eventExclusions = new CopyOnWriteArrayList<>();
+ private final List plugins = new CopyOnWriteArrayList<>();
+
+ private volatile ClientSettings serverSettings = ClientSettings.empty();
+ private volatile ClientSettings effectiveSettings = ClientSettings.empty();
+ // Compiled from the local data exclusions and the effective settings; rebuilt when either
+ // changes.
+ private volatile Exclusions compiledDataExclusions = Exclusions.none();
+ private final Object settingsLock = new Object();
+
+ private volatile boolean includePrivateInformation = true;
+ private volatile boolean includeUserName = true;
+ private volatile boolean includeMachineName = true;
+ private volatile boolean includeIpAddress = true;
+ private volatile boolean includeHeaders = true;
+ private volatile boolean includeCookies = true;
+ private volatile boolean includePostData = true;
+ private volatile boolean includeQueryString = true;
+ private volatile boolean includeModules = true;
+
+ private volatile boolean referenceIdsEnabled = true;
+ private volatile boolean sessionsEnabled;
+ private volatile boolean sendHeartbeats;
+ private volatile Duration heartbeatInterval = Duration.ofSeconds(60);
+ private volatile @Nullable String currentSessionIdentifier;
+
+ private volatile @Nullable LogLevel defaultMinLogLevel;
+
+ private volatile int submissionBatchSize = 50;
+ private volatile Duration queueProcessInterval = Duration.ofSeconds(10);
+ private volatile int queueMaxAttempts = 3;
+ private volatile Duration queueMaxAge = Duration.ofDays(2);
+ private volatile int maxQueueSize = 1000;
+ private volatile Duration updateSettingsWhenIdleInterval = MIN_UPDATE_SETTINGS_INTERVAL;
+ private volatile @Nullable Path storagePath;
+
+ private volatile Duration connectTimeout = Duration.ofSeconds(10);
+ private volatile Duration requestTimeout = Duration.ofSeconds(30);
+ private volatile @Nullable ProxySelector proxy;
+ private volatile @Nullable SSLContext sslContext;
+ private volatile @Nullable HttpClient httpClient;
+ private volatile @Nullable SubmissionClient submissionClient;
+
+ private volatile boolean captureUncaughtExceptions = true;
+ private volatile boolean registerShutdownHook = true;
+ private volatile Duration shutdownTimeout = Duration.ofSeconds(5);
+
+ public ExceptionlessConfiguration() {
+ addDefaultPlugins();
+ }
+
+ /**
+ * Creates a configuration populated from the environment, in increasing order of precedence:
+ * {@code exceptionless.properties} on the class path, {@code EXCEPTIONLESS_*} environment
+ * variables and {@code exceptionless.*} system properties.
+ *
+ *
Supported keys: {@code apiKey}, {@code serverUrl}, {@code configServerUrl}, {@code
+ * heartbeatServerUrl}, {@code enabled}, {@code version}, {@code storagePath}, {@code
+ * includePrivateInformation}, {@code defaultTags} and {@code settings.}. Environment
+ * variables use the upper snake case form, e.g. {@code EXCEPTIONLESS_API_KEY}.
+ */
+ public static ExceptionlessConfiguration fromEnvironment() {
+ ExceptionlessConfiguration config = new ExceptionlessConfiguration();
+ config.readFromEnvironment();
+ return config;
+ }
+
+ /** Applies {@code exceptionless.properties}, environment variables and system properties. */
+ public ExceptionlessConfiguration readFromEnvironment() {
+ readFromClasspathProperties();
+ readFromValues(System.getenv(), ExceptionlessConfiguration::environmentName);
+ Map systemProperties = new LinkedHashMap<>();
+ for (String name : System.getProperties().stringPropertyNames()) {
+ systemProperties.put(name, System.getProperty(name));
+ }
+ readFromValues(systemProperties, key -> "exceptionless." + key);
+ return this;
+ }
+
+ private void readFromClasspathProperties() {
+ ClassLoader loader = Thread.currentThread().getContextClassLoader();
+ if (loader == null) {
+ loader = ExceptionlessConfiguration.class.getClassLoader();
+ }
+ if (loader == null) {
+ return;
+ }
+ try (InputStream in = loader.getResourceAsStream("exceptionless.properties")) {
+ if (in == null) {
+ return;
+ }
+ Properties properties = new Properties();
+ properties.load(in);
+ Map values = new LinkedHashMap<>();
+ for (String name : properties.stringPropertyNames()) {
+ values.put(name, properties.getProperty(name));
+ }
+ readFromValues(values, key -> "exceptionless." + key);
+ readFromValues(values, key -> key);
+ } catch (IOException | IllegalArgumentException e) {
+ LOG.warn("Unable to read exceptionless.properties", e);
+ }
+ }
+
+ /**
+ * Reads configuration values from a map, using {@code keyMapper} to turn a setting name (such as
+ * {@code apiKey}) into the map key.
+ */
+ public ExceptionlessConfiguration readFromValues(
+ Map values, Function keyMapper) {
+ apply(values, keyMapper, "apiKey", this::apiKey);
+ apply(values, keyMapper, "serverUrl", this::serverUrl);
+ apply(values, keyMapper, "configServerUrl", this::configServerUrl);
+ apply(values, keyMapper, "heartbeatServerUrl", this::heartbeatServerUrl);
+ apply(values, keyMapper, "enabled", value -> enabled(Boolean.parseBoolean(value)));
+ apply(values, keyMapper, "version", this::version);
+ apply(values, keyMapper, "storagePath", value -> useFileStorage(Path.of(value)));
+ apply(
+ values,
+ keyMapper,
+ "includePrivateInformation",
+ value -> includePrivateInformation(Boolean.parseBoolean(value)));
+ apply(
+ values,
+ keyMapper,
+ "defaultTags",
+ value -> addDefaultTags(Strings.splitList(value).toArray(String[]::new)));
+ String settingsPrefix = keyMapper.apply("settings.");
+ for (Map.Entry entry : values.entrySet()) {
+ if (entry.getKey().startsWith(settingsPrefix)
+ && entry.getKey().length() > settingsPrefix.length()) {
+ setting(entry.getKey().substring(settingsPrefix.length()), entry.getValue());
+ }
+ }
+ return this;
+ }
+
+ /**
+ * {@code apiKey} becomes {@code EXCEPTIONLESS_API_KEY}; {@code settings.} becomes {@code
+ * EXCEPTIONLESS_SETTINGS_}.
+ */
+ static String environmentName(String key) {
+ StringBuilder sb = new StringBuilder("EXCEPTIONLESS_");
+ for (int i = 0; i < key.length(); i++) {
+ char c = key.charAt(i);
+ if (c == '.') {
+ sb.append('_');
+ } else if (Character.isUpperCase(c)) {
+ sb.append('_').append(c);
+ } else {
+ sb.append(Character.toUpperCase(c));
+ }
+ }
+ return sb.toString();
+ }
+
+ /** Passes the stripped value of {@code name} to {@code setter}, when it is set. */
+ private static void apply(
+ Map values,
+ Function keyMapper,
+ String name,
+ Consumer setter) {
+ String value = Strings.stripToNull(values.get(keyMapper.apply(name)));
+ if (value != null) {
+ setter.accept(value);
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Connection
+
+ public @Nullable String getApiKey() {
+ return apiKey;
+ }
+
+ /** Sets the project API key. Required. */
+ public ExceptionlessConfiguration apiKey(@Nullable String apiKey) {
+ ensureUnlocked("apiKey");
+ String key = Strings.stripToNull(apiKey);
+ this.apiKey = key;
+ this.apiKeyValid = isValidApiKey(key);
+ return this;
+ }
+
+ public String getServerUrl() {
+ return serverUrl;
+ }
+
+ /**
+ * Sets the server URL (for self hosted installs). Also sets the config and heartbeat server URLs,
+ * which can then be overridden individually.
+ */
+ public ExceptionlessConfiguration serverUrl(String serverUrl) {
+ ensureUnlocked("serverUrl");
+ String url = normalizeUrl(serverUrl);
+ this.serverUrl = url;
+ this.configServerUrl = url;
+ this.heartbeatServerUrl = url;
+ return this;
+ }
+
+ public String getConfigServerUrl() {
+ return configServerUrl;
+ }
+
+ public ExceptionlessConfiguration configServerUrl(String configServerUrl) {
+ ensureUnlocked("configServerUrl");
+ this.configServerUrl = normalizeUrl(configServerUrl);
+ return this;
+ }
+
+ public String getHeartbeatServerUrl() {
+ return heartbeatServerUrl;
+ }
+
+ public ExceptionlessConfiguration heartbeatServerUrl(String heartbeatServerUrl) {
+ ensureUnlocked("heartbeatServerUrl");
+ this.heartbeatServerUrl = normalizeUrl(heartbeatServerUrl);
+ return this;
+ }
+
+ private static String normalizeUrl(String url) {
+ String stripped = Strings.stripToNull(url);
+ if (stripped == null) {
+ throw new IllegalArgumentException("A server URL is required");
+ }
+ while (stripped.endsWith("/")) {
+ stripped = stripped.substring(0, stripped.length() - 1);
+ }
+ if (!stripped.startsWith("http://") && !stripped.startsWith("https://")) {
+ throw new IllegalArgumentException("Server URL must start with http:// or https://: " + url);
+ }
+ return stripped;
+ }
+
+ /** Whether events are sent. A configuration without a valid API key is never enabled. */
+ public boolean isEnabled() {
+ return enabled && apiKeyValid;
+ }
+
+ public ExceptionlessConfiguration enabled(boolean enabled) {
+ this.enabled = enabled;
+ return this;
+ }
+
+ /** Returns a list of configuration problems; empty when the configuration is valid. */
+ public List validate() {
+ List problems = new ArrayList<>();
+ String key = apiKey;
+ if (key == null) {
+ problems.add("An API key is required");
+ } else if (!isValidApiKey(key)) {
+ problems.add("The API key is invalid");
+ }
+ return problems;
+ }
+
+ private static boolean isValidApiKey(@Nullable String key) {
+ return key != null
+ && !key.equals("API_KEY_HERE")
+ && key.length() >= 10
+ && key.chars().noneMatch(Character::isWhitespace);
+ }
+
+ boolean isLocked() {
+ return locked;
+ }
+
+ void lock() {
+ locked = true;
+ }
+
+ private void ensureUnlocked(String property) {
+ if (locked) {
+ throw new IllegalStateException(
+ "The " + property + " can't be changed after the Exceptionless client has started");
+ }
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Event defaults
+
+ public @Nullable String getVersion() {
+ return version;
+ }
+
+ /** Sets the application version, attached to every event as {@code @version}. */
+ public ExceptionlessConfiguration version(@Nullable String version) {
+ this.version = Strings.stripToNull(version);
+ return this;
+ }
+
+ /** {@return a snapshot of the tags added to every event} */
+ public Set getDefaultTags() {
+ return Collections.unmodifiableSet(new LinkedHashSet<>(defaultTags));
+ }
+
+ public ExceptionlessConfiguration addDefaultTags(String... tags) {
+ for (String tag : tags) {
+ String stripped = Strings.stripToNull(tag);
+ if (stripped != null) {
+ defaultTags.add(stripped);
+ }
+ }
+ return this;
+ }
+
+ public ExceptionlessConfiguration removeDefaultTags(String... tags) {
+ for (String tag : tags) {
+ defaultTags.remove(tag);
+ }
+ return this;
+ }
+
+ /** {@return a snapshot of the data added to every event} */
+ public Map getDefaultData() {
+ return Map.copyOf(defaultData);
+ }
+
+ /** Adds data to every event. The value is normalized when events are created. */
+ public ExceptionlessConfiguration addDefaultData(String key, @Nullable Object value) {
+ if (value == null) {
+ defaultData.remove(key);
+ } else {
+ defaultData.put(key, value);
+ }
+ return this;
+ }
+
+ /** Sets the user attached to every event (for example in a desktop or CLI app). */
+ public ExceptionlessConfiguration userIdentity(@Nullable String identity) {
+ return userIdentity(identity, null);
+ }
+
+ public ExceptionlessConfiguration userIdentity(@Nullable String identity, @Nullable String name) {
+ if (Strings.isBlank(identity) && Strings.isBlank(name)) {
+ return addDefaultData(KnownDataKeys.USER_INFO, null);
+ }
+ return addDefaultData(KnownDataKeys.USER_INFO, new UserInfo(identity, name));
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Settings, exclusions and privacy
+
+ /**
+ * {@return the effective configuration values: local settings overlaid with the project's
+ * settings}
+ */
+ public ClientSettings getSettings() {
+ return effectiveSettings;
+ }
+
+ /** {@return the settings most recently received from the server} */
+ public ClientSettings getServerSettings() {
+ return serverSettings;
+ }
+
+ /**
+ * Sets a local configuration value, e.g. {@code setting("@@log:com.example.*", "info")}. Values
+ * received from the server take precedence.
+ */
+ public ExceptionlessConfiguration setting(String key, @Nullable String value) {
+ if (value == null) {
+ settings.remove(key);
+ } else {
+ settings.put(key, value);
+ }
+ recomputeSettings();
+ return this;
+ }
+
+ /**
+ * Applies settings received from the server. Called by the client when it loads or fetches the
+ * project settings; applications normally don't need to call it.
+ */
+ public void applyServerSettings(Map values) {
+ this.serverSettings = ClientSettings.of(values);
+ recomputeSettings();
+ }
+
+ private void recomputeSettings() {
+ synchronized (settingsLock) {
+ effectiveSettings = ClientSettings.of(Map.copyOf(settings), serverSettings.asMap());
+ compiledDataExclusions = Exclusions.of(getDataExclusions());
+ }
+ }
+
+ /**
+ * Excludes data with matching names (wildcards supported, e.g. {@code *password*}) from event
+ * data, exception properties, cookies, query strings and form data.
+ */
+ public ExceptionlessConfiguration addDataExclusions(String... patterns) {
+ for (String pattern : patterns) {
+ String stripped = Strings.stripToNull(pattern);
+ if (stripped != null) {
+ dataExclusions.add(stripped);
+ }
+ }
+ synchronized (settingsLock) {
+ compiledDataExclusions = Exclusions.of(getDataExclusions());
+ }
+ return this;
+ }
+
+ /** Local data exclusions plus the project's {@code @@DataExclusions} setting. */
+ public List getDataExclusions() {
+ List server = effectiveSettings.getStringList(ClientSettings.DATA_EXCLUSIONS);
+ if (server.isEmpty()) {
+ return List.copyOf(dataExclusions);
+ }
+ List all = new ArrayList<>(dataExclusions);
+ all.addAll(server);
+ return List.copyOf(all);
+ }
+
+ /**
+ * Whether data named {@code name} matches the {@link #getDataExclusions() data exclusions}. The
+ * patterns are compiled once and recompiled when exclusions or settings change.
+ */
+ public boolean isDataExcluded(String name) {
+ return compiledDataExclusions.test(name);
+ }
+
+ /** A snapshot of the compiled data exclusions. */
+ Exclusions compiledDataExclusions() {
+ return compiledDataExclusions;
+ }
+
+ /** Ignores requests whose user agent matches (wildcards supported, e.g. {@code *bot*}). */
+ public ExceptionlessConfiguration addUserAgentBotPatterns(String... patterns) {
+ for (String pattern : patterns) {
+ String stripped = Strings.stripToNull(pattern);
+ if (stripped != null) {
+ userAgentBotPatterns.add(stripped);
+ }
+ }
+ return this;
+ }
+
+ /** Local bot patterns plus the project's {@code @@UserAgentBotPatterns} setting. */
+ public List getUserAgentBotPatterns() {
+ List server = effectiveSettings.getStringList(ClientSettings.USER_AGENT_BOT_PATTERNS);
+ if (server.isEmpty()) {
+ return List.copyOf(userAgentBotPatterns);
+ }
+ List all = new ArrayList<>(userAgentBotPatterns);
+ all.addAll(server);
+ return List.copyOf(all);
+ }
+
+ /** Adds a callback that returns {@code true} for events that should be discarded. */
+ public ExceptionlessConfiguration addEventExclusion(Predicate exclusion) {
+ eventExclusions.add(exclusion);
+ return this;
+ }
+
+ public List> getEventExclusions() {
+ return List.copyOf(eventExclusions);
+ }
+
+ /**
+ * Turns all private information on or off: user names, machine name, IP addresses, headers,
+ * cookies, form data and query strings. The project's {@code @@IncludePrivateInformation} setting
+ * can also turn it off.
+ */
+ public ExceptionlessConfiguration includePrivateInformation(boolean include) {
+ this.includePrivateInformation = include;
+ this.includeUserName = include;
+ this.includeMachineName = include;
+ this.includeIpAddress = include;
+ this.includeHeaders = include;
+ this.includeCookies = include;
+ this.includePostData = include;
+ this.includeQueryString = include;
+ return this;
+ }
+
+ public boolean isIncludePrivateInformation() {
+ return includePrivateInformation
+ && effectiveSettings.getBoolean(ClientSettings.INCLUDE_PRIVATE_INFORMATION, true);
+ }
+
+ public boolean isIncludeUserName() {
+ return includeUserName && isIncludePrivateInformationSetting();
+ }
+
+ public ExceptionlessConfiguration includeUserName(boolean include) {
+ this.includeUserName = include;
+ return this;
+ }
+
+ public boolean isIncludeMachineName() {
+ return includeMachineName && isIncludePrivateInformationSetting();
+ }
+
+ public ExceptionlessConfiguration includeMachineName(boolean include) {
+ this.includeMachineName = include;
+ return this;
+ }
+
+ public boolean isIncludeIpAddress() {
+ return includeIpAddress && isIncludePrivateInformationSetting();
+ }
+
+ public ExceptionlessConfiguration includeIpAddress(boolean include) {
+ this.includeIpAddress = include;
+ return this;
+ }
+
+ public boolean isIncludeHeaders() {
+ return includeHeaders && isIncludePrivateInformationSetting();
+ }
+
+ public ExceptionlessConfiguration includeHeaders(boolean include) {
+ this.includeHeaders = include;
+ return this;
+ }
+
+ public boolean isIncludeCookies() {
+ return includeCookies && isIncludePrivateInformationSetting();
+ }
+
+ public ExceptionlessConfiguration includeCookies(boolean include) {
+ this.includeCookies = include;
+ return this;
+ }
+
+ public boolean isIncludePostData() {
+ return includePostData && isIncludePrivateInformationSetting();
+ }
+
+ public ExceptionlessConfiguration includePostData(boolean include) {
+ this.includePostData = include;
+ return this;
+ }
+
+ public boolean isIncludeQueryString() {
+ return includeQueryString && isIncludePrivateInformationSetting();
+ }
+
+ public ExceptionlessConfiguration includeQueryString(boolean include) {
+ this.includeQueryString = include;
+ return this;
+ }
+
+ private boolean isIncludePrivateInformationSetting() {
+ return effectiveSettings.getBoolean(ClientSettings.INCLUDE_PRIVATE_INFORMATION, true);
+ }
+
+ /** Whether the libraries on the class path are attached to errors. */
+ public boolean isIncludeModules() {
+ return includeModules;
+ }
+
+ public ExceptionlessConfiguration includeModules(boolean include) {
+ this.includeModules = include;
+ return this;
+ }
+
+ /**
+ * The minimum level for log events from {@code source} when no {@code @@log:*} setting matches.
+ * {@code null} (the default) sends all log events.
+ */
+ public @Nullable LogLevel getDefaultMinLogLevel() {
+ return defaultMinLogLevel;
+ }
+
+ public ExceptionlessConfiguration defaultMinLogLevel(@Nullable LogLevel level) {
+ this.defaultMinLogLevel = level;
+ return this;
+ }
+
+ /**
+ * Whether a log message from {@code source} at {@code level} would be sent, using the project's
+ * {@code @@log:} settings and falling back to {@code defaultMinimum}.
+ */
+ public boolean isLogLevelEnabled(
+ @Nullable String source, LogLevel level, @Nullable LogLevel defaultMinimum) {
+ LogLevel minimum = effectiveSettings.getMinLogLevel(source);
+ if (minimum == null) {
+ minimum = defaultMinimum;
+ }
+ return minimum == null || level.isAtLeast(minimum);
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Reference ids and sessions
+
+ /** Whether error events get a random reference id (on by default). */
+ public boolean isReferenceIdsEnabled() {
+ return referenceIdsEnabled;
+ }
+
+ public ExceptionlessConfiguration useReferenceIds(boolean enabled) {
+ this.referenceIdsEnabled = enabled;
+ return this;
+ }
+
+ /** Enables sessions with heartbeats every 60 seconds. */
+ public ExceptionlessConfiguration useSessions() {
+ return useSessions(true, Duration.ofSeconds(60), false);
+ }
+
+ /**
+ * Enables session tracking.
+ *
+ * @param sendHeartbeats keep the session alive while the app runs
+ * @param heartbeatInterval how often to send heartbeats (at least 30 seconds)
+ * @param useSessionIdManagement generate session ids on the client instead of using the user
+ * identity (for apps without a signed-in user)
+ */
+ public ExceptionlessConfiguration useSessions(
+ boolean sendHeartbeats, Duration heartbeatInterval, boolean useSessionIdManagement) {
+ this.sessionsEnabled = true;
+ this.sendHeartbeats = sendHeartbeats;
+ this.heartbeatInterval =
+ heartbeatInterval.compareTo(MIN_HEARTBEAT_INTERVAL) < 0
+ ? MIN_HEARTBEAT_INTERVAL
+ : heartbeatInterval;
+ if (sendHeartbeats) {
+ addPlugin(new HeartbeatPlugin());
+ }
+ if (useSessionIdManagement) {
+ addPlugin(new SessionIdManagementPlugin());
+ }
+ return this;
+ }
+
+ public boolean isSessionsEnabled() {
+ return sessionsEnabled;
+ }
+
+ public boolean isSendHeartbeats() {
+ return sendHeartbeats;
+ }
+
+ public Duration getHeartbeatInterval() {
+ return heartbeatInterval;
+ }
+
+ /** The session id or user identity heartbeats are sent for. Managed by the session plugins. */
+ public @Nullable String getCurrentSessionIdentifier() {
+ return currentSessionIdentifier;
+ }
+
+ public ExceptionlessConfiguration currentSessionIdentifier(@Nullable String identifier) {
+ this.currentSessionIdentifier = identifier;
+ return this;
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Queue, storage and settings polling
+
+ public int getSubmissionBatchSize() {
+ return submissionBatchSize;
+ }
+
+ public ExceptionlessConfiguration submissionBatchSize(int size) {
+ if (size < 1) {
+ throw new IllegalArgumentException("submissionBatchSize must be at least 1");
+ }
+ this.submissionBatchSize = size;
+ return this;
+ }
+
+ public Duration getQueueProcessInterval() {
+ return queueProcessInterval;
+ }
+
+ /** How often queued events are sent (default 10 seconds). */
+ public ExceptionlessConfiguration queueProcessInterval(Duration interval) {
+ if (interval.isNegative() || interval.isZero()) {
+ throw new IllegalArgumentException("queueProcessInterval must be positive");
+ }
+ this.queueProcessInterval = interval;
+ return this;
+ }
+
+ public int getQueueMaxAttempts() {
+ return queueMaxAttempts;
+ }
+
+ /** How many times an event is retried before it is discarded (default 3). */
+ public ExceptionlessConfiguration queueMaxAttempts(int attempts) {
+ this.queueMaxAttempts = Math.max(1, attempts);
+ return this;
+ }
+
+ public Duration getQueueMaxAge() {
+ return queueMaxAge;
+ }
+
+ /**
+ * How long an event may wait in the queue before it is discarded (default 2 days). The server
+ * discards events older than 3 days.
+ */
+ public ExceptionlessConfiguration queueMaxAge(Duration maxAge) {
+ this.queueMaxAge = maxAge;
+ return this;
+ }
+
+ public int getMaxQueueSize() {
+ return maxQueueSize;
+ }
+
+ /** The maximum number of queued events; the oldest are dropped when full (default 1000). */
+ public ExceptionlessConfiguration maxQueueSize(int maxQueueSize) {
+ this.maxQueueSize = Math.max(1, maxQueueSize);
+ return this;
+ }
+
+ public Duration getUpdateSettingsWhenIdleInterval() {
+ return updateSettingsWhenIdleInterval;
+ }
+
+ /**
+ * How often to check for new project settings (at least 2 minutes). {@link Duration#ZERO}
+ * disables polling; settings are still refreshed when the server reports a new version.
+ */
+ public ExceptionlessConfiguration updateSettingsWhenIdleInterval(Duration interval) {
+ if (interval.isZero() || interval.isNegative()) {
+ this.updateSettingsWhenIdleInterval = Duration.ZERO;
+ } else {
+ this.updateSettingsWhenIdleInterval =
+ interval.compareTo(MIN_UPDATE_SETTINGS_INTERVAL) < 0
+ ? MIN_UPDATE_SETTINGS_INTERVAL
+ : interval;
+ }
+ return this;
+ }
+
+ public @Nullable Path getStoragePath() {
+ return storagePath;
+ }
+
+ /**
+ * Persists queued events and settings under {@code path}, so events survive restarts. Each API
+ * key gets its own sub directory.
+ */
+ public ExceptionlessConfiguration useFileStorage(@Nullable Path path) {
+ ensureUnlocked("storagePath");
+ this.storagePath = path;
+ return this;
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // HTTP
+
+ public Duration getConnectTimeout() {
+ return connectTimeout;
+ }
+
+ public ExceptionlessConfiguration connectTimeout(Duration timeout) {
+ this.connectTimeout = timeout;
+ return this;
+ }
+
+ public Duration getRequestTimeout() {
+ return requestTimeout;
+ }
+
+ public ExceptionlessConfiguration requestTimeout(Duration timeout) {
+ this.requestTimeout = timeout;
+ return this;
+ }
+
+ public @Nullable ProxySelector getProxy() {
+ return proxy;
+ }
+
+ /** Sends requests through a proxy, e.g. {@code ProxySelector.of(new InetSocketAddress(...))}. */
+ public ExceptionlessConfiguration proxy(@Nullable ProxySelector proxy) {
+ ensureUnlocked("proxy");
+ this.proxy = proxy;
+ return this;
+ }
+
+ public @Nullable SSLContext getSslContext() {
+ return sslContext;
+ }
+
+ /** Uses a custom TLS configuration (for example to trust a private CA). */
+ public ExceptionlessConfiguration sslContext(@Nullable SSLContext sslContext) {
+ ensureUnlocked("sslContext");
+ this.sslContext = sslContext;
+ return this;
+ }
+
+ public @Nullable HttpClient getHttpClient() {
+ return httpClient;
+ }
+
+ /** Uses your own {@link HttpClient}; the proxy, TLS and connect timeout options are ignored. */
+ public ExceptionlessConfiguration httpClient(@Nullable HttpClient httpClient) {
+ ensureUnlocked("httpClient");
+ this.httpClient = httpClient;
+ return this;
+ }
+
+ public @Nullable SubmissionClient getSubmissionClient() {
+ return submissionClient;
+ }
+
+ /** Replaces how events are sent. Mostly useful for tests. */
+ public ExceptionlessConfiguration submissionClient(@Nullable SubmissionClient submissionClient) {
+ ensureUnlocked("submissionClient");
+ this.submissionClient = submissionClient;
+ return this;
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Lifecycle
+
+ public boolean isCaptureUncaughtExceptions() {
+ return captureUncaughtExceptions;
+ }
+
+ /** Reports exceptions that reach the default uncaught exception handler (on by default). */
+ public ExceptionlessConfiguration captureUncaughtExceptions(boolean capture) {
+ this.captureUncaughtExceptions = capture;
+ return this;
+ }
+
+ public boolean isRegisterShutdownHook() {
+ return registerShutdownHook;
+ }
+
+ /** Sends queued events when the JVM shuts down (on by default). */
+ public ExceptionlessConfiguration registerShutdownHook(boolean register) {
+ this.registerShutdownHook = register;
+ return this;
+ }
+
+ public Duration getShutdownTimeout() {
+ return shutdownTimeout;
+ }
+
+ /** How long {@link ExceptionlessClient#close()} waits for queued events to be sent. */
+ public ExceptionlessConfiguration shutdownTimeout(Duration timeout) {
+ this.shutdownTimeout = timeout;
+ return this;
+ }
+
+ // ---------------------------------------------------------------------------------------------
+ // Plugins
+
+ /** {@return the plugins in the order they run (ascending priority)} */
+ public List getPlugins() {
+ return plugins.stream().sorted(Comparator.comparingInt(EventPlugin::getPriority)).toList();
+ }
+
+ /** Adds a plugin, replacing any existing plugin with the same name. */
+ public ExceptionlessConfiguration addPlugin(EventPlugin plugin) {
+ removePlugin(plugin.getName());
+ plugins.add(plugin);
+ return this;
+ }
+
+ /** Adds a plugin from a lambda. Lower priorities run first. See {@link EventPlugin#of}. */
+ public ExceptionlessConfiguration addPlugin(
+ String name, int priority, Consumer plugin) {
+ return addPlugin(EventPlugin.of(name, priority, plugin));
+ }
+
+ public ExceptionlessConfiguration removePlugin(String name) {
+ plugins.removeIf(p -> p.getName().equals(name));
+ return this;
+ }
+
+ public ExceptionlessConfiguration removePlugin(Class extends EventPlugin> type) {
+ plugins.removeIf(type::isInstance);
+ return this;
+ }
+
+ public boolean hasPlugin(String name) {
+ return plugins.stream().anyMatch(p -> p.getName().equals(name));
+ }
+
+ private void addDefaultPlugins() {
+ Collection defaults =
+ List.of(
+ new ConfigurationDefaultsPlugin(),
+ new ErrorPlugin(),
+ new ReferenceIdPlugin(),
+ new EventExclusionPlugin(),
+ new EnvironmentInfoPlugin(),
+ new RequestInfoPlugin(),
+ new IgnoreUserAgentPlugin(),
+ new VersionPlugin(),
+ new SubmissionMethodPlugin(),
+ new CancelSessionsWithNoUserPlugin(),
+ new DuplicateCheckerPlugin());
+ plugins.addAll(defaults);
+ }
+}
diff --git a/exceptionless-core/src/main/java/com/exceptionless/LogLevel.java b/exceptionless-core/src/main/java/com/exceptionless/LogLevel.java
new file mode 100644
index 0000000..f3ee3ed
--- /dev/null
+++ b/exceptionless-core/src/main/java/com/exceptionless/LogLevel.java
@@ -0,0 +1,67 @@
+package com.exceptionless;
+
+import java.util.Locale;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Log levels understood by Exceptionless, in increasing order of severity.
+ *
+ *
Like any enum, {@link #toString()} (and JSON serialization of a level used as a data value)
+ * gives the constant name, e.g. {@code ERROR}; {@link #value()} is the lowercase form the server
+ * expects in an event's {@code @level}.
+ */
+public enum LogLevel {
+ /** The most detailed diagnostics, e.g. method entry and exit. */
+ TRACE("trace"),
+ /** Diagnostics useful while developing or debugging. */
+ DEBUG("debug"),
+ /** Normal operation worth recording. */
+ INFO("info"),
+ /** Something unexpected that the application recovered from. */
+ WARN("warn"),
+ /** A failure of the current operation. */
+ ERROR("error"),
+ /** A failure the application can't recover from. */
+ FATAL("fatal"),
+ /** Disables logging. Only meaningful as a minimum level. */
+ OFF("off");
+
+ private final String value;
+
+ LogLevel(String value) {
+ this.value = value;
+ }
+
+ /** {@return the value sent to the server in {@code @level}, e.g. {@code error}} */
+ public String value() {
+ return value;
+ }
+
+ /** True when this level is at least as severe as {@code minimum}. */
+ public boolean isAtLeast(LogLevel minimum) {
+ return this != OFF && compareTo(minimum) >= 0;
+ }
+
+ /**
+ * Parses a level name or setting value. Accepts the level names plus the aliases used by project
+ * settings and the other clients: {@code true/yes/1} (trace), {@code false/no/0} (off), {@code
+ * warning}, {@code information}, {@code critical}, {@code severe}, {@code fine...}.
+ *
+ * @return the level, or {@code null} when the value isn't recognized
+ */
+ public static @Nullable LogLevel parse(@Nullable String value) {
+ if (value == null) {
+ return null;
+ }
+ return switch (value.strip().toLowerCase(Locale.ROOT)) {
+ case "trace", "true", "yes", "1", "finest", "finer", "all", "verbose" -> TRACE;
+ case "debug", "fine", "config" -> DEBUG;
+ case "info", "information" -> INFO;
+ case "warn", "warning" -> WARN;
+ case "error", "severe" -> ERROR;
+ case "fatal", "critical" -> FATAL;
+ case "off", "false", "no", "0", "none" -> OFF;
+ default -> null;
+ };
+ }
+}
diff --git a/exceptionless-core/src/main/java/com/exceptionless/SubmittingEvent.java b/exceptionless-core/src/main/java/com/exceptionless/SubmittingEvent.java
new file mode 100644
index 0000000..f00a0b1
--- /dev/null
+++ b/exceptionless-core/src/main/java/com/exceptionless/SubmittingEvent.java
@@ -0,0 +1,46 @@
+package com.exceptionless;
+
+import com.exceptionless.models.Event;
+import com.exceptionless.plugins.ContextData;
+
+/**
+ * Passed to {@link ExceptionlessClient#addSubmittingEventHandler} handlers after the plugins run
+ * and before the event is queued. Call {@link #cancel()} to discard the event.
+ */
+public final class SubmittingEvent {
+ private final ExceptionlessClient client;
+ private final Event event;
+ private final ContextData contextData;
+ private boolean cancelled;
+
+ SubmittingEvent(ExceptionlessClient client, Event event, ContextData contextData) {
+ this.client = client;
+ this.event = event;
+ this.contextData = contextData;
+ }
+
+ /** {@return the client submitting the event} */
+ public ExceptionlessClient getClient() {
+ return client;
+ }
+
+ /** The event about to be queued. Handlers may change it. */
+ public Event getEvent() {
+ return event;
+ }
+
+ /** Data available to handlers but not sent to the server, such as the exception. */
+ public ContextData getContextData() {
+ return contextData;
+ }
+
+ /** Whether a handler {@link #cancel() cancelled} the event. */
+ public boolean isCancelled() {
+ return cancelled;
+ }
+
+ /** Discards the event; it is not queued. */
+ public void cancel() {
+ this.cancelled = true;
+ }
+}
diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/ClientInfo.java b/exceptionless-core/src/main/java/com/exceptionless/internal/ClientInfo.java
new file mode 100644
index 0000000..2c3929c
--- /dev/null
+++ b/exceptionless-core/src/main/java/com/exceptionless/internal/ClientInfo.java
@@ -0,0 +1,49 @@
+package com.exceptionless.internal;
+
+import java.io.InputStream;
+import java.util.Properties;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** The client's name and version, as reported to the server. */
+public final class ClientInfo {
+ public static final String NAME = "exceptionless-java";
+
+ /** The full version, e.g. {@code 0.9.0-SNAPSHOT}. */
+ public static final String VERSION = loadVersion();
+
+ /**
+ * The {@code User-Agent} header. The server only records a client version that parses as {@code
+ * major.minor[.build[.revision]]}, so pre-release suffixes are dropped.
+ */
+ public static final String USER_AGENT = NAME + "/" + numericVersion(VERSION);
+
+ private ClientInfo() {}
+
+ private static String loadVersion() {
+ try (InputStream in =
+ ClientInfo.class.getResourceAsStream("/com/exceptionless/client.properties")) {
+ if (in != null) {
+ Properties properties = new Properties();
+ properties.load(in);
+ String version = properties.getProperty("version");
+ String stripped = Strings.stripToNull(version);
+ if (stripped != null && !stripped.startsWith("${")) {
+ return stripped;
+ }
+ }
+ } catch (Exception e) {
+ // Fall through to the default.
+ }
+ return "0.9.0";
+ }
+
+ static String numericVersion(String version) {
+ Matcher matcher = Pattern.compile("^(\\d+)(\\.\\d+)?(\\.\\d+)?(\\.\\d+)?").matcher(version);
+ if (!matcher.find()) {
+ return "0.9.0";
+ }
+ String result = matcher.group();
+ return result.contains(".") ? result : result + ".0";
+ }
+}
diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/DaemonThreadFactory.java b/exceptionless-core/src/main/java/com/exceptionless/internal/DaemonThreadFactory.java
new file mode 100644
index 0000000..f1e06f2
--- /dev/null
+++ b/exceptionless-core/src/main/java/com/exceptionless/internal/DaemonThreadFactory.java
@@ -0,0 +1,24 @@
+package com.exceptionless.internal;
+
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/** Creates daemon threads so the client never keeps the JVM alive. */
+public final class DaemonThreadFactory implements ThreadFactory {
+ /** The name prefix of the client's background threads ({@code exceptionless-1}, ...). */
+ public static final String CLIENT_THREAD_NAME = "exceptionless";
+
+ private final String prefix;
+ private final AtomicInteger counter = new AtomicInteger();
+
+ public DaemonThreadFactory(String prefix) {
+ this.prefix = prefix;
+ }
+
+ @Override
+ public Thread newThread(Runnable runnable) {
+ Thread thread = new Thread(runnable, prefix + "-" + counter.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ }
+}
diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/DataNormalizer.java b/exceptionless-core/src/main/java/com/exceptionless/internal/DataNormalizer.java
new file mode 100644
index 0000000..99a9120
--- /dev/null
+++ b/exceptionless-core/src/main/java/com/exceptionless/internal/DataNormalizer.java
@@ -0,0 +1,353 @@
+package com.exceptionless.internal;
+
+import com.exceptionless.json.JsonSerializable;
+import java.io.File;
+import java.lang.reflect.AccessibleObject;
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.RecordComponent;
+import java.net.URI;
+import java.net.URL;
+import java.nio.charset.Charset;
+import java.nio.file.Path;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.TemporalAccessor;
+import java.time.temporal.TemporalAmount;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Currency;
+import java.util.Date;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.UUID;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Converts arbitrary objects into a JSON-friendly tree of maps, lists and simple values, applying a
+ * depth limit and property-name exclusions. The result can always be serialized.
+ *
+ *
Plain objects are read through their public getters and fields. Records are read through their
+ * components. Anything that can't be read is converted with {@code toString()}. The readable
+ * properties of each class are looked up once and cached.
+ */
+public final class DataNormalizer {
+ /** Default depth used for custom properties. */
+ public static final int DEFAULT_MAX_DEPTH = 5;
+
+ private static final int MAX_ITEMS = 1000;
+ private static final Log LOG = Log.get(DataNormalizer.class);
+
+ /** The getters and public fields of a class, in the order they are read. */
+ private static final ClassValue> PROPERTIES =
+ new ClassValue<>() {
+ @Override
+ protected List computeValue(Class> type) {
+ return properties(type);
+ }
+ };
+
+ /** A readable property: a getter or a public instance field. */
+ private record Property(String name, AccessibleObject member) {
+ @Nullable Object read(Object target) throws ReflectiveOperationException {
+ return member instanceof Method method ? method.invoke(target) : ((Field) member).get(target);
+ }
+ }
+
+ private final int maxDepth;
+ private final Predicate excluded;
+ private final Set