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! +[![Build](https://github.com/exceptionless/Exceptionless.Java/actions/workflows/build.yml/badge.svg)](https://github.com/exceptionless/Exceptionless.Java/actions/workflows/build.yml) +[![Maven Central](https://img.shields.io/maven-central/v/com.exceptionless/exceptionless-core)](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 -[![contributors](https://contributors-img.web.app/image?repo=exceptionless/Exceptionless.Java)](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: + * + *
{@code
+ * client.createException(e)
+ *     .addTags("checkout")
+ *     .setProperty("order", order)
+ *     .setUserIdentity(user.getId(), user.getName())
+ *     .submit();
+ * }
+ * + * 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: + * + *
{@code
+ * public static void main(String[] args) {
+ *   Exceptionless.startup("YOUR_API_KEY");
+ *
+ *   Exceptionless.submitLog("App started");
+ *   try {
+ *     run();
+ *   } catch (Exception e) {
+ *     Exceptionless.submitException(e);
+ *   }
+ * }
+ * }
+ * + *

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. + * + *

{@code
+ * try (ExceptionlessClient client = ExceptionlessClient.create(c -> c.apiKey("YOUR_API_KEY"))) {
+ *   client.submitLog("App started");
+ *   try {
+ *     doWork();
+ *   } catch (Exception e) {
+ *     client.createException(e).addTags("work").submit();
+ *   }
+ * }
+ * }
+ * + *

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: + * + *

{@code
+ * ExceptionlessClient client = ExceptionlessClient.create(c -> c
+ *     .apiKey("YOUR_API_KEY")
+ *     .version("1.2.3")
+ *     .addDefaultTags("backend")
+ *     .useSessions());
+ * }
+ * + *

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 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 visiting = Collections.newSetFromMap(new IdentityHashMap<>()); + + private DataNormalizer(int maxDepth, Predicate excluded) { + this.maxDepth = maxDepth; + this.excluded = excluded; + } + + /** + * Normalizes {@code value}. + * + * @param maxDepth how many levels of nested objects to keep; deeper objects are dropped + * @param excluded returns true for property names that must not be included + */ + public static @Nullable Object normalize( + @Nullable Object value, int maxDepth, Predicate excluded) { + return new DataNormalizer(Math.max(0, maxDepth), excluded).convert(value, 0); + } + + /** + * Normalizes the public properties of {@code bean} into a map, even when it is a type (such as a + * {@link Throwable}) that {@link #normalize} would otherwise summarize. + */ + public static Map normalizeProperties( + Object bean, int maxDepth, Predicate excluded) { + DataNormalizer normalizer = new DataNormalizer(Math.max(1, maxDepth), excluded); + normalizer.visiting.add(bean); + Object result = normalizer.convertBean(bean, 0); + @SuppressWarnings("unchecked") + Map map = + result instanceof Map m ? (Map) m : new LinkedHashMap<>(); + return map; + } + + /** + * True when {@code value} is a single value with no nested properties to normalize. Except for + * strings, numbers and booleans, these are converted with {@link #simpleValueToString}. {@link + * com.exceptionless.json.JsonWriter} uses the same rules. + */ + public static boolean isSimpleValue(@Nullable Object value) { + return value == null + || value instanceof CharSequence + || value instanceof Number + || value instanceof Boolean + || value instanceof Character + || value instanceof Enum + || value instanceof TemporalAccessor + || value instanceof TemporalAmount + || value instanceof Date + || value instanceof UUID + || value instanceof URI + || value instanceof URL + || value instanceof File + || value instanceof Path + || value instanceof Locale + || value instanceof Currency + || value instanceof TimeZone + || value instanceof Charset + || value instanceof Pattern + || value instanceof Class; + } + + /** + * Converts a {@link #isSimpleValue simple value} to the string sent to the server: enum names, + * class names, ISO 8601 dates and otherwise {@code toString()}. + */ + public static String simpleValueToString(Object value) { + if (value instanceof Enum enumValue) { + return enumValue.name(); + } + if (value instanceof Class type) { + return type.getName(); + } + if (value instanceof OffsetDateTime dateTime) { + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(dateTime); + } + if (value instanceof Date date) { + return date.toInstant().toString(); + } + return value.toString(); + } + + private @Nullable Object convert(@Nullable Object value, int depth) { + if (value == null) { + return null; + } + if (value instanceof CharSequence) { + return value.toString(); + } + if (value instanceof Number || value instanceof Boolean || value instanceof JsonSerializable) { + return value; + } + if (isSimpleValue(value)) { + return simpleValueToString(value); + } + if (value instanceof Optional optional) { + return convert(optional.orElse(null), depth); + } + if (value instanceof byte[] bytes) { + return "[" + bytes.length + " bytes]"; + } + if (depth >= maxDepth) { + return null; + } + if (!visiting.add(value)) { + return null; // circular reference + } + try { + if (value instanceof Map map) { + return convertMap(map, depth); + } + if (value instanceof Iterable iterable) { + List<@Nullable Object> list = new ArrayList<>(); + for (Object item : iterable) { + if (list.size() >= MAX_ITEMS) { + break; + } + list.add(convert(item, depth + 1)); + } + return list; + } + if (value.getClass().isArray()) { + int length = Math.min(Array.getLength(value), MAX_ITEMS); + List<@Nullable Object> list = new ArrayList<>(length); + for (int i = 0; i < length; i++) { + list.add(convert(Array.get(value, i), depth + 1)); + } + return list; + } + if (value instanceof Throwable throwable) { + Map map = new LinkedHashMap<>(); + map.put("type", throwable.getClass().getName()); + if (throwable.getMessage() != null) { + map.put("message", throwable.getMessage()); + } + return map; + } + if (value.getClass().isRecord()) { + return convertRecord(value, depth); + } + return convertBean(value, depth); + } finally { + visiting.remove(value); + } + } + + private Map convertMap(Map map, int depth) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if (result.size() >= MAX_ITEMS) { + break; + } + String key = String.valueOf(entry.getKey()); + if (excluded.test(key)) { + continue; + } + Object converted = convert(entry.getValue(), depth + 1); + if (converted != null) { + result.put(key, converted); + } + } + return result; + } + + private Object convertRecord(Object value, int depth) { + Map result = new LinkedHashMap<>(); + try { + for (RecordComponent component : value.getClass().getRecordComponents()) { + String name = component.getName(); + if (excluded.test(name)) { + continue; + } + Method accessor = component.getAccessor(); + if (!accessor.canAccess(value)) { + accessor.setAccessible(true); + } + put(result, name, accessor.invoke(value), depth); + } + return result; + } catch (ReflectiveOperationException | RuntimeException e) { + LOG.debug(() -> "Unable to read record " + value.getClass().getName() + ": " + e); + return safeToString(value); + } + } + + private Object convertBean(Object value, int depth) { + Class type = value.getClass(); + List properties = PROPERTIES.get(type); + if (properties.isEmpty()) { + return safeToString(value); + } + Map result = new LinkedHashMap<>(); + for (Property property : properties) { + String name = property.name(); + if (excluded.test(name) || result.containsKey(name)) { + continue; + } + if (!accessible(property.member(), value)) { + continue; + } + try { + put(result, name, property.read(value), depth); + } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { + LOG.trace(() -> "Unable to read property " + name + " of " + type.getName() + ": " + e); + } + } + return result; + } + + /** The getters, then the public instance fields, of {@code type}. */ + private static List properties(Class type) { + List properties = new ArrayList<>(); + try { + for (Method method : type.getMethods()) { + String name = propertyName(method); + if (name != null) { + properties.add(new Property(name, method)); + } + } + for (Field field : type.getFields()) { + if (!Modifier.isStatic(field.getModifiers())) { + properties.add(new Property(field.getName(), field)); + } + } + } catch (RuntimeException | LinkageError e) { + LOG.debug(() -> "Unable to read the properties of " + type.getName() + ": " + e); + } + return List.copyOf(properties); + } + + /** + * Public members of non-public classes (such as package-private exception types) need {@code + * setAccessible}, which only works when the package is open to this library (always the case on + * the class path). + */ + private static boolean accessible(AccessibleObject member, Object target) { + try { + return member.canAccess(target) || member.trySetAccessible(); + } catch (RuntimeException e) { + return false; + } + } + + private void put(Map result, String name, @Nullable Object raw, int depth) { + Object converted = convert(raw, depth + 1); + if (converted != null) { + result.put(name, converted); + } + } + + private static @Nullable String propertyName(Method method) { + if (method.getParameterCount() != 0 + || Modifier.isStatic(method.getModifiers()) + || method.getReturnType() == void.class + || method.getDeclaringClass() == Object.class + || method.isBridge() + || method.isSynthetic()) { + return null; + } + String name = method.getName(); + if (name.startsWith("get") && name.length() > 3) { + return decapitalize(name.substring(3)); + } + if (name.startsWith("is") + && name.length() > 2 + && (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class)) { + return decapitalize(name.substring(2)); + } + return null; + } + + private static String decapitalize(String name) { + if (name.length() > 1 && Character.isUpperCase(name.charAt(1))) { + return name; // e.g. getURL -> URL + } + return Character.toLowerCase(name.charAt(0)) + name.substring(1); + } + + private static String safeToString(Object value) { + try { + return String.valueOf(value); + } catch (RuntimeException e) { + return value.getClass().getName(); + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/EnvironmentCollector.java b/exceptionless-core/src/main/java/com/exceptionless/internal/EnvironmentCollector.java new file mode 100644 index 0000000..95b8808 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/EnvironmentCollector.java @@ -0,0 +1,177 @@ +package com.exceptionless.internal; + +import com.exceptionless.models.EnvironmentInfo; +import java.lang.management.ManagementFactory; +import java.lang.management.OperatingSystemMXBean; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Collects information about the machine and JVM. Values that don't change while the JVM runs are + * computed once; the slow ones (host name and network lookups) lazily, through holder classes, so + * they are only computed when the privacy settings allow sending them. + */ +public final class EnvironmentCollector { + private static final Log LOG = Log.get(EnvironmentCollector.class); + + private static final @Nullable String PROCESS_NAME = processName(); + private static final String RUNTIME_VERSION = runtimeVersion(); + + private static final class MachineName { + static final @Nullable String VALUE = lookUpMachineName(); + } + + private static final class IpAddresses { + static final @Nullable String VALUE = lookUpIpAddresses(); + } + + private EnvironmentCollector() {} + + /** Collects the current environment. Never throws. */ + public static EnvironmentInfo collect( + boolean includeMachineName, boolean includeIpAddress, @Nullable String installId) { + EnvironmentInfo info = new EnvironmentInfo(); + try { + Runtime runtime = Runtime.getRuntime(); + info.setProcessorCount(runtime.availableProcessors()); + info.setProcessMemorySize(runtime.totalMemory()); + info.setProcessName(PROCESS_NAME); + // Deliberately not the real command line: program arguments routinely contain secrets. + info.setCommandLine(PROCESS_NAME); + info.setProcessId(Long.toString(ProcessHandle.current().pid())); + Thread thread = Thread.currentThread(); + info.setThreadName(thread.getName()); + info.setThreadId(Long.toString(threadId(thread))); + info.setArchitecture(System.getProperty("os.arch")); + info.setOsName(System.getProperty("os.name")); + info.setOsVersion(System.getProperty("os.version")); + info.setRuntimeVersion(RUNTIME_VERSION); + info.setInstallId(installId); + putIfNotNull(info, "java.vendor", System.getProperty("java.vendor")); + putIfNotNull(info, "java.vm.name", System.getProperty("java.vm.name")); + putIfNotNull(info, "file.encoding", System.getProperty("file.encoding")); + addMemory(info); + if (includeMachineName) { + info.setMachineName(machineName()); + } + if (includeIpAddress) { + info.setIpAddress(ipAddresses()); + } + } catch (RuntimeException | LinkageError e) { + LOG.debug(() -> "Unable to collect environment info: " + e); + } + return info; + } + + @SuppressWarnings("deprecation") // Thread.threadId() is only available on Java 19+. + private static long threadId(Thread thread) { + return thread.getId(); + } + + private static void putIfNotNull(EnvironmentInfo info, String key, @Nullable String value) { + if (value != null) { + info.getData().put(key, value); + } + } + + private static void addMemory(EnvironmentInfo info) { + try { + MemoryProbe.addMemory(info); + } catch (RuntimeException | LinkageError e) { + // java.management or jdk.management isn't available (for example in a jlinked runtime). + } + } + + /** Isolated so that a missing java.management module only fails this class. */ + private static final class MemoryProbe { + static void addMemory(EnvironmentInfo info) { + OperatingSystemMXBean os = ManagementFactory.getOperatingSystemMXBean(); + if (os instanceof com.sun.management.OperatingSystemMXBean sun) { + info.setTotalPhysicalMemory(sun.getTotalMemorySize()); + info.setAvailablePhysicalMemory(sun.getFreeMemorySize()); + } + } + } + + static @Nullable String machineName() { + return MachineName.VALUE; + } + + static @Nullable String ipAddresses() { + return IpAddresses.VALUE; + } + + private static @Nullable String lookUpMachineName() { + String name = Strings.stripToNull(System.getenv("HOSTNAME")); + if (name == null) { + name = Strings.stripToNull(System.getenv("COMPUTERNAME")); + } + if (name == null) { + try { + name = Strings.stripToNull(InetAddress.getLocalHost().getHostName()); + } catch (Exception e) { + LOG.debug(() -> "Unable to look up the host name: " + e); + } + } + return name; + } + + private static @Nullable String lookUpIpAddresses() { + List list = new ArrayList<>(); + try { + for (NetworkInterface nic : Collections.list(NetworkInterface.getNetworkInterfaces())) { + if (!nic.isUp() || nic.isLoopback() || nic.isVirtual()) { + continue; + } + for (InetAddress address : Collections.list(nic.getInetAddresses())) { + if (address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isAnyLocalAddress()) { + continue; + } + String host = address.getHostAddress(); + if (address instanceof Inet6Address) { + int scope = host.indexOf('%'); + if (scope > 0) { + host = host.substring(0, scope); + } + } + if (!list.contains(host)) { + list.add(host); + } + } + } + } catch (Exception e) { + LOG.debug(() -> "Unable to read network interfaces: " + e); + } + return list.isEmpty() ? null : String.join(",", list); + } + + /** + * The file name of the main class or jar (e.g. {@code com.example.App} or {@code app.jar}). This + * is also reported as the command line: the program arguments are deliberately left out because + * they can contain secrets (see {@link JavaCommand#mainClassOrJar()}). + */ + private static @Nullable String processName() { + String mainClassOrJar = JavaCommand.mainClassOrJar(); + if (mainClassOrJar != null) { + return JavaCommand.fileName(mainClassOrJar); + } + try { + return ProcessHandle.current().info().command().orElse("java"); + } catch (RuntimeException e) { + return "java"; + } + } + + private static String runtimeVersion() { + String version = System.getProperty("java.runtime.version", System.getProperty("java.version")); + String vendor = System.getProperty("java.vendor"); + return "Java " + version + (vendor != null ? " (" + vendor + ")" : ""); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/ErrorConverter.java b/exceptionless-core/src/main/java/com/exceptionless/internal/ErrorConverter.java new file mode 100644 index 0000000..e16c537 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/ErrorConverter.java @@ -0,0 +1,224 @@ +package com.exceptionless.internal; + +import com.exceptionless.models.ErrorInfo; +import com.exceptionless.models.KnownDataKeys; +import com.exceptionless.models.ModuleInfo; +import com.exceptionless.models.StackFrame; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** Converts a {@link Throwable} (and its causes) into the Exceptionless error model. */ +public final class ErrorConverter { + /** Maximum number of causes followed. */ + public static final int MAX_CAUSES = 20; + + /** Maximum frames kept per error. The top of the stack is kept; it decides stacking. */ + static final int MAX_FRAMES = 256; + + /** Maximum suppressed exceptions recorded per error. */ + static final int MAX_SUPPRESSED = 10; + + private static final int EXTENDED_DATA_DEPTH = 3; + private static final Set IGNORED_PROPERTIES = + Set.of( + "message", + "localizedMessage", + "cause", + "stackTrace", + "suppressed", + "class", + "ourStackTrace", + "rootCause", + "mostSpecificCause"); + + private static final Pattern LAMBDA = + Pattern.compile("\\$\\$Lambda(?:\\$\\d+)?(?:/0x[0-9a-fA-F]+)?"); + private static final Pattern CGLIB = + Pattern.compile( + "\\$\\$(EnhancerBySpringCGLIB|FastClassBySpringCGLIB|SpringCGLIB|EnhancerByCGLIB|FastClassByCGLIB)\\$\\$[0-9a-fA-F]+"); + private static final Pattern HIBERNATE_PROXY = Pattern.compile("\\$HibernateProxy\\$\\w+"); + private static final Pattern JDK_PROXY = Pattern.compile("^\\$Proxy\\d+$"); + private static final Pattern JDK_PROXY_PACKAGE = Pattern.compile("^jdk\\.proxy\\d+$"); + private static final Pattern GENERATED_ACCESSOR = + Pattern.compile( + "^(GeneratedMethodAccessor|GeneratedConstructorAccessor|GeneratedSerializationConstructorAccessor)\\d+$"); + + private ErrorConverter() {} + + /** + * Converts {@code throwable}. + * + * @param excluded data exclusion predicate applied to extended exception properties + * @param modules modules to attach to the outermost error (may be empty) + */ + public static ErrorInfo convert( + Throwable throwable, Predicate excluded, List modules) { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + ErrorInfo root = convertChain(throwable, null, excluded, seen, 0); + root.getModules().addAll(modules); + return root; + } + + private static ErrorInfo convertChain( + Throwable throwable, + StackTraceElement @Nullable [] enclosingTrace, + Predicate excluded, + Set seen, + int level) { + seen.add(throwable); + ErrorInfo error = new ErrorInfo(); + error.setType(throwable.getClass().getName()); + error.setMessage(safeMessage(throwable)); + + StackTraceElement[] trace = safeStackTrace(throwable); + addFrames(error, trace, enclosingTrace); + addExtendedData(error, throwable, excluded); + addSuppressed(error, throwable, trace, excluded, seen, level); + + Throwable cause = safeCause(throwable); + if (cause != null && level + 1 < MAX_CAUSES && !seen.contains(cause)) { + error.setInner(convertChain(cause, trace, excluded, seen, level + 1)); + } + return error; + } + + private static void addFrames( + ErrorInfo error, StackTraceElement[] trace, StackTraceElement @Nullable [] enclosing) { + int end = trace.length; + if (enclosing != null) { + // Like Throwable.printStackTrace: drop the frames this cause shares with its enclosing trace. + int m = trace.length - 1; + int n = enclosing.length - 1; + while (m >= 0 && n >= 0 && trace[m].equals(enclosing[n])) { + m--; + n--; + } + // Always keep the top frame so every error in the chain has a location. + end = Math.max(m + 1, Math.min(1, trace.length)); + } + int limit = Math.min(end, MAX_FRAMES); + for (int i = 0; i < limit; i++) { + error.getStackTrace().add(toFrame(trace[i])); + } + if (end > limit) { + error.getData().put("frames_omitted", end - limit); + } + } + + /** Converts a single stack trace element. */ + public static StackFrame toFrame(StackTraceElement element) { + StackFrame frame = new StackFrame(); + String className = element.getClassName(); + int lastDot = className.lastIndexOf('.'); + String namespace = lastDot > 0 ? className.substring(0, lastDot) : null; + String type = lastDot > 0 ? className.substring(lastDot + 1) : className; + if (namespace != null && JDK_PROXY_PACKAGE.matcher(namespace).matches()) { + namespace = "jdk.proxy"; + } + frame.setDeclaringNamespace(namespace); + frame.setDeclaringType(normalizeTypeName(type)); + frame.setName(element.getMethodName()); + frame.setFileName(element.getFileName()); + if (element.getLineNumber() > 0) { + frame.setLineNumber(element.getLineNumber()); + } + if (element.isNativeMethod()) { + frame.getData().put("is_native", true); + } + return frame; + } + + /** + * Removes the unstable parts of generated class names (lambdas, CGLIB/Hibernate/JDK proxies) so + * the same code always produces the same stack signature. + */ + static String normalizeTypeName(String type) { + String result = LAMBDA.matcher(type).replaceAll("\\$\\$Lambda"); + result = CGLIB.matcher(result).replaceAll("\\$\\$$1"); + result = HIBERNATE_PROXY.matcher(result).replaceAll("\\$HibernateProxy"); + result = JDK_PROXY.matcher(result).replaceAll("\\$Proxy"); + result = GENERATED_ACCESSOR.matcher(result).replaceAll("$1"); + return result; + } + + private static void addExtendedData( + ErrorInfo error, Throwable throwable, Predicate excluded) { + try { + Map properties = + DataNormalizer.normalizeProperties( + throwable, + EXTENDED_DATA_DEPTH, + name -> IGNORED_PROPERTIES.contains(name) || excluded.test(name)); + if (!properties.isEmpty()) { + error.getData().put(KnownDataKeys.EXTENDED_DATA, properties); + } + } catch (RuntimeException | LinkageError e) { + // Extended data is best effort and must never prevent the error from being reported. + } + } + + private static void addSuppressed( + ErrorInfo error, + Throwable throwable, + StackTraceElement[] trace, + Predicate excluded, + Set seen, + int level) { + Throwable[] suppressed; + try { + suppressed = throwable.getSuppressed(); + } catch (RuntimeException e) { + return; + } + if (suppressed.length == 0 || level + 1 >= MAX_CAUSES) { + return; + } + List list = new ArrayList<>(); + for (Throwable item : suppressed) { + if (list.size() >= MAX_SUPPRESSED) { + break; + } + if (item != null && !seen.contains(item)) { + list.add(convertChain(item, trace, excluded, seen, level + 1)); + } + } + if (!list.isEmpty()) { + error.getData().put("suppressed", list); + } + } + + private static @Nullable String safeMessage(Throwable throwable) { + try { + return throwable.getMessage(); + } catch (RuntimeException e) { + return null; + } + } + + private static StackTraceElement[] safeStackTrace(Throwable throwable) { + try { + StackTraceElement[] trace = throwable.getStackTrace(); + return trace != null ? trace : new StackTraceElement[0]; + } catch (RuntimeException e) { + return new StackTraceElement[0]; + } + } + + /** + * {@return the cause of {@code throwable}, or null when it has none or {@code getCause} fails} + */ + public static @Nullable Throwable safeCause(Throwable throwable) { + try { + return throwable.getCause(); + } catch (RuntimeException e) { + return null; + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/Exclusions.java b/exceptionless-core/src/main/java/com/exceptionless/internal/Exclusions.java new file mode 100644 index 0000000..4bb23c9 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Exclusions.java @@ -0,0 +1,54 @@ +package com.exceptionless.internal; + +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Stream; + +/** + * A compiled set of data exclusion patterns: tests whether a property name matches any of them. + * Patterns are normalized once, so an instance can be cached and shared between threads. + */ +public final class Exclusions implements Predicate { + private static final Exclusions NONE = new Exclusions(List.of()); + + private final List patterns; + + private Exclusions(List patterns) { + this.patterns = patterns; + } + + /** {@return exclusions matching nothing} */ + public static Exclusions none() { + return NONE; + } + + /** Compiles {@code patterns} (wildcards supported, e.g. {@code *password*}). */ + public static Exclusions of(List patterns) { + if (patterns.isEmpty()) { + return NONE; + } + return new Exclusions(patterns.stream().map(Wildcard::compile).toList()); + } + + /** {@return exclusions matching names excluded by this or {@code other}} */ + public Exclusions plus(Exclusions other) { + if (other.patterns.isEmpty()) { + return this; + } + if (patterns.isEmpty()) { + return other; + } + return new Exclusions(Stream.concat(patterns.stream(), other.patterns.stream()).toList()); + } + + /** {@return whether {@code name} matches any of the patterns} */ + @Override + public boolean test(String name) { + for (Wildcard pattern : patterns) { + if (pattern.test(name)) { + return true; + } + } + return false; + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/Ids.java b/exceptionless-core/src/main/java/com/exceptionless/internal/Ids.java new file mode 100644 index 0000000..789871c --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Ids.java @@ -0,0 +1,34 @@ +package com.exceptionless.internal; + +import java.security.SecureRandom; +import java.util.UUID; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** Identifier helpers. */ +public final class Ids { + private static final char[] ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz".toCharArray(); + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Pattern REFERENCE_ID = Pattern.compile("^[a-zA-Z\\d-]{8,100}$"); + + private Ids() {} + + /** A new random reference id (16 base-36 characters, ~82 bits of entropy). */ + public static String newReferenceId() { + char[] chars = new char[16]; + for (int i = 0; i < chars.length; i++) { + chars[i] = ALPHABET[RANDOM.nextInt(ALPHABET.length)]; + } + return new String(chars); + } + + /** A new random 32 character hex id, used for session ids and install ids. */ + public static String newGuid() { + return UUID.randomUUID().toString().replace("-", ""); + } + + /** True when the id satisfies the server's reference id rules: 8-100 of [a-zA-Z0-9-]. */ + public static boolean isValidReferenceId(@Nullable String id) { + return id != null && REFERENCE_ID.matcher(id).matches(); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/JavaCommand.java b/exceptionless-core/src/main/java/com/exceptionless/internal/JavaCommand.java new file mode 100644 index 0000000..84ce4ce --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/JavaCommand.java @@ -0,0 +1,37 @@ +package com.exceptionless.internal; + +import org.jspecify.annotations.Nullable; + +/** What the JVM was launched with, from the {@code sun.java.command} system property. */ +final class JavaCommand { + private JavaCommand() {} + + /** + * The main class or jar the JVM was started with, e.g. {@code com.example.App} or {@code + * /opt/app/app.jar}. + * + *

Only the first token of the command is returned. The program arguments that follow it are + * deliberately dropped: they routinely contain secrets such as passwords, tokens and connection + * strings, so they must never be reported. + * + * @return the main class or jar path, or {@code null} when the JVM doesn't report it + */ + static @Nullable String mainClassOrJar() { + String command = Strings.stripToNull(System.getProperty("sun.java.command")); + if (command == null) { + return null; + } + for (int i = 0; i < command.length(); i++) { + if (Character.isWhitespace(command.charAt(i))) { + return command.substring(0, i); + } + } + return command; + } + + /** {@return the last segment of a file path} */ + static String fileName(String path) { + int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return slash >= 0 ? path.substring(slash + 1) : path; + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/Log.java b/exceptionless-core/src/main/java/com/exceptionless/internal/Log.java new file mode 100644 index 0000000..ac37aa0 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Log.java @@ -0,0 +1,61 @@ +package com.exceptionless.internal; + +import java.util.function.Supplier; +import org.jspecify.annotations.Nullable; + +/** + * Internal diagnostics logger. Uses {@link System.Logger} so the core library has no logging + * dependency; it reaches SLF4J/Log4j/JUL through whatever bridge the application already has. + * + *

All Exceptionless internal loggers live under the {@code com.exceptionless} name, which the + * logging integrations ignore so the client never reports its own diagnostics. + */ +public final class Log { + public static final String ROOT_NAME = "com.exceptionless"; + + private final System.Logger logger; + + private Log(String name) { + this.logger = System.getLogger(name); + } + + public static Log get(Class type) { + return new Log(ROOT_NAME + "." + type.getSimpleName()); + } + + public boolean isDebugEnabled() { + return logger.isLoggable(System.Logger.Level.DEBUG); + } + + public void trace(Supplier message) { + logger.log(System.Logger.Level.TRACE, message); + } + + public void debug(String message) { + logger.log(System.Logger.Level.DEBUG, message); + } + + public void debug(Supplier message) { + logger.log(System.Logger.Level.DEBUG, message); + } + + public void info(String message) { + logger.log(System.Logger.Level.INFO, message); + } + + public void warn(String message) { + logger.log(System.Logger.Level.WARNING, message); + } + + public void warn(String message, @Nullable Throwable error) { + logger.log(System.Logger.Level.WARNING, message, error); + } + + public void error(String message) { + logger.log(System.Logger.Level.ERROR, message); + } + + public void error(String message, @Nullable Throwable error) { + logger.log(System.Logger.Level.ERROR, message, error); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/ModuleCollector.java b/exceptionless-core/src/main/java/com/exceptionless/internal/ModuleCollector.java new file mode 100644 index 0000000..3ada722 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/ModuleCollector.java @@ -0,0 +1,151 @@ +package com.exceptionless.internal; + +import com.exceptionless.models.ModuleInfo; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.jar.Attributes; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** + * Discovers the libraries on the class path from their jar manifests. The result is computed once, + * on first use, and cached, because scanning can take a few milliseconds. + */ +public final class ModuleCollector { + static final int MAX_MODULES = 500; + private static final Log LOG = Log.get(ModuleCollector.class); + private static final Pattern JAR_NAME = + Pattern.compile("(?:^|[/!])([^/!]+?)(?:-(\\d+(?:\\.\\w+)*(?:[-.+][\\w.]+)?))?\\.jar(?:!|$)"); + + private ModuleCollector() {} + + /** Lazily initialized holder: the JVM runs {@code collect()} once, on first access. */ + private static final class Modules { + static final List ALL = List.copyOf(collect()); + } + + /** The modules on the class path, sorted by name. Never throws. */ + public static List getModules() { + return Modules.ALL; + } + + static List collect() { + // Libraries can appear more than once (e.g. shaded); one of the copies may be the entry point. + Map byName = new LinkedHashMap<>(); + try { + ClassLoader loader = Thread.currentThread().getContextClassLoader(); + if (loader == null) { + loader = ModuleCollector.class.getClassLoader(); + } + if (loader == null) { + return List.of(); + } + String entryPoint = JavaCommand.mainClassOrJar(); + Enumeration manifests = loader.getResources("META-INF/MANIFEST.MF"); + while (manifests.hasMoreElements() && byName.size() < MAX_MODULES) { + URL url = manifests.nextElement(); + Library module = read(url, entryPoint); + if (module != null) { + byName.merge(module.name() + "@" + module.version(), module, Library::merge); + } + } + } catch (IOException | RuntimeException | LinkageError e) { + LOG.debug(() -> "Unable to collect modules: " + e); + } + List sorted = new ArrayList<>(byName.values()); + sorted.sort(Comparator.comparing(Library::name, String.CASE_INSENSITIVE_ORDER)); + List modules = new ArrayList<>(sorted.size()); + for (Library module : sorted) { + modules.add(new ModuleInfo(modules.size(), module.name(), module.version(), module.entry())); + } + return modules; + } + + /** A library read from a manifest, before it is given a module id. */ + private record Library(String name, @Nullable String version, boolean entry) { + Library merge(Library other) { + return other.entry && !entry ? other : this; + } + } + + private static @Nullable Library read(URL url, @Nullable String entryPoint) { + String location = url.toString(); + Manifest manifest; + try { + URLConnection connection = url.openConnection(); + connection.setUseCaches(false); + try (InputStream in = connection.getInputStream()) { + manifest = new Manifest(in); + } + } catch (IOException | RuntimeException e) { + return null; + } + Attributes attributes = manifest.getMainAttributes(); + String fileName = null; + String fileVersion = null; + Matcher matcher = JAR_NAME.matcher(location.replace("!/META-INF/MANIFEST.MF", "!")); + String lastName = null; + String lastVersion = null; + while (matcher.find()) { + lastName = matcher.group(1); + lastVersion = matcher.group(2); + } + if (lastName != null) { + fileName = lastName; + fileVersion = lastVersion; + } + + String name = + firstNonBlank( + attributes.getValue("Implementation-Title"), + symbolicName(attributes.getValue("Bundle-SymbolicName")), + attributes.getValue("Automatic-Module-Name"), + fileName); + if (name == null) { + return null; + } + String version = + firstNonBlank( + attributes.getValue("Implementation-Version"), + attributes.getValue("Bundle-Version"), + fileVersion); + + String mainClass = attributes.getValue("Main-Class"); + String startClass = attributes.getValue("Start-Class"); + boolean entry = + entryPoint != null + && (entryPoint.equals(mainClass) + || entryPoint.equals(startClass) + || (entryPoint.endsWith(".jar") + && location.contains(JavaCommand.fileName(entryPoint)))); + return new Library(name, version, entry); + } + + private static @Nullable String symbolicName(@Nullable String value) { + if (value == null) { + return null; + } + int semicolon = value.indexOf(';'); + return (semicolon > 0 ? value.substring(0, semicolon) : value).strip(); + } + + private static @Nullable String firstNonBlank(@Nullable String... values) { + for (String value : values) { + String stripped = Strings.stripToNull(value); + if (stripped != null) { + return stripped; + } + } + return null; + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/SettingsJson.java b/exceptionless-core/src/main/java/com/exceptionless/internal/SettingsJson.java new file mode 100644 index 0000000..ef9eef4 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/SettingsJson.java @@ -0,0 +1,46 @@ +package com.exceptionless.internal; + +import com.exceptionless.json.JsonParser; +import com.exceptionless.json.JsonWriter; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * Reads and writes project settings documents, {@code {"version": 3, "settings": {...}}}, as + * returned by the server and persisted by {@link SettingsManager}. + */ +public final class SettingsJson { + private SettingsJson() {} + + /** + * A parsed settings document. + * + * @param settings the settings; values that are {@code null} are dropped + * @param version the settings version, or {@code null} when it is missing + */ + public record Parsed(Map settings, @Nullable Integer version) {} + + /** + * Parses a settings document. + * + * @throws IllegalArgumentException if {@code json} is not a JSON object + */ + public static Parsed parse(String json) { + Map document = JsonParser.parseObject(json); + Map settings = new LinkedHashMap<>(); + if (document.get("settings") instanceof Map values) { + for (Map.Entry entry : values.entrySet()) { + if (entry.getValue() != null) { + settings.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue())); + } + } + } + Integer version = document.get("version") instanceof Number n ? n.intValue() : null; + return new Parsed(settings, version); + } + + public static String write(Map settings, int version) { + return JsonWriter.toJson(Map.of("version", version, "settings", settings)); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/SettingsManager.java b/exceptionless-core/src/main/java/com/exceptionless/internal/SettingsManager.java new file mode 100644 index 0000000..e434b50 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/SettingsManager.java @@ -0,0 +1,84 @@ +package com.exceptionless.internal; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.storage.KeyValueStorage; +import com.exceptionless.submission.SettingsResponse; +import com.exceptionless.submission.SubmissionClient; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantLock; +import org.jspecify.annotations.Nullable; + +/** + * Keeps the project settings up to date. Settings are persisted so they apply immediately on the + * next start, before the first request to the server. + */ +public final class SettingsManager { + static final String STORAGE_KEY = "server-settings.json"; + private static final Log LOG = Log.get(SettingsManager.class); + + private final ExceptionlessConfiguration config; + private final SubmissionClient submissionClient; + private final KeyValueStorage storage; + private final ReentrantLock updateLock = new ReentrantLock(); + private volatile int version; + + public SettingsManager( + ExceptionlessConfiguration config, + SubmissionClient submissionClient, + KeyValueStorage storage) { + this.config = config; + this.submissionClient = submissionClient; + this.storage = storage; + } + + /** {@return the version of the settings currently applied (0 when none)} */ + public int getVersion() { + return version; + } + + /** Applies the persisted settings, if any. Returns true when settings were loaded. */ + public boolean loadSavedSettings() { + String saved = storage.read(STORAGE_KEY); + if (saved == null) { + return false; + } + try { + SettingsJson.Parsed parsed = SettingsJson.parse(saved); + config.applyServerSettings(parsed.settings()); + version = Objects.requireNonNullElse(parsed.version(), 0); + return true; + } catch (IllegalArgumentException e) { + LOG.warn("Ignoring unreadable saved settings", e); + return false; + } + } + + /** Fetches settings when the server reports a newer version than ours. */ + public void checkVersion(@Nullable Integer serverVersion) { + if (serverVersion != null && serverVersion > version) { + updateSettings(); + } + } + + /** Fetches and applies the latest settings. Blocks while the request runs. */ + public void updateSettings() { + if (!config.isEnabled() || !updateLock.tryLock()) { + return; + } + try { + SettingsResponse response = submissionClient.getSettings(version); + if (response instanceof SettingsResponse.Failed failed) { + LOG.debug(failed::message); + } else if (response instanceof SettingsResponse.Updated updated) { + config.applyServerSettings(updated.settings()); + version = updated.version(); + storage.write(STORAGE_KEY, SettingsJson.write(updated.settings(), updated.version())); + LOG.debug(() -> "Applied settings version " + updated.version()); + } + } catch (RuntimeException e) { + LOG.warn("Unable to update settings", e); + } finally { + updateLock.unlock(); + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/Strings.java b/exceptionless-core/src/main/java/com/exceptionless/internal/Strings.java new file mode 100644 index 0000000..c8df9bc --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Strings.java @@ -0,0 +1,42 @@ +package com.exceptionless.internal; + +import java.util.List; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** String helpers. */ +public final class Strings { + private static final Pattern LIST_SEPARATORS = Pattern.compile("[,;\\r\\n]"); + + private Strings() {} + + public static boolean isBlank(@Nullable String value) { + return value == null || value.isBlank(); + } + + /** {@return {@code value} without leading and trailing white space, or null when blank} */ + public static @Nullable String stripToNull(@Nullable String value) { + if (value == null) { + return null; + } + String stripped = value.strip(); + return stripped.isEmpty() ? null : stripped; + } + + /** Splits a comma, semicolon or newline separated list, stripping and dropping blanks. */ + public static List splitList(@Nullable String value) { + if (isBlank(value)) { + return List.of(); + } + return LIST_SEPARATORS + .splitAsStream(value) + .map(String::strip) + .filter(part -> !part.isEmpty()) + .toList(); + } + + /** {@return {@code value}, cut to at most {@code maxLength} characters} */ + public static String truncate(String value, int maxLength) { + return value.length() <= maxLength ? value : value.substring(0, maxLength); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/Wildcard.java b/exceptionless-core/src/main/java/com/exceptionless/internal/Wildcard.java new file mode 100644 index 0000000..318bdc3 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Wildcard.java @@ -0,0 +1,94 @@ +package com.exceptionless.internal; + +import java.util.Collection; +import java.util.Locale; +import java.util.function.Predicate; +import org.jspecify.annotations.Nullable; + +/** + * Case-insensitive wildcard matching with the same semantics as the other Exceptionless clients: + * {@code *} matches everything, {@code *x} ends with, {@code x*} starts with, {@code *x*} contains, + * otherwise an exact match. + * + *

Use {@link #compile} when the same pattern is matched repeatedly; it normalizes the pattern + * once instead of on every call. + */ +public final class Wildcard implements Predicate<@Nullable String> { + private enum Kind { + ANY, + EXACT, + PREFIX, + SUFFIX, + CONTAINS, + NONE + } + + private static final Wildcard NEVER = new Wildcard(Kind.NONE, ""); + + private final Kind kind; + private final String text; + + private Wildcard(Kind kind, String text) { + this.kind = kind; + this.text = text; + } + + /** Compiles {@code pattern}. A null or blank pattern never matches. */ + public static Wildcard compile(@Nullable String pattern) { + if (pattern == null) { + return NEVER; + } + String p = pattern.strip().toLowerCase(Locale.ROOT); + if (p.isEmpty()) { + return NEVER; + } + if (p.equals("*")) { + return new Wildcard(Kind.ANY, ""); + } + boolean leading = p.charAt(0) == '*'; + boolean trailing = p.length() > 1 && p.charAt(p.length() - 1) == '*'; + if (leading && trailing) { + return new Wildcard(Kind.CONTAINS, p.substring(1, p.length() - 1)); + } + if (leading) { + return new Wildcard(Kind.SUFFIX, p.substring(1)); + } + if (trailing) { + return new Wildcard(Kind.PREFIX, p.substring(0, p.length() - 1)); + } + return new Wildcard(Kind.EXACT, p); + } + + /** {@return whether {@code value} matches this pattern} A null value never matches. */ + @Override + public boolean test(@Nullable String value) { + if (value == null || kind == Kind.NONE) { + return false; + } + String v = value.strip().toLowerCase(Locale.ROOT); + return switch (kind) { + case ANY -> true; + case EXACT -> v.equals(text); + case PREFIX -> v.startsWith(text); + case SUFFIX -> v.endsWith(text); + case CONTAINS -> v.contains(text); + case NONE -> false; + }; + } + + public static boolean matches(@Nullable String value, @Nullable String pattern) { + return value != null && compile(pattern).test(value); + } + + public static boolean matchesAny(@Nullable String value, @Nullable Collection patterns) { + if (value == null || patterns == null) { + return false; + } + for (String pattern : patterns) { + if (matches(value, pattern)) { + return true; + } + } + return false; + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/package-info.java b/exceptionless-core/src/main/java/com/exceptionless/internal/package-info.java new file mode 100644 index 0000000..5a9f4f5 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/package-info.java @@ -0,0 +1,5 @@ +/** Internal implementation details. Not part of the public API; may change in any release. */ +@NullMarked +package com.exceptionless.internal; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-core/src/main/java/com/exceptionless/json/JsonParser.java b/exceptionless-core/src/main/java/com/exceptionless/json/JsonParser.java new file mode 100644 index 0000000..6448404 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/json/JsonParser.java @@ -0,0 +1,304 @@ +package com.exceptionless.json; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * A small, strict JSON parser following RFC 8259. Objects become {@link LinkedHashMap}, arrays + * become {@link ArrayList}, numbers become {@link Long} or {@link BigDecimal}. + * + *

Anything outside the grammar is rejected, including leading zeros ({@code 01}), incomplete + * fractions or exponents ({@code 1.}, {@code 1e}), single quotes, comments and trailing commas. A + * byte order mark is ignored at the start of the document only. + */ +public final class JsonParser { + private static final int MAX_DEPTH = 256; + private static final char BYTE_ORDER_MARK = '\uFEFF'; + + private final String input; + private int pos; + private int depth; + + private JsonParser(String input) { + this.input = input; + } + + /** + * Parses a JSON document. + * + * @throws IllegalArgumentException if the input is not valid JSON + */ + public static @Nullable Object parse(String json) { + JsonParser parser = new JsonParser(json); + if (!json.isEmpty() && json.charAt(0) == BYTE_ORDER_MARK) { + parser.pos++; + } + parser.skipWhitespace(); + Object value = parser.readValue(); + parser.skipWhitespace(); + if (parser.pos != parser.input.length()) { + throw parser.error("Unexpected trailing content"); + } + return value; + } + + /** Parses a JSON object, failing if the document is not an object. */ + @SuppressWarnings("unchecked") + public static Map parseObject(String json) { + Object value = parse(json); + if (value instanceof Map map) { + return (Map) map; + } + throw new IllegalArgumentException("Expected a JSON object"); + } + + private @Nullable Object readValue() { + if (pos >= input.length()) { + throw error("Unexpected end of input"); + } + char c = input.charAt(pos); + return switch (c) { + case '{' -> readObject(); + case '[' -> readArray(); + case '"' -> readString(); + case 't' -> readLiteral("true", true); + case 'f' -> readLiteral("false", false); + case 'n' -> readLiteral("null", null); + default -> { + if (c == '-' || (c >= '0' && c <= '9')) { + yield readNumber(); + } + throw error("Unexpected character '" + c + "'"); + } + }; + } + + private Map readObject() { + enter(); + pos++; // { + Map map = new LinkedHashMap<>(); + skipWhitespace(); + if (peek() == '}') { + pos++; + depth--; + return map; + } + while (true) { + skipWhitespace(); + if (peek() != '"') { + throw error("Expected property name"); + } + String name = readString(); + skipWhitespace(); + expect(':'); + skipWhitespace(); + map.put(name, readValue()); + skipWhitespace(); + char c = next(); + if (c == '}') { + break; + } + if (c != ',') { + throw error("Expected ',' or '}'"); + } + } + depth--; + return map; + } + + private List<@Nullable Object> readArray() { + enter(); + pos++; // [ + List<@Nullable Object> list = new ArrayList<>(); + skipWhitespace(); + if (peek() == ']') { + pos++; + depth--; + return list; + } + while (true) { + skipWhitespace(); + list.add(readValue()); + skipWhitespace(); + char c = next(); + if (c == ']') { + break; + } + if (c != ',') { + throw error("Expected ',' or ']'"); + } + } + depth--; + return list; + } + + private String readString() { + expect('"'); + StringBuilder sb = null; + int start = pos; + while (true) { + if (pos >= input.length()) { + throw error("Unterminated string"); + } + char c = input.charAt(pos++); + if (c == '"') { + if (sb == null) { + return input.substring(start, pos - 1); + } + return sb.toString(); + } + if (c == '\\') { + if (sb == null) { + sb = new StringBuilder(input.substring(start, pos - 1)); + } + char escaped = next(); + switch (escaped) { + case '"' -> sb.append('"'); + case '\\' -> sb.append('\\'); + case '/' -> sb.append('/'); + case 'b' -> sb.append('\b'); + case 'f' -> sb.append('\f'); + case 'n' -> sb.append('\n'); + case 'r' -> sb.append('\r'); + case 't' -> sb.append('\t'); + case 'u' -> { + if (pos + 4 > input.length()) { + throw error("Invalid unicode escape"); + } + for (int i = pos; i < pos + 4; i++) { + if (!HexFormat.isHexDigit(input.charAt(i))) { + throw error("Invalid unicode escape"); + } + } + sb.append((char) HexFormat.fromHexDigits(input, pos, pos + 4)); + pos += 4; + } + default -> throw error("Invalid escape '\\" + escaped + "'"); + } + } else if (c < 0x20) { + throw error("Unescaped control character in string"); + } else if (sb != null) { + sb.append(c); + } + } + } + + /** Reads {@code -? (0 | [1-9][0-9]*) (. [0-9]+)? ([eE] [+-]? [0-9]+)?}. */ + private Number readNumber() { + int start = pos; + if (peekIs('-')) { + pos++; + } + if (peekIs('0')) { + pos++; + if (isDigit()) { + throw error("Leading zeros are not allowed"); + } + } else { + digits(); + } + boolean integer = true; + if (peekIs('.')) { + pos++; + digits(); + integer = false; + } + if (peekIs('e') || peekIs('E')) { + pos++; + if (peekIs('+') || peekIs('-')) { + pos++; + } + digits(); + integer = false; + } + String text = input.substring(start, pos); + if (integer) { + try { + return Long.parseLong(text); + } catch (NumberFormatException overflow) { + // Too large for a long. + } + } + try { + return new BigDecimal(text); + } catch (NumberFormatException | ArithmeticException e) { + throw error("Invalid number '" + text + "'"); + } + } + + /** Reads one or more digits. */ + private void digits() { + if (!isDigit()) { + throw error("Expected a digit"); + } + while (isDigit()) { + pos++; + } + } + + private boolean isDigit() { + if (pos >= input.length()) { + return false; + } + char c = input.charAt(pos); + return c >= '0' && c <= '9'; + } + + private boolean peekIs(char expected) { + return pos < input.length() && input.charAt(pos) == expected; + } + + private @Nullable Object readLiteral(String literal, @Nullable Object value) { + if (!input.startsWith(literal, pos)) { + throw error("Invalid literal"); + } + pos += literal.length(); + return value; + } + + private void enter() { + if (++depth > MAX_DEPTH) { + throw error("JSON nested too deeply"); + } + } + + private void skipWhitespace() { + while (pos < input.length()) { + char c = input.charAt(pos); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + pos++; + } else { + break; + } + } + } + + private char peek() { + if (pos >= input.length()) { + throw error("Unexpected end of input"); + } + return input.charAt(pos); + } + + private char next() { + char c = peek(); + pos++; + return c; + } + + private void expect(char expected) { + if (next() != expected) { + pos--; + throw error("Expected '" + expected + "'"); + } + } + + private IllegalArgumentException error(String message) { + return new IllegalArgumentException(message + " at position " + pos); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/json/JsonSerializable.java b/exceptionless-core/src/main/java/com/exceptionless/json/JsonSerializable.java new file mode 100644 index 0000000..bf424de --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/json/JsonSerializable.java @@ -0,0 +1,10 @@ +package com.exceptionless.json; + +/** + * A type that knows how to write itself as JSON using the Exceptionless wire format (snake_case + * property names, null properties omitted). + */ +public interface JsonSerializable { + /** Writes this value as a single JSON value (usually an object). */ + void toJson(JsonWriter writer); +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/json/JsonWriter.java b/exceptionless-core/src/main/java/com/exceptionless/json/JsonWriter.java new file mode 100644 index 0000000..de9fa25 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/json/JsonWriter.java @@ -0,0 +1,315 @@ +package com.exceptionless.json; + +import com.exceptionless.internal.DataNormalizer; +import java.lang.reflect.Array; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Arrays; +import java.util.Collection; +import java.util.HexFormat; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalDouble; +import java.util.OptionalInt; +import java.util.OptionalLong; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.jspecify.annotations.Nullable; + +/** + * A small streaming JSON writer. It never throws for unexpected value types: anything it does not + * understand is written using {@link Object#toString()}, so serializing an event can't fail. + * + *

Simple values (dates, enums, UUIDs, paths and so on) are written as strings exactly like + * {@link DataNormalizer} converts them, so a value looks the same whether or not it was normalized + * first. + */ +public final class JsonWriter { + private static final int MAX_DEPTH = 64; + private static final HexFormat HEX = HexFormat.of(); + + private final StringBuilder out; + // One entry per open container: true when the next element needs a leading comma. + private boolean[] needsComma = new boolean[16]; + private int depth; + private boolean afterName; + + /** Creates a writer with its own buffer; read the result with {@link #toString()}. */ + public JsonWriter() { + this(new StringBuilder(256)); + } + + /** Creates a writer that appends to {@code out}. */ + public JsonWriter(StringBuilder out) { + this.out = out; + } + + /** Serializes {@code value} to a JSON string. */ + public static String toJson(@Nullable Object value) { + JsonWriter writer = new JsonWriter(); + writer.value(value); + return writer.toString(); + } + + /** Starts an object: write {@link #name(String) name}/value pairs, then {@link #endObject()}. */ + public JsonWriter beginObject() { + beforeValue(); + out.append('{'); + push(); + return this; + } + + /** Ends the current object. */ + public JsonWriter endObject() { + pop(); + out.append('}'); + return this; + } + + /** Starts an array: write values, then {@link #endArray()}. */ + public JsonWriter beginArray() { + beforeValue(); + out.append('['); + push(); + return this; + } + + /** Ends the current array. */ + public JsonWriter endArray() { + pop(); + out.append(']'); + return this; + } + + /** Writes a property name. Must be followed by exactly one value. */ + public JsonWriter name(String name) { + if (depth > 0 && needsComma[depth - 1]) { + out.append(','); + } + if (depth > 0) { + needsComma[depth - 1] = true; + } + writeString(name); + out.append(':'); + afterName = true; + return this; + } + + /** Writes {@code "name": value} unless the value is {@code null}. */ + public JsonWriter field(String name, @Nullable Object value) { + if (value == null) { + return this; + } + name(name); + return value(value); + } + + /** Writes {@code "name": value} unless the value is {@code null}, empty or blank. */ + public JsonWriter fieldIfNotEmpty(String name, @Nullable Object value) { + if (value == null) { + return this; + } + if (value instanceof CharSequence && value.toString().isBlank()) { + return this; + } + if (value instanceof Map map && map.isEmpty()) { + return this; + } + if (value instanceof Collection collection && collection.isEmpty()) { + return this; + } + name(name); + return value(value); + } + + /** Writes {@code null}. */ + public JsonWriter nullValue() { + beforeValue(); + out.append("null"); + return this; + } + + /** Writes any supported value. Unknown types are written as their {@code toString()}. */ + public JsonWriter value(@Nullable Object value) { + if (value == null) { + return nullValue(); + } + if (depth >= MAX_DEPTH) { + return string("..."); + } + if (value instanceof JsonSerializable serializable) { + int before = depth; + serializable.toJson(this); + if (depth != before) { + throw new IllegalStateException( + value.getClass().getName() + ".toJson left the writer unbalanced"); + } + return this; + } + if (value instanceof CharSequence || value instanceof Character) { + return string(value.toString()); + } + if (value instanceof Boolean bool) { + beforeValue(); + out.append(bool.booleanValue() ? "true" : "false"); + return this; + } + if (value instanceof Number number) { + return number(number); + } + if (DataNormalizer.isSimpleValue(value)) { + // Before Iterable: a Path is an Iterable of Paths. + return string(DataNormalizer.simpleValueToString(value)); + } + if (value instanceof Map map) { + beginObject(); + for (Map.Entry entry : map.entrySet()) { + name(String.valueOf(entry.getKey())); + value(entry.getValue()); + } + return endObject(); + } + if (value instanceof Iterable iterable) { + beginArray(); + for (Object item : iterable) { + value(item); + } + return endArray(); + } + if (value.getClass().isArray()) { + beginArray(); + int length = Array.getLength(value); + for (int i = 0; i < length; i++) { + value(Array.get(value, i)); + } + return endArray(); + } + if (value instanceof Optional optional) { + return value(optional.orElse(null)); + } + if (value instanceof OptionalInt optional) { + return optional.isPresent() ? number(optional.getAsInt()) : nullValue(); + } + if (value instanceof OptionalLong optional) { + return optional.isPresent() ? number(optional.getAsLong()) : nullValue(); + } + if (value instanceof OptionalDouble optional) { + return optional.isPresent() ? number(optional.getAsDouble()) : nullValue(); + } + return string(String.valueOf(value)); + } + + /** Writes a string value, escaping it as needed. */ + public JsonWriter string(String value) { + beforeValue(); + writeString(value); + return this; + } + + /** + * Writes a number. Whole floating point values are written without a fraction; NaN and infinity, + * which JSON can't represent, are written as {@code null}. + */ + public JsonWriter number(Number value) { + if (value instanceof Double || value instanceof Float) { + double d = value.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + return nullValue(); + } + beforeValue(); + if (d == Math.rint(d) && Math.abs(d) < 1e15) { + out.append((long) d); + } else { + out.append(value); + } + return this; + } + beforeValue(); + if (value instanceof BigDecimal decimal) { + out.append(decimal.toPlainString()); + } else if (value instanceof Integer + || value instanceof Long + || value instanceof Short + || value instanceof Byte + || value instanceof BigInteger + || value instanceof AtomicInteger + || value instanceof AtomicLong) { + out.append(value); + } else { + // Unknown Number subclass: fall back to its double value. + double d = value.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + out.append("null"); + } else { + out.append(BigDecimal.valueOf(d).toPlainString()); + } + } + return this; + } + + /** {@return the JSON written so far} */ + @Override + public String toString() { + return out.toString(); + } + + private void beforeValue() { + if (afterName) { + afterName = false; + return; + } + if (depth > 0) { + if (needsComma[depth - 1]) { + out.append(','); + } + needsComma[depth - 1] = true; + } + } + + private void push() { + if (depth == needsComma.length) { + needsComma = Arrays.copyOf(needsComma, needsComma.length * 2); + } + needsComma[depth++] = false; + } + + private void pop() { + if (depth == 0) { + throw new IllegalStateException("Unbalanced JSON container"); + } + depth--; + } + + private void writeString(String value) { + out.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + case '\b' -> out.append("\\b"); + case '\f' -> out.append("\\f"); + default -> { + if (c < 0x20 || c == '
' || c == '
') { + out.append("\\u").append(HEX.toHexDigits(c)); + } else if (Character.isSurrogate(c)) { + // Keep valid surrogate pairs; replace lone surrogates so the output is valid UTF-8. + if (Character.isHighSurrogate(c) + && i + 1 < value.length() + && Character.isLowSurrogate(value.charAt(i + 1))) { + out.append(c).append(value.charAt(++i)); + } else { + out.append('�'); + } + } else { + out.append(c); + } + } + } + } + out.append('"'); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/json/package-info.java b/exceptionless-core/src/main/java/com/exceptionless/json/package-info.java new file mode 100644 index 0000000..f4f358e --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/json/package-info.java @@ -0,0 +1,5 @@ +/** Minimal, reflection-free JSON support used for the Exceptionless wire format. */ +@NullMarked +package com.exceptionless.json; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-core/src/main/java/com/exceptionless/logging/LogEventSupport.java b/exceptionless-core/src/main/java/com/exceptionless/logging/LogEventSupport.java new file mode 100644 index 0000000..356974e --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/logging/LogEventSupport.java @@ -0,0 +1,383 @@ +package com.exceptionless.logging; + +import com.exceptionless.EventBuilder; +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.LogLevel; +import com.exceptionless.internal.DaemonThreadFactory; +import com.exceptionless.internal.Strings; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jspecify.annotations.Nullable; + +/** + * The client handling shared by logging integrations (appenders and handlers), so they all behave + * the same. Each appender owns one instance, configures it from its own settings and calls {@link + * #submit} for every log event: + * + *

{@code
+ * public final class MyAppender extends SomeFrameworkAppender {
+ *   private final LogEventSupport support = new LogEventSupport("myFramework", this::reportError);
+ *
+ *   public void setApiKey(String apiKey) { support.setApiKey(apiKey); }
+ *
+ *   public void start() { support.start(); super.start(); }
+ *
+ *   public void stop() { super.stop(); support.close(); }
+ *
+ *   protected void append(MyEvent event) {
+ *     LogLevel level = toLogLevel(event.getLevel());
+ *     support.submit(event.getLoggerName(), event.getThreadName(), level,
+ *         client -> support
+ *             .createEvent(client, event.getLoggerName(), event.getMessage(), level,
+ *                 event.getThrown(), event.getThreadName())
+ *             .setProperty("custom", event.getCustomValue()));
+ *   }
+ * }
+ * }
+ * + *

Client. Events go to, in order of precedence: + * + *

    + *
  1. the client passed to {@link #setClient}, which is never closed here; + *
  2. with an {@link #setApiKey API key}, a client created on first use (configured like {@link + * ExceptionlessConfiguration#fromEnvironment()}, plus the {@link #setServerUrl server URL}, + * without the uncaught exception handler) and closed, sending queued events, by {@link + * #close()}. If it can't be created, the error is reported once and no new attempt is made + * until {@link #start()} or a new API key or server URL; + *
  3. otherwise {@link Exceptionless#getDefault() the default client}, once the application has + * started it, so logging never creates, starts or locks the default client's configuration. + *
+ * + *

Filtering. {@link #submit} drops events while stopped, events from the client's own + * loggers and background threads ({@link #isInternal}), events logged on a thread that is already + * submitting an event (for example from a plugin or a submitting event handler; this guard is + * shared by every integration), events below the minimum level or the project's {@code @@log} + * settings, and everything while the client is disabled (no API key). + * + *

Errors. {@link #submit} never throws. The first failure after {@link #start()} is + * reported to the {@link ErrorReporter} (usually the framework's status or error handler); later + * ones are only returned, so a broken client doesn't flood the framework's own error output. This + * class is thread safe. + */ +public final class LogEventSupport implements AutoCloseable { + /** Loggers whose names start with this (the client's own diagnostics) are never sent. */ + public static final String CLIENT_LOGGER_PREFIX = "com.exceptionless"; + + /** + * The client's background threads are named with this prefix ({@code exceptionless-1}, ...); + * anything logged on them is never sent. + */ + public static final String CLIENT_THREAD_PREFIX = DaemonThreadFactory.CLIENT_THREAD_NAME + "-"; + + /** Set while the current thread submits an event, to drop events logged meanwhile. */ + private static final ThreadLocal SUBMITTING = new ThreadLocal<>(); + + /** Receives errors that the framework should show in its own status output. */ + @FunctionalInterface + public interface ErrorReporter { + void report(String message, Throwable error); + } + + /** Creates the event for a log entry, given the client it is sent with. */ + @FunctionalInterface + public interface EventFactory { + EventBuilder create(ExceptionlessClient client); + } + + private final String submissionMethod; + private final ErrorReporter errorReporter; + private final Object lock = new Object(); + private final AtomicBoolean reportedSubmitFailure = new AtomicBoolean(); + + private volatile @Nullable String apiKey; + private volatile @Nullable String serverUrl; + private volatile LogLevel minimumLevel = LogLevel.WARN; + private volatile @Nullable ExceptionlessClient client; + private volatile @Nullable ExceptionlessClient ownedClient; + private volatile boolean ownedClientFailed; + private volatile boolean started; + + /** + * @param submissionMethod the {@code @submission_method} recorded on events, e.g. {@code logback} + * @param errorReporter receives failures to create, use or close the client + */ + public LogEventSupport(String submissionMethod, ErrorReporter errorReporter) { + this.submissionMethod = Objects.requireNonNull(submissionMethod, "submissionMethod"); + this.errorReporter = Objects.requireNonNull(errorReporter, "errorReporter"); + } + + /** The {@code @submission_method} recorded on events. */ + public String getSubmissionMethod() { + return submissionMethod; + } + + public @Nullable String getApiKey() { + return apiKey; + } + + /** + * The project API key. When set (and no {@link #setClient client} is), a client is created and + * owned for it. Blank values count as unset. Changing it closes a client created for the old key. + */ + public void setApiKey(@Nullable String apiKey) { + String value = Strings.stripToNull(apiKey); + if (!Objects.equals(value, this.apiKey)) { + this.apiKey = value; + resetOwnedClient(); + } + } + + public @Nullable String getServerUrl() { + return serverUrl; + } + + /** + * The server URL for self hosted installs, used with {@link #setApiKey}. Blank values count as + * unset. Changing it closes a client created for the old URL. + */ + public void setServerUrl(@Nullable String serverUrl) { + String value = Strings.stripToNull(serverUrl); + if (!Objects.equals(value, this.serverUrl)) { + this.serverUrl = value; + resetOwnedClient(); + } + } + + public LogLevel getMinimumLevel() { + return minimumLevel; + } + + /** + * The minimum level sent when the project has no matching {@code @@log} setting (default {@link + * LogLevel#WARN}). {@link LogLevel#OFF} sends only what the project settings enable. + */ + public void setMinimumLevel(LogLevel minimumLevel) { + this.minimumLevel = Objects.requireNonNull(minimumLevel, "minimumLevel"); + } + + /** + * Sets the minimum level by name, accepting the names {@link LogLevel#parse} does (Exceptionless, + * Log4j, Logback and {@code java.util.logging} level names). + * + * @return false, leaving the level unchanged, when the name isn't recognized + */ + public boolean setMinimumLevel(@Nullable String minimumLevel) { + LogLevel level = LogLevel.parse(minimumLevel); + if (level == null) { + return false; + } + this.minimumLevel = level; + return true; + } + + /** + * The client set with {@link #setClient}, else the one created for the API key, else {@code null} + * (the default client is used, or nothing has been sent yet). + */ + public @Nullable ExceptionlessClient getClient() { + ExceptionlessClient explicit = client; + return explicit != null ? explicit : ownedClient; + } + + /** Sends to the given client, which is never closed here. Takes precedence over the API key. */ + public void setClient(@Nullable ExceptionlessClient client) { + this.client = client; + } + + public boolean isStarted() { + return started; + } + + /** Starts accepting events and clears earlier failures, so new ones are reported again. */ + public void start() { + synchronized (lock) { + ownedClientFailed = false; + reportedSubmitFailure.set(false); + started = true; + } + } + + /** + * Stops accepting events and closes the client created for the API key, sending queued events + * (waiting up to the client's {@link ExceptionlessConfiguration#getShutdownTimeout() shutdown + * timeout}). Never throws. {@link #start()} can be called again afterwards. + */ + @Override + public void close() { + close(null); + } + + /** + * Like {@link #close()}, but waits at most {@code timeout} for queued events to be sent; {@code + * null} or a non-positive timeout uses the client's shutdown timeout. + */ + public void close(@Nullable Duration timeout) { + ExceptionlessClient owned; + synchronized (lock) { + started = false; + owned = ownedClient; + ownedClient = null; + } + closeOwned(owned, timeout); + } + + /** + * The client an event would be sent to now (see the class documentation), or {@code null} when + * there is none yet. May create the client for the API key. + */ + public @Nullable ExceptionlessClient resolveClient() { + ExceptionlessClient explicit = client; + if (explicit != null) { + return explicit; + } + if (apiKey == null) { + return startedDefaultClient(); + } + ExceptionlessClient owned = ownedClient; + if (owned != null || ownedClientFailed || !started) { + return owned; + } + synchronized (lock) { + String key = apiKey; + if (ownedClient == null && !ownedClientFailed && started && key != null) { + try { + ExceptionlessConfiguration config = + ExceptionlessConfiguration.fromEnvironment() + .apiKey(key) + .captureUncaughtExceptions(false); + String url = serverUrl; + if (url != null) { + config.serverUrl(url); + } + ownedClient = new ExceptionlessClient(config).startup(); + } catch (RuntimeException e) { + ownedClientFailed = true; + errorReporter.report("Unable to create the Exceptionless client", e); + } + } + return ownedClient; + } + } + + /** The default client when it exists and the application has started it, else {@code null}. */ + public static @Nullable ExceptionlessClient startedDefaultClient() { + if (!Exceptionless.hasDefault()) { + return null; + } + ExceptionlessClient defaultClient = Exceptionless.getDefault(); + return defaultClient.isStarted() ? defaultClient : null; + } + + /** + * Submits the event {@code factory} creates, unless it is filtered out (see the class + * documentation). The factory only runs when the event will be sent, so it is the place for + * expensive work such as formatting the message. Never throws. + * + * @param loggerName the logger name, used for the {@code @@log} settings and as the source + * @param threadName the name of the thread that logged the event + * @param level the event's level, mapped from the framework's level + * @return the failure when creating or submitting the event threw, else {@code null}; only the + * first failure after {@link #start()} is also sent to the {@link ErrorReporter} + */ + public @Nullable Throwable submit( + @Nullable String loggerName, + @Nullable String threadName, + LogLevel level, + EventFactory factory) { + if (!started || SUBMITTING.get() != null || isInternal(loggerName, threadName)) { + return null; + } + SUBMITTING.set(Boolean.TRUE); + try { + ExceptionlessClient target = resolveClient(); + if (target == null + || !target.getConfiguration().isEnabled() + || !target.isLogLevelEnabled(loggerName, level, minimumLevel)) { + return null; + } + factory.create(target).submit(); + return null; + } catch (RuntimeException | LinkageError e) { + if (reportedSubmitFailure.compareAndSet(false, true)) { + errorReporter.report("Unable to send a log event to Exceptionless", e); + } + return e; + } finally { + SUBMITTING.remove(); + } + } + + /** + * Creates the event for a log entry: an error event with the logger name as source, the message + * and the level when there is an exception, otherwise a log event. Sets the submission method and + * the {@code thread} property. + */ + public EventBuilder createEvent( + ExceptionlessClient client, + @Nullable String loggerName, + @Nullable String message, + LogLevel level, + @Nullable Throwable exception, + @Nullable String threadName) { + String text = message != null ? message : ""; + EventBuilder builder = + exception != null + ? client + .createException(exception) + .setSource(loggerName) + .setMessage(text) + .setLevel(level) + : client.createLog(loggerName, text, level); + builder.setSubmissionMethod(submissionMethod); + if (threadName != null) { + builder.setProperty("thread", threadName); + } + return builder; + } + + /** + * True for the client's own diagnostics: loggers named {@value #CLIENT_LOGGER_PREFIX} or below + * it, and threads whose names start with {@value #CLIENT_THREAD_PREFIX}. + */ + public static boolean isInternal(@Nullable String loggerName, @Nullable String threadName) { + if (threadName != null && threadName.startsWith(CLIENT_THREAD_PREFIX)) { + return true; + } + return loggerName != null + && loggerName.startsWith(CLIENT_LOGGER_PREFIX) + && (loggerName.length() == CLIENT_LOGGER_PREFIX.length() + || loggerName.charAt(CLIENT_LOGGER_PREFIX.length()) == '.'); + } + + private void resetOwnedClient() { + ExceptionlessClient owned; + synchronized (lock) { + owned = ownedClient; + ownedClient = null; + ownedClientFailed = false; + } + closeOwned(owned, null); + } + + private void closeOwned(@Nullable ExceptionlessClient owned, @Nullable Duration timeout) { + if (owned == null) { + return; + } + if (timeout != null && !timeout.isNegative() && !timeout.isZero()) { + try { + // close() waits up to the shutdown timeout for queued events to be sent. + owned.getConfiguration().shutdownTimeout(timeout); + } catch (RuntimeException e) { + // Keep the configured shutdown timeout. + } + } + try { + owned.close(); + } catch (RuntimeException e) { + errorReporter.report("Unable to close the Exceptionless client", e); + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/logging/package-info.java b/exceptionless-core/src/main/java/com/exceptionless/logging/package-info.java new file mode 100644 index 0000000..95aa7f1 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/logging/package-info.java @@ -0,0 +1,10 @@ +/** + * Support for writing logging integrations (appenders and handlers for logging frameworks). {@link + * com.exceptionless.logging.LogEventSupport} implements the client handling that the bundled + * Logback, Log4j 2 and {@code java.util.logging} integrations share, so a new integration behaves + * the same way. + */ +@NullMarked +package com.exceptionless.logging; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/EnvironmentInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/EnvironmentInfo.java new file mode 100644 index 0000000..ff0cbc6 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/EnvironmentInfo.java @@ -0,0 +1,183 @@ +package com.exceptionless.models; + +import com.exceptionless.json.JsonSerializable; +import com.exceptionless.json.JsonWriter; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** The machine and process an event came from ({@code @environment}). */ +public final class EnvironmentInfo implements JsonSerializable { + private @Nullable Integer processorCount; + private @Nullable Long totalPhysicalMemory; + private @Nullable Long availablePhysicalMemory; + private @Nullable String commandLine; + private @Nullable String processName; + private @Nullable String processId; + private @Nullable Long processMemorySize; + private @Nullable String threadName; + private @Nullable String threadId; + private @Nullable String architecture; + private @Nullable String osName; + private @Nullable String osVersion; + private @Nullable String ipAddress; + private @Nullable String machineName; + private @Nullable String installId; + private @Nullable String runtimeVersion; + private final Map data = new LinkedHashMap<>(); + + public @Nullable Integer getProcessorCount() { + return processorCount; + } + + public void setProcessorCount(@Nullable Integer processorCount) { + this.processorCount = processorCount; + } + + public @Nullable Long getTotalPhysicalMemory() { + return totalPhysicalMemory; + } + + public void setTotalPhysicalMemory(@Nullable Long totalPhysicalMemory) { + this.totalPhysicalMemory = totalPhysicalMemory; + } + + public @Nullable Long getAvailablePhysicalMemory() { + return availablePhysicalMemory; + } + + public void setAvailablePhysicalMemory(@Nullable Long availablePhysicalMemory) { + this.availablePhysicalMemory = availablePhysicalMemory; + } + + public @Nullable String getCommandLine() { + return commandLine; + } + + public void setCommandLine(@Nullable String commandLine) { + this.commandLine = commandLine; + } + + public @Nullable String getProcessName() { + return processName; + } + + public void setProcessName(@Nullable String processName) { + this.processName = processName; + } + + public @Nullable String getProcessId() { + return processId; + } + + public void setProcessId(@Nullable String processId) { + this.processId = processId; + } + + public @Nullable Long getProcessMemorySize() { + return processMemorySize; + } + + public void setProcessMemorySize(@Nullable Long processMemorySize) { + this.processMemorySize = processMemorySize; + } + + public @Nullable String getThreadName() { + return threadName; + } + + public void setThreadName(@Nullable String threadName) { + this.threadName = threadName; + } + + public @Nullable String getThreadId() { + return threadId; + } + + public void setThreadId(@Nullable String threadId) { + this.threadId = threadId; + } + + public @Nullable String getArchitecture() { + return architecture; + } + + public void setArchitecture(@Nullable String architecture) { + this.architecture = architecture; + } + + public @Nullable String getOsName() { + return osName; + } + + public void setOsName(@Nullable String osName) { + this.osName = osName; + } + + public @Nullable String getOsVersion() { + return osVersion; + } + + public void setOsVersion(@Nullable String osVersion) { + this.osVersion = osVersion; + } + + public @Nullable String getIpAddress() { + return ipAddress; + } + + public void setIpAddress(@Nullable String ipAddress) { + this.ipAddress = ipAddress; + } + + public @Nullable String getMachineName() { + return machineName; + } + + public void setMachineName(@Nullable String machineName) { + this.machineName = machineName; + } + + public @Nullable String getInstallId() { + return installId; + } + + public void setInstallId(@Nullable String installId) { + this.installId = installId; + } + + public @Nullable String getRuntimeVersion() { + return runtimeVersion; + } + + public void setRuntimeVersion(@Nullable String runtimeVersion) { + this.runtimeVersion = runtimeVersion; + } + + public Map getData() { + return data; + } + + @Override + public void toJson(JsonWriter writer) { + writer.beginObject(); + writer.fieldIfNotEmpty("processor_count", processorCount); + writer.fieldIfNotEmpty("total_physical_memory", totalPhysicalMemory); + writer.fieldIfNotEmpty("available_physical_memory", availablePhysicalMemory); + writer.fieldIfNotEmpty("command_line", commandLine); + writer.fieldIfNotEmpty("process_name", processName); + writer.fieldIfNotEmpty("process_id", processId); + writer.fieldIfNotEmpty("process_memory_size", processMemorySize); + writer.fieldIfNotEmpty("thread_name", threadName); + writer.fieldIfNotEmpty("thread_id", threadId); + writer.fieldIfNotEmpty("architecture", architecture); + writer.fieldIfNotEmpty("o_s_name", osName); + writer.fieldIfNotEmpty("o_s_version", osVersion); + writer.fieldIfNotEmpty("ip_address", ipAddress); + writer.fieldIfNotEmpty("machine_name", machineName); + writer.fieldIfNotEmpty("install_id", installId); + writer.fieldIfNotEmpty("runtime_version", runtimeVersion); + writer.fieldIfNotEmpty("data", data); + writer.endObject(); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/ErrorInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/ErrorInfo.java new file mode 100644 index 0000000..b703498 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/ErrorInfo.java @@ -0,0 +1,97 @@ +package com.exceptionless.models; + +import com.exceptionless.json.JsonSerializable; +import com.exceptionless.json.JsonWriter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * An error ({@code @error}) and its chain of causes. Modules are only set on the outermost error. + */ +public final class ErrorInfo implements JsonSerializable { + private @Nullable String message; + private @Nullable String type; + private @Nullable String code; + private @Nullable ErrorInfo inner; + private final List stackTrace = new ArrayList<>(); + private final List modules = new ArrayList<>(); + private final Map data = new LinkedHashMap<>(); + + public @Nullable String getMessage() { + return message; + } + + public void setMessage(@Nullable String message) { + this.message = message; + } + + /** {@return the fully qualified exception class name} */ + public @Nullable String getType() { + return type; + } + + public void setType(@Nullable String type) { + this.type = type; + } + + public @Nullable String getCode() { + return code; + } + + public void setCode(@Nullable String code) { + this.code = code; + } + + /** {@return the cause of this error, if any} */ + public @Nullable ErrorInfo getInner() { + return inner; + } + + public void setInner(@Nullable ErrorInfo inner) { + this.inner = inner; + } + + public List getStackTrace() { + return stackTrace; + } + + public List getModules() { + return modules; + } + + public Map getData() { + return data; + } + + /** {@return the innermost (root cause) error} */ + public ErrorInfo getRootCause() { + ErrorInfo current = this; + while (current.inner != null) { + current = current.inner; + } + return current; + } + + @Override + public void toJson(JsonWriter writer) { + writer + .beginObject() + .fieldIfNotEmpty("message", message) + .fieldIfNotEmpty("type", type) + .fieldIfNotEmpty("code", code) + .fieldIfNotEmpty("data", data) + .field("inner", inner) + .fieldIfNotEmpty("stack_trace", stackTrace) + .fieldIfNotEmpty("modules", modules) + .endObject(); + } + + @Override + public String toString() { + String name = type != null ? type : ""; + return message != null ? name + ": " + message : name; + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/Event.java b/exceptionless-core/src/main/java/com/exceptionless/models/Event.java new file mode 100644 index 0000000..9586565 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/Event.java @@ -0,0 +1,284 @@ +package com.exceptionless.models; + +import com.exceptionless.json.JsonSerializable; +import com.exceptionless.json.JsonWriter; +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import org.jspecify.annotations.Nullable; + +/** + * An Exceptionless event. Events are mutable while they move through the plugin pipeline and are + * serialized when they are queued. + */ +public final class Event implements JsonSerializable { + private @Nullable String type; + private @Nullable String source; + private @Nullable OffsetDateTime date; + private final Set tags = new LinkedHashSet<>(); + private @Nullable String message; + private @Nullable String geo; + private @Nullable BigDecimal value; + private @Nullable Integer count; + private final Map data = new LinkedHashMap<>(); + private @Nullable String referenceId; + + public @Nullable String getType() { + return type; + } + + public void setType(@Nullable String type) { + this.type = type; + } + + public boolean isError() { + return EventTypes.ERROR.equals(type); + } + + public boolean isLog() { + return EventTypes.LOG.equals(type); + } + + public boolean isSessionStart() { + return EventTypes.SESSION.equals(type); + } + + public boolean isSessionEnd() { + return EventTypes.SESSION_END.equals(type); + } + + public boolean isNotFound() { + return EventTypes.NOT_FOUND.equals(type); + } + + public @Nullable String getSource() { + return source; + } + + public void setSource(@Nullable String source) { + this.source = source; + } + + public @Nullable OffsetDateTime getDate() { + return date; + } + + public void setDate(@Nullable OffsetDateTime date) { + this.date = date; + } + + /** + * The event's tags, as a read-only view. Use {@link #addTags(String...)} and {@link + * #removeTags(String...)} to change them. + */ + public Set getTags() { + return Collections.unmodifiableSet(tags); + } + + /** Whether the event has the tag. */ + public boolean hasTag(@Nullable String tag) { + String stripped = normalizeTag(tag); + return stripped != null && tags.contains(stripped); + } + + /** Adds tags. Surrounding whitespace is removed and blank tags are ignored. */ + public void addTags(String... tags) { + addTags(Arrays.asList(tags)); + } + + public void addTags(Collection tags) { + for (String tag : tags) { + String stripped = normalizeTag(tag); + if (stripped != null) { + this.tags.add(stripped); + } + } + } + + public void removeTags(String... tags) { + for (String tag : tags) { + String stripped = normalizeTag(tag); + if (stripped != null) { + this.tags.remove(stripped); + } + } + } + + private static @Nullable String normalizeTag(@Nullable String tag) { + if (tag == null) { + return null; + } + String stripped = tag.strip(); + return stripped.isEmpty() ? null : stripped; + } + + public @Nullable String getMessage() { + return message; + } + + public void setMessage(@Nullable String message) { + this.message = message; + } + + /** {@return the location as {@code "latitude,longitude"}} */ + public @Nullable String getGeo() { + return geo; + } + + public void setGeo(@Nullable String geo) { + this.geo = geo; + } + + public @Nullable BigDecimal getValue() { + return value; + } + + public void setValue(@Nullable BigDecimal value) { + this.value = value; + } + + public @Nullable Integer getCount() { + return count; + } + + public void setCount(@Nullable Integer count) { + this.count = count; + } + + /** Extended data. The map is live and may be modified. */ + public Map getData() { + return data; + } + + public @Nullable String getReferenceId() { + return referenceId; + } + + public void setReferenceId(@Nullable String referenceId) { + this.referenceId = referenceId; + } + + public @Nullable ErrorInfo getError() { + return data.get(KnownDataKeys.ERROR) instanceof ErrorInfo error ? error : null; + } + + public void setError(@Nullable ErrorInfo error) { + putOrRemove(KnownDataKeys.ERROR, error); + } + + public @Nullable RequestInfo getRequestInfo() { + return data.get(KnownDataKeys.REQUEST_INFO) instanceof RequestInfo request ? request : null; + } + + public void setRequestInfo(@Nullable RequestInfo requestInfo) { + putOrRemove(KnownDataKeys.REQUEST_INFO, requestInfo); + } + + public @Nullable EnvironmentInfo getEnvironmentInfo() { + return data.get(KnownDataKeys.ENVIRONMENT_INFO) instanceof EnvironmentInfo env ? env : null; + } + + public void setEnvironmentInfo(@Nullable EnvironmentInfo environmentInfo) { + putOrRemove(KnownDataKeys.ENVIRONMENT_INFO, environmentInfo); + } + + public @Nullable UserInfo getUserInfo() { + return data.get(KnownDataKeys.USER_INFO) instanceof UserInfo user ? user : null; + } + + public void setUserInfo(@Nullable UserInfo userInfo) { + putOrRemove(KnownDataKeys.USER_INFO, userInfo); + } + + public @Nullable UserDescription getUserDescription() { + return data.get(KnownDataKeys.USER_DESCRIPTION) instanceof UserDescription d ? d : null; + } + + public void setUserDescription(@Nullable UserDescription userDescription) { + putOrRemove(KnownDataKeys.USER_DESCRIPTION, userDescription); + } + + public @Nullable ManualStackingInfo getManualStackingInfo() { + return data.get(KnownDataKeys.MANUAL_STACKING_INFO) instanceof ManualStackingInfo info + ? info + : null; + } + + public void setManualStackingInfo(@Nullable ManualStackingInfo info) { + putOrRemove(KnownDataKeys.MANUAL_STACKING_INFO, info); + } + + /** The log level ({@code @level}), e.g. {@code "error"}. */ + public @Nullable String getLevel() { + return data.get(KnownDataKeys.LEVEL) instanceof String level ? level : null; + } + + public void setLevel(@Nullable String level) { + putOrRemove(KnownDataKeys.LEVEL, level); + } + + public @Nullable String getVersion() { + return data.get(KnownDataKeys.VERSION) instanceof String version ? version : null; + } + + public void setVersion(@Nullable String version) { + putOrRemove(KnownDataKeys.VERSION, version); + } + + public @Nullable String getSubmissionMethod() { + return data.get(KnownDataKeys.SUBMISSION_METHOD) instanceof String method ? method : null; + } + + public void setSubmissionMethod(@Nullable String submissionMethod) { + putOrRemove(KnownDataKeys.SUBMISSION_METHOD, submissionMethod); + } + + /** {@return the id of the session this event belongs to ({@code @ref:session})} */ + public @Nullable String getSessionId() { + return getEventReference("session"); + } + + public @Nullable String getEventReference(String name) { + return data.get(KnownDataKeys.REFERENCE_PREFIX + name) instanceof String id ? id : null; + } + + public void setEventReference(String name, @Nullable String id) { + putOrRemove(KnownDataKeys.REFERENCE_PREFIX + name, id); + } + + private void putOrRemove(String key, @Nullable Object value) { + if (value == null) { + data.remove(key); + } else { + data.put(key, value); + } + } + + @Override + public void toJson(JsonWriter writer) { + writer + .beginObject() + .fieldIfNotEmpty("type", type) + .fieldIfNotEmpty("source", source) + .field("date", date) + .fieldIfNotEmpty("tags", tags) + .fieldIfNotEmpty("message", message) + .fieldIfNotEmpty("geo", geo) + .field("value", value) + .field("count", count) + .fieldIfNotEmpty("data", data) + .fieldIfNotEmpty("reference_id", referenceId) + .endObject(); + } + + @Override + public String toString() { + return "Event{type=" + type + ", source=" + source + ", message=" + message + "}"; + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/EventTags.java b/exceptionless-core/src/main/java/com/exceptionless/models/EventTags.java new file mode 100644 index 0000000..b9fe619 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/EventTags.java @@ -0,0 +1,9 @@ +package com.exceptionless.models; + +/** Tags with special meaning to the Exceptionless server. */ +public final class EventTags { + public static final String CRITICAL = "Critical"; + public static final String INTERNAL = "Internal"; + + private EventTags() {} +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/EventTypes.java b/exceptionless-core/src/main/java/com/exceptionless/models/EventTypes.java new file mode 100644 index 0000000..13f6d38 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/EventTypes.java @@ -0,0 +1,14 @@ +package com.exceptionless.models; + +/** Built-in event types understood by the Exceptionless server. Custom types are allowed. */ +public final class EventTypes { + public static final String ERROR = "error"; + public static final String FEATURE_USAGE = "usage"; + public static final String LOG = "log"; + public static final String NOT_FOUND = "404"; + public static final String SESSION = "session"; + public static final String SESSION_END = "sessionend"; + public static final String SESSION_HEARTBEAT = "heartbeat"; + + private EventTypes() {} +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/KnownDataKeys.java b/exceptionless-core/src/main/java/com/exceptionless/models/KnownDataKeys.java new file mode 100644 index 0000000..d8167b2 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/KnownDataKeys.java @@ -0,0 +1,24 @@ +package com.exceptionless.models; + +/** Data keys with special meaning to the Exceptionless server. */ +public final class KnownDataKeys { + public static final String ERROR = "@error"; + public static final String SIMPLE_ERROR = "@simple_error"; + public static final String REQUEST_INFO = "@request"; + public static final String TRACE_LOG = "@trace"; + public static final String ENVIRONMENT_INFO = "@environment"; + public static final String USER_INFO = "@user"; + public static final String USER_DESCRIPTION = "@user_description"; + public static final String VERSION = "@version"; + public static final String LEVEL = "@level"; + public static final String SUBMISSION_METHOD = "@submission_method"; + public static final String MANUAL_STACKING_INFO = "@stack"; + + /** Prefix for event references, e.g. {@code @ref:session}. */ + public static final String REFERENCE_PREFIX = "@ref:"; + + /** Extra exception properties inside {@code @error.data}. */ + public static final String EXTENDED_DATA = "@ext"; + + private KnownDataKeys() {} +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/ManualStackingInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/ManualStackingInfo.java new file mode 100644 index 0000000..14299c7 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/ManualStackingInfo.java @@ -0,0 +1,37 @@ +package com.exceptionless.models; + +import com.exceptionless.json.JsonSerializable; +import com.exceptionless.json.JsonWriter; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * Overrides the server's automatic stacking ({@code @stack}). All events with the same signature + * data are grouped into one stack. + */ +public record ManualStackingInfo(@Nullable String title, Map signatureData) + implements JsonSerializable { + + /** The signature key used by {@code setManualStackingKey}, shared with the other clients. */ + public static final String MANUAL_STACKING_KEY = "ManualStackingKey"; + + /** Copies {@code signatureData} into an unmodifiable map that keeps its order. */ + public ManualStackingInfo { + signatureData = Collections.unmodifiableMap(new LinkedHashMap<>(signatureData)); + } + + public static ManualStackingInfo ofKey(String key, @Nullable String title) { + return new ManualStackingInfo(title, Map.of(MANUAL_STACKING_KEY, key)); + } + + @Override + public void toJson(JsonWriter writer) { + writer + .beginObject() + .fieldIfNotEmpty("title", title) + .field("signature_data", signatureData) + .endObject(); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/ModuleInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/ModuleInfo.java new file mode 100644 index 0000000..4f871c0 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/ModuleInfo.java @@ -0,0 +1,33 @@ +package com.exceptionless.models; + +import com.exceptionless.json.JsonSerializable; +import com.exceptionless.json.JsonWriter; +import org.jspecify.annotations.Nullable; + +/** + * A loaded library (jar) and its version. + * + * @param moduleId the module's index, referenced by {@link StackFrame#getModuleId()} + * @param name the library name + * @param version the library version, if known + * @param entry whether the application was started from this library + */ +public record ModuleInfo(int moduleId, String name, @Nullable String version, boolean entry) + implements JsonSerializable { + + @Override + public void toJson(JsonWriter writer) { + writer + .beginObject() + .field("module_id", moduleId) + .fieldIfNotEmpty("name", name) + .fieldIfNotEmpty("version", version) + .field("is_entry", entry) + .endObject(); + } + + @Override + public String toString() { + return version != null ? name + " " + version : name; + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/RequestInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/RequestInfo.java new file mode 100644 index 0000000..a02763c --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/RequestInfo.java @@ -0,0 +1,147 @@ +package com.exceptionless.models; + +import com.exceptionless.json.JsonSerializable; +import com.exceptionless.json.JsonWriter; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** The HTTP request an event happened during ({@code @request}). */ +public final class RequestInfo implements JsonSerializable { + private @Nullable String userAgent; + private @Nullable String httpMethod; + private @Nullable Boolean secure; + private @Nullable String host; + private @Nullable Integer port; + private @Nullable String path; + private @Nullable String referrer; + private @Nullable String clientIpAddress; + private @Nullable Map> headers; + private @Nullable Map cookies; + private @Nullable Object postData; + private @Nullable Map queryString; + private final Map data = new LinkedHashMap<>(); + + public @Nullable String getUserAgent() { + return userAgent; + } + + public void setUserAgent(@Nullable String userAgent) { + this.userAgent = userAgent; + } + + public @Nullable String getHttpMethod() { + return httpMethod; + } + + public void setHttpMethod(@Nullable String httpMethod) { + this.httpMethod = httpMethod; + } + + /** {@return whether the request used HTTPS} */ + public @Nullable Boolean getSecure() { + return secure; + } + + public void setSecure(@Nullable Boolean secure) { + this.secure = secure; + } + + public @Nullable String getHost() { + return host; + } + + public void setHost(@Nullable String host) { + this.host = host; + } + + public @Nullable Integer getPort() { + return port; + } + + public void setPort(@Nullable Integer port) { + this.port = port; + } + + public @Nullable String getPath() { + return path; + } + + public void setPath(@Nullable String path) { + this.path = path; + } + + public @Nullable String getReferrer() { + return referrer; + } + + public void setReferrer(@Nullable String referrer) { + this.referrer = referrer; + } + + public @Nullable String getClientIpAddress() { + return clientIpAddress; + } + + public void setClientIpAddress(@Nullable String clientIpAddress) { + this.clientIpAddress = clientIpAddress; + } + + /** Request headers. Each header maps to all of its values. */ + public @Nullable Map> getHeaders() { + return headers; + } + + public void setHeaders(@Nullable Map> headers) { + this.headers = headers; + } + + public @Nullable Map getCookies() { + return cookies; + } + + public void setCookies(@Nullable Map cookies) { + this.cookies = cookies; + } + + /** Form fields (a map) or the raw request body (a string). */ + public @Nullable Object getPostData() { + return postData; + } + + public void setPostData(@Nullable Object postData) { + this.postData = postData; + } + + public @Nullable Map getQueryString() { + return queryString; + } + + public void setQueryString(@Nullable Map queryString) { + this.queryString = queryString; + } + + public Map getData() { + return data; + } + + @Override + public void toJson(JsonWriter writer) { + writer.beginObject(); + writer.fieldIfNotEmpty("user_agent", userAgent); + writer.fieldIfNotEmpty("http_method", httpMethod); + writer.fieldIfNotEmpty("is_secure", secure); + writer.fieldIfNotEmpty("host", host); + writer.fieldIfNotEmpty("port", port); + writer.fieldIfNotEmpty("path", path); + writer.fieldIfNotEmpty("referrer", referrer); + writer.fieldIfNotEmpty("client_ip_address", clientIpAddress); + writer.fieldIfNotEmpty("headers", headers); + writer.fieldIfNotEmpty("cookies", cookies); + writer.fieldIfNotEmpty("post_data", postData); + writer.fieldIfNotEmpty("query_string", queryString); + writer.fieldIfNotEmpty("data", data); + writer.endObject(); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/StackFrame.java b/exceptionless-core/src/main/java/com/exceptionless/models/StackFrame.java new file mode 100644 index 0000000..cbba5db --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/StackFrame.java @@ -0,0 +1,116 @@ +package com.exceptionless.models; + +import com.exceptionless.json.JsonSerializable; +import com.exceptionless.json.JsonWriter; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** A single stack frame. Java packages map to {@code declaring_namespace}. */ +public final class StackFrame implements JsonSerializable { + private @Nullable String name; + private @Nullable String declaringNamespace; + private @Nullable String declaringType; + private @Nullable String fileName; + private @Nullable Integer lineNumber; + private @Nullable Integer column; + private @Nullable Integer moduleId; + private final Map data = new LinkedHashMap<>(); + + public @Nullable String getName() { + return name; + } + + public void setName(@Nullable String name) { + this.name = name; + } + + public @Nullable String getDeclaringNamespace() { + return declaringNamespace; + } + + public void setDeclaringNamespace(@Nullable String declaringNamespace) { + this.declaringNamespace = declaringNamespace; + } + + public @Nullable String getDeclaringType() { + return declaringType; + } + + public void setDeclaringType(@Nullable String declaringType) { + this.declaringType = declaringType; + } + + public @Nullable String getFileName() { + return fileName; + } + + public void setFileName(@Nullable String fileName) { + this.fileName = fileName; + } + + public @Nullable Integer getLineNumber() { + return lineNumber; + } + + public void setLineNumber(@Nullable Integer lineNumber) { + this.lineNumber = lineNumber; + } + + public @Nullable Integer getColumn() { + return column; + } + + public void setColumn(@Nullable Integer column) { + this.column = column; + } + + public @Nullable Integer getModuleId() { + return moduleId; + } + + public void setModuleId(@Nullable Integer moduleId) { + this.moduleId = moduleId; + } + + public Map getData() { + return data; + } + + /** {@code namespace.Type.method}, the way the frame is displayed. */ + public String getFullName() { + StringBuilder sb = new StringBuilder(); + if (declaringNamespace != null && !declaringNamespace.isEmpty()) { + sb.append(declaringNamespace).append('.'); + } + if (declaringType != null && !declaringType.isEmpty()) { + sb.append(declaringType).append('.'); + } + sb.append(Objects.requireNonNullElse(name, "")); + return sb.toString(); + } + + @Override + public void toJson(JsonWriter writer) { + writer + .beginObject() + .fieldIfNotEmpty("name", name) + .fieldIfNotEmpty("declaring_namespace", declaringNamespace) + .fieldIfNotEmpty("declaring_type", declaringType) + .fieldIfNotEmpty("file_name", fileName) + .field("line_number", lineNumber) + .field("column", column) + .field("module_id", moduleId) + .fieldIfNotEmpty("data", data) + .endObject(); + } + + @Override + public String toString() { + if (fileName == null) { + return getFullName(); + } + return getFullName() + "(" + fileName + (lineNumber != null ? ":" + lineNumber : "") + ")"; + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/UserDescription.java b/exceptionless-core/src/main/java/com/exceptionless/models/UserDescription.java new file mode 100644 index 0000000..6473560 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/UserDescription.java @@ -0,0 +1,33 @@ +package com.exceptionless.models; + +import com.exceptionless.json.JsonSerializable; +import com.exceptionless.json.JsonWriter; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** A user supplied description of what they were doing ({@code @user_description}). */ +public record UserDescription( + @Nullable String emailAddress, @Nullable String description, @Nullable Map data) + implements JsonSerializable { + + /** Copies {@code data} into an unmodifiable map that keeps its order. */ + public UserDescription { + data = data == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(data)); + } + + public UserDescription(@Nullable String emailAddress, @Nullable String description) { + this(emailAddress, description, null); + } + + @Override + public void toJson(JsonWriter writer) { + writer + .beginObject() + .fieldIfNotEmpty("email_address", emailAddress) + .fieldIfNotEmpty("description", description) + .fieldIfNotEmpty("data", data) + .endObject(); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/UserInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/UserInfo.java new file mode 100644 index 0000000..ab193e3 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/UserInfo.java @@ -0,0 +1,37 @@ +package com.exceptionless.models; + +import com.exceptionless.json.JsonSerializable; +import com.exceptionless.json.JsonWriter; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** The user an event happened to ({@code @user}). */ +public record UserInfo( + @Nullable String identity, @Nullable String name, @Nullable Map data) + implements JsonSerializable { + + /** Copies {@code data} into an unmodifiable map that keeps its order. */ + public UserInfo { + data = data == null ? null : Collections.unmodifiableMap(new LinkedHashMap<>(data)); + } + + public UserInfo(@Nullable String identity, @Nullable String name) { + this(identity, name, null); + } + + public UserInfo(@Nullable String identity) { + this(identity, null, null); + } + + @Override + public void toJson(JsonWriter writer) { + writer + .beginObject() + .fieldIfNotEmpty("identity", identity) + .fieldIfNotEmpty("name", name) + .fieldIfNotEmpty("data", data) + .endObject(); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/package-info.java b/exceptionless-core/src/main/java/com/exceptionless/models/package-info.java new file mode 100644 index 0000000..70e11c8 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/package-info.java @@ -0,0 +1,5 @@ +/** The Exceptionless event model, serialized using the server's snake_case wire format. */ +@NullMarked +package com.exceptionless.models; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-core/src/main/java/com/exceptionless/package-info.java b/exceptionless-core/src/main/java/com/exceptionless/package-info.java new file mode 100644 index 0000000..a76c0ff --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/package-info.java @@ -0,0 +1,10 @@ +/** + * The Exceptionless client for Java. + * + *

Start with {@link com.exceptionless.Exceptionless} for a shared default client, or create your + * own {@link com.exceptionless.ExceptionlessClient}. + */ +@NullMarked +package com.exceptionless; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/CancelSessionsWithNoUserPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/CancelSessionsWithNoUserPlugin.java new file mode 100644 index 0000000..72f397b --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/CancelSessionsWithNoUserPlugin.java @@ -0,0 +1,31 @@ +package com.exceptionless.plugins; + +import com.exceptionless.internal.Strings; +import com.exceptionless.models.Event; +import com.exceptionless.models.UserInfo; + +/** + * Discards session start events that can't be tied to a user or a client managed session id, + * because the server can't track them. + */ +public final class CancelSessionsWithNoUserPlugin implements EventPlugin { + public static final int PRIORITY = 900; + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public void run(EventPluginContext context) { + Event event = context.getEvent(); + if (!event.isSessionStart()) { + return; + } + UserInfo user = event.getUserInfo(); + if ((user == null || Strings.isBlank(user.identity())) + && Strings.isBlank(event.getReferenceId())) { + context.cancel(); + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/ConfigurationDefaultsPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/ConfigurationDefaultsPlugin.java new file mode 100644 index 0000000..f7afd22 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/ConfigurationDefaultsPlugin.java @@ -0,0 +1,44 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.internal.DataNormalizer; +import com.exceptionless.json.JsonSerializable; +import com.exceptionless.models.Event; +import java.util.Map; +import java.util.function.Predicate; + +/** Adds the configured default tags and data to every event. */ +public final class ConfigurationDefaultsPlugin implements EventPlugin { + public static final int PRIORITY = 10; + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public void run(EventPluginContext context) { + ExceptionlessConfiguration config = context.getConfiguration(); + Event event = context.getEvent(); + event.addTags(config.getDefaultTags()); + + Map defaults = config.getDefaultData(); + if (defaults.isEmpty()) { + return; + } + Predicate excluded = config::isDataExcluded; + for (Map.Entry entry : defaults.entrySet()) { + String key = entry.getKey(); + if (event.getData().containsKey(key) || excluded.test(key)) { + continue; + } + Object value = entry.getValue(); + if (!(value instanceof JsonSerializable)) { + value = DataNormalizer.normalize(value, DataNormalizer.DEFAULT_MAX_DEPTH, excluded); + } + if (value != null) { + event.getData().put(key, value); + } + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/ContextData.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/ContextData.java new file mode 100644 index 0000000..d69adea --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/ContextData.java @@ -0,0 +1,71 @@ +package com.exceptionless.plugins; + +import com.exceptionless.models.RequestInfo; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * Data that travels with an event through the plugin pipeline but is not sent to the server, such + * as the original exception. + */ +public final class ContextData { + private static final String EXCEPTION = "@@_Exception"; + private static final String IS_UNHANDLED = "@@_IsUnhandledError"; + private static final String SUBMISSION_METHOD = "@@_SubmissionMethod"; + private static final String REQUEST_INFO = "@@_RequestInfo"; + + private final Map values = new LinkedHashMap<>(); + + public @Nullable Object get(String key) { + return values.get(key); + } + + public ContextData put(String key, @Nullable Object value) { + if (value == null) { + values.remove(key); + } else { + values.put(key, value); + } + return this; + } + + public boolean containsKey(String key) { + return values.containsKey(key); + } + + public @Nullable Throwable getException() { + return values.get(EXCEPTION) instanceof Throwable throwable ? throwable : null; + } + + public ContextData setException(@Nullable Throwable exception) { + return put(EXCEPTION, exception); + } + + public boolean isUnhandledError() { + return Boolean.TRUE.equals(values.get(IS_UNHANDLED)); + } + + /** Marks the error as unhandled (it crashed a thread or request). */ + public ContextData markAsUnhandledError() { + return put(IS_UNHANDLED, true); + } + + public @Nullable String getSubmissionMethod() { + return values.get(SUBMISSION_METHOD) instanceof String method ? method : null; + } + + /** How the event was captured, e.g. {@code uncaughtExceptionHandler} or {@code servletFilter}. */ + public ContextData setSubmissionMethod(@Nullable String submissionMethod) { + return put(SUBMISSION_METHOD, submissionMethod); + } + + public @Nullable RequestInfo getRequestInfo() { + return values.get(REQUEST_INFO) instanceof RequestInfo request ? request : null; + } + + /** The request the event happened during. Privacy settings are applied by the pipeline. */ + public ContextData setRequestInfo(@Nullable RequestInfo requestInfo) { + return put(REQUEST_INFO, requestInfo); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/DuplicateCheckerPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/DuplicateCheckerPlugin.java new file mode 100644 index 0000000..a8dd21f --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/DuplicateCheckerPlugin.java @@ -0,0 +1,156 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.models.ErrorInfo; +import com.exceptionless.models.Event; +import com.exceptionless.models.StackFrame; +import java.time.Duration; +import java.time.Instant; +import java.time.InstantSource; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ScheduledFuture; +import org.jspecify.annotations.Nullable; + +/** + * Collapses repeated errors. When the same error (type, message and stack) happens again within the + * window, it is counted instead of sent, and one event with the total count is sent at the end of + * the window. + */ +public final class DuplicateCheckerPlugin implements EventPlugin { + public static final int PRIORITY = 1010; + static final int MAX_HASHES = 50; + + private final Duration window; + private final InstantSource clock; + private final Deque processed = new ArrayDeque<>(); + private final Map merged = new LinkedHashMap<>(); + private @Nullable ScheduledFuture timer; + private @Nullable ExceptionlessClient client; + + /** An error hash and when it was first seen in the current window. */ + private record Processed(int hash, Instant seenAt) {} + + public DuplicateCheckerPlugin() { + this(Duration.ofSeconds(30), InstantSource.system()); + } + + DuplicateCheckerPlugin(Duration window, InstantSource clock) { + this.window = window; + this.clock = clock; + } + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public synchronized void startup(ExceptionlessClient client) { + this.client = client; + if (timer == null) { + timer = client.scheduleAtFixedRate(this::flush, window, window); + } + } + + @Override + public void run(EventPluginContext context) { + Event event = context.getEvent(); + if (!event.isError() || event.getError() == null) { + return; + } + int hash = hash(event); + Instant now = clock.instant(); + Instant cutoff = now.minus(window); + synchronized (this) { + Merged existing = merged.get(hash); + if (existing != null) { + existing.count += countOf(event); + context.cancel(); + return; + } + for (Iterator it = processed.iterator(); it.hasNext(); ) { + Processed entry = it.next(); + if (!entry.seenAt().isAfter(cutoff)) { + it.remove(); + } else if (entry.hash() == hash) { + merged.put(hash, new Merged(event, countOf(event))); + context.cancel(); + return; + } + } + processed.addLast(new Processed(hash, now)); + while (processed.size() > MAX_HASHES) { + processed.removeFirst(); + } + } + } + + /** Stops the flush timer and sends the counted duplicates. */ + @Override + public void suspend(ExceptionlessClient client) { + synchronized (this) { + if (timer != null) { + timer.cancel(false); + timer = null; + } + } + flush(); + } + + /** Sends the counted duplicates. */ + public void flush() { + List toSend; + ExceptionlessClient target; + synchronized (this) { + if (merged.isEmpty()) { + return; + } + toSend = new ArrayList<>(merged.values()); + merged.clear(); + target = client; + } + if (target == null) { + return; + } + for (Merged item : toSend) { + item.event.setCount(item.count); + target.getQueue().enqueue(item.event); + } + } + + private static int countOf(Event event) { + return event.getCount() != null && event.getCount() > 0 ? event.getCount() : 1; + } + + static int hash(Event event) { + int hash = Objects.hash(event.getType(), event.getSource()); + ErrorInfo error = event.getError(); + while (error != null) { + hash = 31 * hash + Objects.hashCode(error.getType()); + hash = 31 * hash + Objects.hashCode(error.getMessage()); + for (StackFrame frame : error.getStackTrace()) { + hash = 31 * hash + frame.getFullName().hashCode(); + hash = 31 * hash + Objects.hashCode(frame.getLineNumber()); + } + error = error.getInner(); + } + return hash; + } + + private static final class Merged { + final Event event; + int count; + + Merged(Event event, int count) { + this.event = event; + this.count = count; + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/EnvironmentInfoPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/EnvironmentInfoPlugin.java new file mode 100644 index 0000000..f0eb471 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/EnvironmentInfoPlugin.java @@ -0,0 +1,29 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.internal.EnvironmentCollector; +import com.exceptionless.models.Event; + +/** Adds machine, OS and JVM information ({@code @environment}). */ +public final class EnvironmentInfoPlugin implements EventPlugin { + public static final int PRIORITY = 50; + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public void run(EventPluginContext context) { + Event event = context.getEvent(); + if (event.getEnvironmentInfo() != null) { + return; + } + ExceptionlessConfiguration config = context.getConfiguration(); + event.setEnvironmentInfo( + EnvironmentCollector.collect( + config.isIncludeMachineName(), + config.isIncludeIpAddress(), + context.getClient().getInstallId())); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/ErrorPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/ErrorPlugin.java new file mode 100644 index 0000000..8244253 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/ErrorPlugin.java @@ -0,0 +1,79 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.internal.ErrorConverter; +import com.exceptionless.internal.ModuleCollector; +import com.exceptionless.models.Event; +import com.exceptionless.models.EventTypes; +import com.exceptionless.models.ModuleInfo; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.WeakHashMap; + +/** + * Converts the context exception into the {@code @error} model. Each exception is only reported + * once, including when it is later reported again wrapped in another exception (or vice versa), so + * an error that is both logged and caught by a framework integration doesn't show up twice. + */ +public final class ErrorPlugin implements EventPlugin { + public static final int PRIORITY = 20; + + /** Exceptions already reported. Weak, so reported exceptions can still be collected. */ + private final Set processed = Collections.newSetFromMap(new WeakHashMap<>()); + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public void run(EventPluginContext context) { + Throwable exception = context.getContextData().getException(); + if (exception == null) { + return; + } + if (!markProcessed(exception)) { + context.cancel(); + return; + } + Event event = context.getEvent(); + event.setType(EventTypes.ERROR); + if (event.getError() != null) { + return; + } + ExceptionlessConfiguration config = context.getConfiguration(); + List modules = config.isIncludeModules() ? ModuleCollector.getModules() : List.of(); + event.setError(ErrorConverter.convert(exception, config::isDataExcluded, modules)); + } + + /** + * Records the exception and its causes. Returns false when any of them was already reported, so a + * wrapper of an already reported exception (or a cause of one) isn't sent again. + */ + private boolean markProcessed(Throwable exception) { + List chain = new ArrayList<>(); + for (Throwable t = exception; + t != null && chain.size() < ErrorConverter.MAX_CAUSES && !chain.contains(t); + t = ErrorConverter.safeCause(t)) { + chain.add(t); + } + synchronized (processed) { + for (Throwable t : chain) { + if (processed.contains(t)) { + return false; + } + } + processed.addAll(chain); + } + return true; + } + + /** Visible for tests: forgets which exceptions were reported. */ + void reset() { + synchronized (processed) { + processed.clear(); + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/EventExclusionPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventExclusionPlugin.java new file mode 100644 index 0000000..56b1ebc --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventExclusionPlugin.java @@ -0,0 +1,93 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.LogLevel; +import com.exceptionless.internal.Log; +import com.exceptionless.models.ErrorInfo; +import com.exceptionless.models.Event; +import com.exceptionless.models.EventTypes; +import com.exceptionless.settings.ClientSettings; +import java.util.function.Predicate; + +/** + * Discards events using the project settings and custom exclusions: + * + *

    + *
  • log events below the {@code @@log:} minimum level + *
  • errors whose type (or the type of any cause) has {@code @@error:} set to false + *
  • any other event whose {@code @@:} setting is false + *
  • events matching {@link ExceptionlessConfiguration#addEventExclusion} + *
+ */ +public final class EventExclusionPlugin implements EventPlugin { + public static final int PRIORITY = 40; + private static final Log LOG = Log.get(EventExclusionPlugin.class); + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public void run(EventPluginContext context) { + ExceptionlessConfiguration config = context.getConfiguration(); + Event event = context.getEvent(); + + for (Predicate exclusion : config.getEventExclusions()) { + try { + if (exclusion.test(event)) { + LOG.debug("Cancelling event: excluded by a custom event exclusion"); + context.cancel(); + return; + } + } catch (RuntimeException e) { + LOG.warn("Event exclusion callback failed", e); + } + } + + ClientSettings settings = config.getSettings(); + String type = event.getType(); + if (event.isLog()) { + LogLevel level = LogLevel.parse(event.getLevel()); + if (level == null) { + return; + } + LogLevel minimum = settings.getMinLogLevel(event.getSource()); + if (minimum == null) { + minimum = config.getDefaultMinLogLevel(); + } + if (minimum != null && (minimum == LogLevel.OFF || !level.isAtLeast(minimum))) { + LogLevel min = minimum; + LOG.debug( + () -> + "Cancelling " + + level + + " log event from " + + event.getSource() + + " (minimum " + + min + + ")"); + context.cancel(); + } + return; + } + + if (type != null && !settings.getTypeAndSourceSetting(type, event.getSource(), true)) { + LOG.debug( + () -> "Cancelling event from excluded type/source: " + type + " " + event.getSource()); + context.cancel(); + return; + } + + ErrorInfo error = event.getError(); + while (error != null) { + if (!settings.getTypeAndSourceSetting(EventTypes.ERROR, error.getType(), true)) { + String errorType = error.getType(); + LOG.debug(() -> "Cancelling error from excluded exception type " + errorType); + context.cancel(); + return; + } + error = error.getInner(); + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPlugin.java new file mode 100644 index 0000000..018650f --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPlugin.java @@ -0,0 +1,45 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessClient; +import java.util.function.Consumer; + +/** + * A step in the event pipeline. Plugins run in ascending {@link #getPriority() priority} order on + * the thread that submitted the event, so they can read thread-local state (MDC, the current + * request...). They must be fast and must not block. + */ +public interface EventPlugin { + /** + * Creates a plugin from a lambda. + * + *
{@code
+   * config.addPlugin(EventPlugin.of("AddTenant", 100, ctx -> ctx.getEvent().addTags(tenant())));
+   * }
+ * + * @param name a unique name + * @param priority lower values run first + * @param action processes each event + */ + static EventPlugin of(String name, int priority, Consumer action) { + return new LambdaEventPlugin(name, priority, action); + } + + /** A unique name; adding a plugin with the same name replaces the existing one. */ + default String getName() { + return getClass().getSimpleName(); + } + + /** Lower values run first. The built-in plugins use values between 10 and 1010. */ + default int getPriority() { + return 0; + } + + /** Processes an event. Call {@link EventPluginContext#cancel()} to discard it. */ + void run(EventPluginContext context); + + /** Called once before the plugin first runs. */ + default void startup(ExceptionlessClient client) {} + + /** Called when the client is closed, so buffered work can be flushed. */ + default void suspend(ExceptionlessClient client) {} +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPluginContext.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPluginContext.java new file mode 100644 index 0000000..bd6670c --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPluginContext.java @@ -0,0 +1,49 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.models.Event; + +/** The event being processed, plus its context data and the client processing it. */ +public final class EventPluginContext { + private final ExceptionlessClient client; + private final Event event; + private final ContextData contextData; + private boolean cancelled; + + public EventPluginContext(ExceptionlessClient client, Event event, ContextData contextData) { + this.client = client; + this.event = event; + this.contextData = contextData; + } + + /** {@return the client processing the event} */ + public ExceptionlessClient getClient() { + return client; + } + + /** {@return the client's configuration} */ + public ExceptionlessConfiguration getConfiguration() { + return client.getConfiguration(); + } + + /** The event being processed. Plugins may change it. */ + public Event getEvent() { + return event; + } + + /** Data available to plugins but not sent to the server, such as the exception. */ + public ContextData getContextData() { + return contextData; + } + + /** Whether a plugin {@link #cancel() cancelled} the event. */ + public boolean isCancelled() { + return cancelled; + } + + /** Discards the event; no further plugins run. */ + public void cancel() { + this.cancelled = true; + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/HeartbeatPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/HeartbeatPlugin.java new file mode 100644 index 0000000..5213d8b --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/HeartbeatPlugin.java @@ -0,0 +1,94 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.internal.Strings; +import com.exceptionless.models.Event; +import com.exceptionless.models.KnownDataKeys; +import com.exceptionless.models.UserInfo; +import java.util.concurrent.ScheduledFuture; +import org.jspecify.annotations.Nullable; + +/** + * Keeps the current session alive by sending a heartbeat on an interval for the session id or user + * identity of the most recent event. + */ +public final class HeartbeatPlugin implements EventPlugin { + public static final int PRIORITY = 100; + + private @Nullable ScheduledFuture heartbeat; + private @Nullable String identity; + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public void run(EventPluginContext context) { + ExceptionlessConfiguration config = context.getConfiguration(); + Event event = context.getEvent(); + if (event.isSessionEnd()) { + stop(); + return; + } + String id = config.getCurrentSessionIdentifier(); + if (id == null) { + id = identityOf(event.getUserInfo()); + } + if (id == null + && config.getDefaultData().get(KnownDataKeys.USER_INFO) instanceof UserInfo user) { + id = identityOf(user); + } + if (id != null) { + start(context.getClient(), id, config); + } + } + + private static @Nullable String identityOf(@Nullable UserInfo user) { + if (user == null) { + return null; + } + String identity = user.identity(); + return Strings.isBlank(identity) ? null : identity; + } + + private synchronized void start( + ExceptionlessClient client, String id, ExceptionlessConfiguration config) { + if (heartbeat != null && !heartbeat.isDone() && id.equals(identity)) { + return; + } + stop(); + identity = id; + heartbeat = + client.scheduleAtFixedRate( + () -> sendHeartbeat(client, id), + config.getHeartbeatInterval(), + config.getHeartbeatInterval()); + } + + // Fire and forget: the next heartbeat is sent on schedule whatever this one's outcome. + @SuppressWarnings("FutureReturnValueIgnored") + private static void sendHeartbeat(ExceptionlessClient client, String id) { + client.submitSessionHeartbeat(id); + } + + /** {@return whether heartbeats are being sent} */ + public synchronized boolean isRunning() { + return heartbeat != null && !heartbeat.isDone(); + } + + /** Stops sending heartbeats (called when the session ends). */ + public synchronized void stop() { + if (heartbeat != null) { + heartbeat.cancel(false); + heartbeat = null; + } + identity = null; + } + + @Override + public void suspend(ExceptionlessClient client) { + stop(); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/IgnoreUserAgentPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/IgnoreUserAgentPlugin.java new file mode 100644 index 0000000..59122c9 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/IgnoreUserAgentPlugin.java @@ -0,0 +1,29 @@ +package com.exceptionless.plugins; + +import com.exceptionless.internal.Log; +import com.exceptionless.internal.Wildcard; +import com.exceptionless.models.RequestInfo; + +/** Discards events from requests whose user agent matches a bot pattern. */ +public final class IgnoreUserAgentPlugin implements EventPlugin { + public static final int PRIORITY = 65; + private static final Log LOG = Log.get(IgnoreUserAgentPlugin.class); + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public void run(EventPluginContext context) { + RequestInfo request = context.getEvent().getRequestInfo(); + if (request == null || request.getUserAgent() == null) { + return; + } + String userAgent = request.getUserAgent(); + if (Wildcard.matchesAny(userAgent, context.getConfiguration().getUserAgentBotPatterns())) { + LOG.debug(() -> "Cancelling event from bot user agent " + userAgent); + context.cancel(); + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/LambdaEventPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/LambdaEventPlugin.java new file mode 100644 index 0000000..da3428e --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/LambdaEventPlugin.java @@ -0,0 +1,23 @@ +package com.exceptionless.plugins; + +import java.util.function.Consumer; + +/** A plugin created by {@link EventPlugin#of}. */ +record LambdaEventPlugin(String name, int priority, Consumer action) + implements EventPlugin { + + @Override + public String getName() { + return name; + } + + @Override + public int getPriority() { + return priority; + } + + @Override + public void run(EventPluginContext context) { + action.accept(context); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/ReferenceIdPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/ReferenceIdPlugin.java new file mode 100644 index 0000000..452408f --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/ReferenceIdPlugin.java @@ -0,0 +1,28 @@ +package com.exceptionless.plugins; + +import com.exceptionless.internal.Ids; +import com.exceptionless.internal.Strings; +import com.exceptionless.models.Event; + +/** + * Gives error events a random reference id, so users can quote it to support and you can attach + * user descriptions with {@code updateUserEmailAndDescription}. + */ +public final class ReferenceIdPlugin implements EventPlugin { + public static final int PRIORITY = 30; + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public void run(EventPluginContext context) { + Event event = context.getEvent(); + if (context.getConfiguration().isReferenceIdsEnabled() + && event.isError() + && Strings.isBlank(event.getReferenceId())) { + event.setReferenceId(Ids.newReferenceId()); + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/RequestInfoPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/RequestInfoPlugin.java new file mode 100644 index 0000000..87fe2ce --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/RequestInfoPlugin.java @@ -0,0 +1,134 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.internal.Strings; +import com.exceptionless.models.Event; +import com.exceptionless.models.RequestInfo; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; +import org.jspecify.annotations.Nullable; + +/** + * Attaches the request from the context data and applies the privacy settings and data exclusions + * to it, whoever created it. Integrations only need to capture the raw request. + */ +public final class RequestInfoPlugin implements EventPlugin { + public static final int PRIORITY = 60; + + /** Headers that are never sent: credentials, and values already captured elsewhere. */ + private static final Set IGNORED_HEADERS = + Set.of( + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "x-auth-token", + "x-csrf-token", + "x-xsrf-token", + "host", + "referer", + "user-agent"); + + static final int MAX_VALUE_LENGTH = 1000; + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public void run(EventPluginContext context) { + Event event = context.getEvent(); + RequestInfo request = event.getRequestInfo(); + if (request == null) { + request = context.getContextData().getRequestInfo(); + if (request == null) { + return; + } + event.setRequestInfo(request); + } + sanitize(request, context.getConfiguration()); + } + + /** Applies the privacy settings and data exclusions to {@code request} in place. */ + private static void sanitize(RequestInfo request, ExceptionlessConfiguration config) { + Predicate excluded = config::isDataExcluded; + + if (!config.isIncludeIpAddress()) { + request.setClientIpAddress(null); + } + if (!config.isIncludeCookies()) { + request.setCookies(null); + } else { + request.setCookies(filter(request.getCookies(), excluded)); + } + if (!config.isIncludeQueryString()) { + request.setQueryString(null); + } else { + request.setQueryString(filter(request.getQueryString(), excluded)); + } + if (!config.isIncludePostData()) { + request.setPostData(null); + } else if (request.getPostData() instanceof Map form) { + Map filtered = new LinkedHashMap<>(); + for (Map.Entry entry : form.entrySet()) { + String key = String.valueOf(entry.getKey()); + if (!excluded.test(key) && entry.getValue() != null) { + filtered.put(key, truncateFormValue(entry.getValue())); + } + } + request.setPostData(filtered.isEmpty() ? null : filtered); + } + Map> headers = request.getHeaders(); + if (!config.isIncludeHeaders()) { + request.setHeaders(null); + } else if (headers != null) { + Map> filtered = new LinkedHashMap<>(); + for (Map.Entry> entry : headers.entrySet()) { + String name = entry.getKey(); + if (name == null + || IGNORED_HEADERS.contains(name.toLowerCase(Locale.ROOT)) + || excluded.test(name) + || entry.getValue() == null) { + continue; + } + List values = + entry.getValue().stream() + .filter(Objects::nonNull) + .map(RequestInfoPlugin::truncate) + .toList(); + filtered.put(name, values); + } + request.setHeaders(filtered.isEmpty() ? null : filtered); + } + } + + private static @Nullable Map filter( + @Nullable Map values, Predicate excluded) { + if (values == null || values.isEmpty()) { + return null; + } + Map filtered = new LinkedHashMap<>(); + for (Map.Entry entry : values.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null && !excluded.test(entry.getKey())) { + filtered.put(entry.getKey(), truncate(entry.getValue())); + } + } + return filtered.isEmpty() ? null : filtered; + } + + private static String truncate(String value) { + return Strings.truncate(value, MAX_VALUE_LENGTH); + } + + /** Form values are usually strings, but integrations may pass other simple values as-is. */ + private static Object truncateFormValue(Object value) { + return value instanceof String text ? truncate(text) : value; + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/SessionIdManagementPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/SessionIdManagementPlugin.java new file mode 100644 index 0000000..606c56d --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/SessionIdManagementPlugin.java @@ -0,0 +1,40 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.internal.Ids; +import com.exceptionless.models.Event; + +/** + * Generates session ids on the client, for apps that track sessions without a signed in user. + * Session start events get a new id, and every other event references the current session. + */ +public final class SessionIdManagementPlugin implements EventPlugin { + public static final int PRIORITY = 25; + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public void run(EventPluginContext context) { + ExceptionlessConfiguration config = context.getConfiguration(); + Event event = context.getEvent(); + if (event.isSessionStart()) { + String id = Ids.newGuid(); + config.currentSessionIdentifier(id); + event.setReferenceId(id); + return; + } + String current = config.getCurrentSessionIdentifier(); + if (current == null) { + return; + } + if (event.isSessionEnd()) { + event.setReferenceId(current); + config.currentSessionIdentifier(null); + } else if (event.getSessionId() == null) { + event.setEventReference("session", current); + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/SubmissionMethodPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/SubmissionMethodPlugin.java new file mode 100644 index 0000000..7ee482b --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/SubmissionMethodPlugin.java @@ -0,0 +1,19 @@ +package com.exceptionless.plugins; + +/** Records how the event was captured ({@code @submission_method}). */ +public final class SubmissionMethodPlugin implements EventPlugin { + public static final int PRIORITY = 90; + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public void run(EventPluginContext context) { + String method = context.getContextData().getSubmissionMethod(); + if (method != null && context.getEvent().getSubmissionMethod() == null) { + context.getEvent().setSubmissionMethod(method); + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/VersionPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/VersionPlugin.java new file mode 100644 index 0000000..d35f9fe --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/VersionPlugin.java @@ -0,0 +1,49 @@ +package com.exceptionless.plugins; + +import com.exceptionless.internal.ModuleCollector; +import com.exceptionless.models.Event; +import com.exceptionless.models.ModuleInfo; +import org.jspecify.annotations.Nullable; + +/** + * Sets the application version ({@code @version}) from the configuration, falling back to the + * {@code Implementation-Version} of the application's jar. + */ +public final class VersionPlugin implements EventPlugin { + public static final int PRIORITY = 80; + + /** Lazily initialized holder: the version is looked up once, the first time it is needed. */ + private static final class Detected { + static final @Nullable String VERSION = detect(); + } + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public void run(EventPluginContext context) { + Event event = context.getEvent(); + if (event.getVersion() != null) { + return; + } + String version = context.getConfiguration().getVersion(); + if (version == null) { + version = Detected.VERSION; + } + if (version != null) { + event.setVersion(version); + } + } + + /** The version of the module containing the application's entry point, if it has one. */ + private static @Nullable String detect() { + for (ModuleInfo module : ModuleCollector.getModules()) { + if (module.entry() && module.version() != null) { + return module.version(); + } + } + return null; + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/package-info.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/package-info.java new file mode 100644 index 0000000..36cfb9c --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/package-info.java @@ -0,0 +1,8 @@ +/** + * The event plugin pipeline. Every event runs through the plugins in ascending priority order + * before it is queued; a plugin can enrich the event or cancel it. + */ +@NullMarked +package com.exceptionless.plugins; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java b/exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java new file mode 100644 index 0000000..02184d0 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java @@ -0,0 +1,286 @@ +package com.exceptionless.queue; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.internal.Log; +import com.exceptionless.json.JsonWriter; +import com.exceptionless.models.Event; +import com.exceptionless.storage.QueueEntry; +import com.exceptionless.storage.QueueStorage; +import com.exceptionless.submission.SubmissionClient; +import com.exceptionless.submission.SubmissionResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.time.InstantSource; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; + +/** + * The default queue. Events are serialized when queued, so a bad event can never block the queue, + * and sent in batches by the client's background thread. + * + *

Server responses are handled like the other Exceptionless clients: + * + *

    + *
  • 429 (rate limited) and 503: keep the events and pause until the server should accept them + * again + *
  • 402 (plan limit reached): drop queued events and new events for an hour + *
  • 401/403 (bad API key): drop the batch and pause for 15 minutes + *
  • 400/404: drop the batch and pause for 4 hours + *
  • 413 (too large): retry with smaller batches; drop single events that are still too large + *
  • anything else (network errors, 5xx): keep the events, retry with backoff, and drop an event + * after {@link ExceptionlessConfiguration#getQueueMaxAttempts() max attempts} + *
+ */ +public final class DefaultEventQueue implements EventQueue { + /** + * Uncompressed size limit for one batch. The server rejects request bodies over 200 KB + * (compressed) with a 413; JSON usually compresses 5-10x, and batches that still don't fit are + * split on the 413. + */ + static final int MAX_BATCH_BYTES = 1_000_000; + + private static final int MAX_BATCHES_PER_RUN = 100; + private static final Log LOG = Log.get(DefaultEventQueue.class); + + private final ExceptionlessConfiguration config; + private final SubmissionClient submissionClient; + private final QueueStorage storage; + private final InstantSource clock; + private final ReentrantLock processLock = new ReentrantLock(); + private final List listeners = new CopyOnWriteArrayList<>(); + + private volatile Instant suspendedUntil = Instant.MIN; + private volatile Instant discardUntil = Instant.MIN; + // Only accessed while holding processLock. + private int currentBatchSize; + private int consecutiveFailures; + + public DefaultEventQueue( + ExceptionlessConfiguration config, SubmissionClient submissionClient, QueueStorage storage) { + this(config, submissionClient, storage, InstantSource.system()); + } + + DefaultEventQueue( + ExceptionlessConfiguration config, + SubmissionClient submissionClient, + QueueStorage storage, + InstantSource clock) { + this.config = config; + this.submissionClient = submissionClient; + this.storage = storage; + this.clock = clock; + this.currentBatchSize = config.getSubmissionBatchSize(); + } + + @Override + public void enqueue(Event event) { + if (clock.instant().isBefore(discardUntil)) { + LOG.debug("Discarding event: the plan limit was reached"); + return; + } + String json; + try { + json = JsonWriter.toJson(event); + } catch (RuntimeException | StackOverflowError e) { + LOG.error("Discarding event that could not be serialized: " + event, e); + return; + } + storage.enqueue(json); + } + + @Override + public void process() { + if (!processLock.tryLock()) { + return; // already processing + } + try { + for (int run = 0; run < MAX_BATCHES_PER_RUN; run++) { + if (isSuspended() || !config.isEnabled()) { + return; + } + if (run == 0) { + // A smaller batch size after a 413 lasts for the rest of the run, like the .NET client. + currentBatchSize = config.getSubmissionBatchSize(); + } + List batch = nextBatch(); + if (batch.isEmpty()) { + return; + } + if (!send(batch)) { + return; + } + } + } catch (RuntimeException e) { + LOG.error("Error processing the event queue", e); + suspendProcessing(Duration.ofMinutes(1)); + } finally { + processLock.unlock(); + } + } + + private List nextBatch() { + int limit = Math.max(1, Math.min(currentBatchSize, config.getSubmissionBatchSize())); + while (true) { + List entries = storage.peek(limit); + List expired = new ArrayList<>(); + List batch = new ArrayList<>(); + Instant oldest = clock.instant().minus(config.getQueueMaxAge()); + int bytes = 2; + for (QueueEntry entry : entries) { + if (entry.enqueuedAt().isBefore(oldest) + || entry.attempts() >= config.getQueueMaxAttempts()) { + expired.add(entry); + continue; + } + int size = entry.json().getBytes(StandardCharsets.UTF_8).length + 1; + if (!batch.isEmpty() && bytes + size > MAX_BATCH_BYTES) { + break; + } + batch.add(entry); + bytes += size; + } + if (!expired.isEmpty()) { + LOG.info( + "Discarding " + + expired.size() + + " queued events that are too old or failed too many times"); + storage.remove(expired); + } + if (!batch.isEmpty() || expired.isEmpty()) { + return batch; + } + } + } + + /** Sends one batch. Returns true to keep processing. */ + private boolean send(List batch) { + String json = batch.stream().map(QueueEntry::json).collect(Collectors.joining(",", "[", "]")); + + SubmissionResponse response; + try { + response = submissionClient.postEvents(json); + } catch (RuntimeException e) { + response = SubmissionResponse.failed(e); + } + boolean keepGoing = handleResponse(batch, response); + for (EventsPostedListener listener : listeners) { + try { + listener.eventsPosted(batch.size(), response); + } catch (RuntimeException e) { + LOG.warn("EventsPosted listener failed", e); + } + } + return keepGoing; + } + + private boolean handleResponse(List batch, SubmissionResponse response) { + if (response.isSuccess()) { + LOG.debug(() -> "Sent " + batch.size() + " events"); + storage.remove(batch); + consecutiveFailures = 0; + return true; + } + int status = response.statusCode(); + switch (status) { + case 429 -> { + // The server throttles per organization in 15 minute windows and sends no Retry-After. + Instant now = clock.instant(); + Instant window = now.truncatedTo(ChronoUnit.HOURS); + while (!window.isAfter(now)) { + window = window.plus(15, ChronoUnit.MINUTES); + } + LOG.warn("Rate limit reached; pausing event submission until " + window); + suspendProcessing(Duration.between(now, window)); + return false; + } + case 503 -> { + LOG.warn("Server unavailable (503); pausing event submission for 5 minutes"); + suspendProcessing(Duration.ofMinutes(5)); + return false; + } + case 402 -> { + LOG.warn("Event limit reached for this billing period; discarding events for an hour"); + suspendProcessing(Duration.ofHours(1), true, true); + return false; + } + case 401, 403 -> { + LOG.error( + "Unable to send events: the API key is invalid or not authorized (" + status + ")"); + storage.remove(batch); + suspendProcessing(Duration.ofMinutes(15)); + return false; + } + case 400, 404 -> { + LOG.error("Unable to send events (HTTP " + status + "): " + response.message()); + storage.remove(batch); + suspendProcessing(Duration.ofHours(4)); + return false; + } + case 413 -> { + if (batch.size() > 1) { + // Shrink from what was actually sent, which may be fewer events than the batch size + // when the byte limit applied. + currentBatchSize = Math.max(1, batch.size() / 2); + LOG.info("Batch too large; retrying with " + currentBatchSize + " events per batch"); + return true; + } + LOG.error("Discarding an event that is too large to send"); + storage.remove(batch); + return true; + } + default -> { + // Network errors, timeouts and 5xx: retry with backoff. + storage.incrementAttempts(batch); + consecutiveFailures++; + long seconds = Math.min(300, 15L << Math.min(consecutiveFailures - 1, 5)); + LOG.warn( + "Unable to send events (" + + (status < 0 ? response.message() : "HTTP " + status) + + "); retrying in " + + seconds + + " seconds"); + suspendProcessing(Duration.ofSeconds(seconds)); + return false; + } + } + } + + @Override + public void suspendProcessing( + Duration duration, boolean discardFutureQueuedItems, boolean clearQueue) { + Instant until = clock.instant().plus(duration); + suspendedUntil = until; + if (discardFutureQueuedItems) { + discardUntil = until; + } + if (clearQueue) { + storage.clear(); + } + } + + @Override + public void resume() { + suspendedUntil = Instant.MIN; + discardUntil = Instant.MIN; + } + + @Override + public boolean isSuspended() { + return clock.instant().isBefore(suspendedUntil); + } + + @Override + public int size() { + return storage.size(); + } + + @Override + public void onEventsPosted(EventsPostedListener listener) { + listeners.add(listener); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/queue/EventQueue.java b/exceptionless-core/src/main/java/com/exceptionless/queue/EventQueue.java new file mode 100644 index 0000000..b0b0756 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/queue/EventQueue.java @@ -0,0 +1,41 @@ +package com.exceptionless.queue; + +import com.exceptionless.models.Event; +import java.time.Duration; + +/** Queues events and sends them to the server in batches. */ +public interface EventQueue { + /** Serializes and queues an event. Never blocks on the network. */ + void enqueue(Event event); + + /** Sends queued events now. Blocks the calling thread until done or suspended. */ + void process(); + + /** Stops sending for {@code duration}, keeping queued and new events. */ + default void suspendProcessing(Duration duration) { + suspendProcessing(duration, false, false); + } + + /** + * Stops sending for {@code duration}. + * + * @param discardFutureQueuedItems drop events enqueued while suspended + * @param clearQueue drop the events already queued + */ + void suspendProcessing(Duration duration, boolean discardFutureQueuedItems, boolean clearQueue); + + /** Resumes sending immediately after a suspension. */ + void resume(); + + /** + * {@return whether sending is paused} The queue pauses after errors and rate limiting, or when + * {@link #suspendProcessing} is called; events are still queued while it is paused. + */ + boolean isSuspended(); + + /** {@return the number of queued events} */ + int size(); + + /** Adds a listener called after each batch is posted. */ + void onEventsPosted(EventsPostedListener listener); +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/queue/EventsPostedListener.java b/exceptionless-core/src/main/java/com/exceptionless/queue/EventsPostedListener.java new file mode 100644 index 0000000..ef9886b --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/queue/EventsPostedListener.java @@ -0,0 +1,15 @@ +package com.exceptionless.queue; + +import com.exceptionless.submission.SubmissionResponse; + +/** Notified after the queue posts a batch of events. */ +@FunctionalInterface +public interface EventsPostedListener { + /** + * Called after a batch is posted. + * + * @param count the number of events in the batch + * @param response the server's response + */ + void eventsPosted(int count, SubmissionResponse response); +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/queue/package-info.java b/exceptionless-core/src/main/java/com/exceptionless/queue/package-info.java new file mode 100644 index 0000000..77d559e --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/queue/package-info.java @@ -0,0 +1,5 @@ +/** Queueing and sending events in batches. */ +@NullMarked +package com.exceptionless.queue; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-core/src/main/java/com/exceptionless/settings/ClientSettings.java b/exceptionless-core/src/main/java/com/exceptionless/settings/ClientSettings.java new file mode 100644 index 0000000..1e623c4 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/settings/ClientSettings.java @@ -0,0 +1,164 @@ +package com.exceptionless.settings; + +import com.exceptionless.LogLevel; +import com.exceptionless.internal.Strings; +import com.exceptionless.internal.Wildcard; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import org.jspecify.annotations.Nullable; + +/** + * An immutable, case-insensitive view of the effective client configuration values: local settings + * overlaid with the project's server settings. + * + *

Implements the {@code @@log:}, {@code @@:} and {@code @@error:} + * conventions shared by all Exceptionless clients. + */ +public final class ClientSettings { + public static final String DATA_EXCLUSIONS = "@@DataExclusions"; + public static final String INCLUDE_PRIVATE_INFORMATION = "@@IncludePrivateInformation"; + public static final String USER_AGENT_BOT_PATTERNS = "@@UserAgentBotPatterns"; + public static final String LOG_PREFIX = "@@log:"; + + private static final ClientSettings EMPTY = new ClientSettings(Map.of()); + + private final Map values; + // Wildcard-keyed @@type:source settings ordered longest first, so the most specific wins. + private final List> wildcardEntries; + private final Map> minLogLevelCache = new ConcurrentHashMap<>(); + + private ClientSettings(Map values) { + TreeMap map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + map.putAll(values); + this.values = Collections.unmodifiableMap(map); + List> wildcards = new ArrayList<>(); + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey().startsWith("@@") && entry.getKey().contains("*")) { + wildcards.add(Map.entry(entry.getKey(), entry.getValue())); + } + } + wildcards.sort( + Comparator.comparingInt((Map.Entry e) -> e.getKey().length()).reversed()); + this.wildcardEntries = List.copyOf(wildcards); + } + + public static ClientSettings empty() { + return EMPTY; + } + + /** Creates settings from {@code base} overlaid with {@code overrides}. */ + public static ClientSettings of(Map base, Map overrides) { + if (base.isEmpty() && overrides.isEmpty()) { + return EMPTY; + } + TreeMap merged = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + merged.putAll(base); + merged.putAll(overrides); + return new ClientSettings(merged); + } + + public static ClientSettings of(Map values) { + return values.isEmpty() ? EMPTY : new ClientSettings(values); + } + + public Map asMap() { + return values; + } + + public @Nullable String getString(String key) { + return values.get(key); + } + + public boolean getBoolean(String key, boolean defaultValue) { + Boolean value = parseBoolean(values.get(key)); + return value != null ? value : defaultValue; + } + + /** A comma, semicolon or newline separated list setting. */ + public List getStringList(String key) { + return Strings.splitList(values.get(key)); + } + + /** + * The minimum log level for {@code source}: the {@code @@log:} setting, otherwise the + * most specific matching wildcard ({@code @@log:com.example.*}, {@code @@log:*}). + * + * @return the configured level or {@code null} when nothing matches + */ + public @Nullable LogLevel getMinLogLevel(@Nullable String source) { + String key = source == null || source.isBlank() ? "*" : source; + if (minLogLevelCache.size() > 10_000) { + minLogLevelCache.clear(); + } + return minLogLevelCache + .computeIfAbsent( + key, + k -> { + String setting = getTypeAndSourceSettingValue("log", k); + return Optional.ofNullable(setting != null ? LogLevel.parse(setting) : null); + }) + .orElse(null); + } + + /** + * The boolean {@code @@:} setting, used to turn event types and sources on and off + * (for example {@code @@error:java.io.IOException=false} or {@code @@usage:Export=false}). + */ + public boolean getTypeAndSourceSetting( + String type, @Nullable String source, boolean defaultValue) { + if (source == null) { + return defaultValue; + } + String value = getTypeAndSourceSettingValue(type, source); + Boolean parsed = parseBoolean(value); + if (parsed != null) { + return parsed; + } + // Log level values ("off", "trace"...) are also accepted for non-log types. + LogLevel level = LogLevel.parse(value); + if (level != null) { + return level != LogLevel.OFF; + } + return defaultValue; + } + + private @Nullable String getTypeAndSourceSettingValue(String type, String source) { + String prefix = "@@" + type + ":"; + String exact = values.get(prefix + source); + if (exact != null) { + return exact; + } + String prefixLower = prefix.toLowerCase(Locale.ROOT); + for (Map.Entry entry : wildcardEntries) { + String key = entry.getKey(); + if (key.toLowerCase(Locale.ROOT).startsWith(prefixLower) + && Wildcard.matches(source, key.substring(prefix.length()))) { + return entry.getValue(); + } + } + return null; + } + + static @Nullable Boolean parseBoolean(@Nullable String value) { + if (value == null) { + return null; + } + return switch (value.strip().toLowerCase(Locale.ROOT)) { + case "true", "yes", "1", "on" -> Boolean.TRUE; + case "false", "no", "0", "off" -> Boolean.FALSE; + default -> null; + }; + } + + @Override + public String toString() { + return values.toString(); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/settings/package-info.java b/exceptionless-core/src/main/java/com/exceptionless/settings/package-info.java new file mode 100644 index 0000000..8ae5cd0 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/settings/package-info.java @@ -0,0 +1,5 @@ +/** Client configuration values and project settings sent by the server. */ +@NullMarked +package com.exceptionless.settings; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-core/src/main/java/com/exceptionless/storage/FileKeyValueStorage.java b/exceptionless-core/src/main/java/com/exceptionless/storage/FileKeyValueStorage.java new file mode 100644 index 0000000..3e13217 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/FileKeyValueStorage.java @@ -0,0 +1,60 @@ +package com.exceptionless.storage; + +import com.exceptionless.internal.Log; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** Persists client state as small files in a directory. */ +public final class FileKeyValueStorage implements KeyValueStorage { + private static final Log LOG = Log.get(FileKeyValueStorage.class); + private static final Pattern SAFE_KEY = Pattern.compile("[A-Za-z0-9._-]+"); + + private final Path directory; + + public FileKeyValueStorage(Path directory) { + this.directory = directory; + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new UncheckedIOException("Unable to create storage directory " + directory, e); + } + } + + @Override + public @Nullable String read(String key) { + try { + return Files.readString(file(key), StandardCharsets.UTF_8); + } catch (NoSuchFileException e) { + return null; + } catch (IOException e) { + LOG.warn("Unable to read " + key, e); + return null; + } + } + + @Override + public void write(String key, String value) { + Path target = file(key); + Path temp = directory.resolve(key + ".tmp"); + try { + Files.writeString(temp, value, StandardCharsets.UTF_8); + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + LOG.warn("Unable to write " + key, e); + } + } + + private Path file(String key) { + if (!SAFE_KEY.matcher(key).matches()) { + throw new IllegalArgumentException("Invalid storage key: " + key); + } + return directory.resolve(key); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/storage/FileQueueStorage.java b/exceptionless-core/src/main/java/com/exceptionless/storage/FileQueueStorage.java new file mode 100644 index 0000000..ae421d9 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/FileQueueStorage.java @@ -0,0 +1,267 @@ +package com.exceptionless.storage; + +import com.exceptionless.internal.Log; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.time.InstantSource; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.NavigableMap; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** + * Persists queued events as files so they survive restarts. Each event is one file named {@code + * -..json}. A directory should only be used by one process at + * a time. + * + *

The directory is read once, when the storage is created; after that an in-memory index of the + * queued files is kept, so queueing an event writes one file without listing the directory. + */ +public final class FileQueueStorage implements QueueStorage { + public static final int DEFAULT_MAX_ITEMS = 10_000; + private static final Log LOG = Log.get(FileQueueStorage.class); + private static final Pattern FILE_NAME = Pattern.compile("^(\\d+)-(\\d+)\\.(\\d+)\\.json$"); + private static final int SEQUENCE_LIMIT = 1_000_000; + + private final Path directory; + private final int maxItems; + private final InstantSource clock; + // Guarded by this: the queued files, oldest first. + private final NavigableMap index = new TreeMap<>(); + private long sequence; + + /** Orders files by when they were queued, then by their sequence number. */ + private record Key(long millis, long sequence) implements Comparable { + private static final Comparator ORDER = + Comparator.comparingLong(Key::millis).thenComparingLong(Key::sequence); + + @Override + public int compareTo(Key other) { + return ORDER.compare(this, other); + } + } + + /** + * A queued file. + * + * @param baseName the file name without the attempts and extension ({@code -}) + */ + private record Stored(String baseName, int attempts) { + String fileName() { + return baseName + "." + attempts + ".json"; + } + } + + /** A parsed file name. */ + private record Parsed(Key key, Stored stored) { + static @Nullable Parsed of(String fileName) { + Matcher matcher = FILE_NAME.matcher(fileName); + if (!matcher.matches()) { + return null; + } + try { + Key key = new Key(Long.parseLong(matcher.group(1)), Long.parseLong(matcher.group(2))); + String baseName = matcher.group(1) + "-" + matcher.group(2); + return new Parsed(key, new Stored(baseName, Integer.parseInt(matcher.group(3)))); + } catch (NumberFormatException e) { + return null; // digits out of range + } + } + } + + public FileQueueStorage(Path directory) { + this(directory, DEFAULT_MAX_ITEMS); + } + + public FileQueueStorage(Path directory, int maxItems) { + this(directory, maxItems, InstantSource.system()); + } + + /** + * Creates a storage in {@code directory} that drops the oldest events beyond {@code maxItems}. + * + * @param clock records when each event was queued + * @throws java.io.UncheckedIOException when the directory can't be created + */ + public FileQueueStorage(Path directory, int maxItems, InstantSource clock) { + if (maxItems < 1) { + throw new IllegalArgumentException("maxItems must be at least 1"); + } + this.directory = directory; + this.maxItems = maxItems; + this.clock = clock; + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new UncheckedIOException("Unable to create queue directory " + directory, e); + } + loadIndex(); + } + + public Path getDirectory() { + return directory; + } + + private void loadIndex() { + try (DirectoryStream stream = Files.newDirectoryStream(directory, "*.json")) { + for (Path file : stream) { + Parsed parsed = Parsed.of(file.getFileName().toString()); + if (parsed == null) { + continue; // not a queued event + } + // Continue the sequence, so events queued in the same millisecond keep their order. + sequence = Math.max(sequence, parsed.key().sequence() % SEQUENCE_LIMIT); + Stored existing = index.get(parsed.key()); + if (existing == null) { + index.put(parsed.key(), parsed.stored()); + } else if (existing.attempts() < parsed.stored().attempts()) { + // The same event with two attempt counts: keep the higher count. + deleteQuietly(directory.resolve(existing.fileName())); + index.put(parsed.key(), parsed.stored()); + } else { + deleteQuietly(file); + } + } + } catch (IOException e) { + LOG.warn("Unable to list queued events in " + directory, e); + } + } + + @Override + public synchronized void enqueue(String json) { + int excess = index.size() - maxItems + 1; + for (int i = 0; i < excess; i++) { + Map.Entry oldest = index.pollFirstEntry(); + deleteQuietly(directory.resolve(oldest.getValue().fileName())); + } + if (excess > 0) { + LOG.warn("Persisted event queue is full; dropped " + excess + " of the oldest events"); + } + Key key = nextKey(clock.millis()); + Stored stored = + new Stored(String.format(Locale.ROOT, "%013d-%06d", key.millis(), key.sequence()), 0); + Path target = directory.resolve(stored.fileName()); + Path temp = directory.resolve(stored.fileName() + ".tmp"); + try { + Files.writeString(temp, json, StandardCharsets.UTF_8); + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + index.put(key, stored); + } catch (IOException e) { + deleteQuietly(temp); + LOG.error("Unable to persist event to " + target, e); + } + } + + /** A key that isn't in use, so a new file never replaces a queued one. */ + private Key nextKey(long millis) { + Key key; + do { + sequence = (sequence + 1) % SEQUENCE_LIMIT; + key = new Key(millis, sequence); + } while (index.containsKey(key)); + return key; + } + + @Override + public synchronized List peek(int max) { + List result = new ArrayList<>(Math.min(max, index.size())); + Iterator> it = index.entrySet().iterator(); + while (result.size() < max && it.hasNext()) { + Map.Entry item = it.next(); + Stored stored = item.getValue(); + Path file = directory.resolve(stored.fileName()); + try { + String json = Files.readString(file, StandardCharsets.UTF_8); + result.add( + new QueueEntry( + stored.fileName(), + json, + stored.attempts(), + Instant.ofEpochMilli(item.getKey().millis()))); + } catch (NoSuchFileException e) { + it.remove(); // deleted outside of this storage + } catch (IOException | RuntimeException e) { + LOG.warn("Discarding unreadable queued event " + file, e); + it.remove(); + deleteQuietly(file); + } + } + return result; + } + + @Override + public synchronized void remove(Collection entries) { + for (QueueEntry entry : entries) { + Parsed parsed = Parsed.of(entry.id()); + if (parsed == null) { + continue; + } + Stored stored = index.remove(parsed.key()); + if (stored != null) { + deleteQuietly(directory.resolve(stored.fileName())); + } + } + } + + @Override + public synchronized void incrementAttempts(Collection entries) { + for (QueueEntry entry : entries) { + Parsed parsed = Parsed.of(entry.id()); + if (parsed == null) { + continue; + } + Stored stored = index.get(parsed.key()); + if (stored == null) { + continue; + } + Stored updated = new Stored(stored.baseName(), stored.attempts() + 1); + try { + Files.move( + directory.resolve(stored.fileName()), + directory.resolve(updated.fileName()), + StandardCopyOption.ATOMIC_MOVE); + index.put(parsed.key(), updated); + } catch (NoSuchFileException e) { + index.remove(parsed.key()); + } catch (IOException e) { + LOG.debug(() -> "Unable to update attempts for " + entry.id() + ": " + e); + } + } + } + + @Override + public synchronized void clear() { + for (Stored stored : index.values()) { + deleteQuietly(directory.resolve(stored.fileName())); + } + index.clear(); + } + + @Override + public synchronized int size() { + return index.size(); + } + + private static void deleteQuietly(Path file) { + try { + Files.deleteIfExists(file); + } catch (IOException e) { + LOG.debug(() -> "Unable to delete " + file + ": " + e); + } + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/storage/InMemoryKeyValueStorage.java b/exceptionless-core/src/main/java/com/exceptionless/storage/InMemoryKeyValueStorage.java new file mode 100644 index 0000000..748a36f --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/InMemoryKeyValueStorage.java @@ -0,0 +1,20 @@ +package com.exceptionless.storage; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jspecify.annotations.Nullable; + +/** Keeps client state in memory for the lifetime of the process. */ +public final class InMemoryKeyValueStorage implements KeyValueStorage { + private final Map values = new ConcurrentHashMap<>(); + + @Override + public @Nullable String read(String key) { + return values.get(key); + } + + @Override + public void write(String key, String value) { + values.put(key, value); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/storage/InMemoryQueueStorage.java b/exceptionless-core/src/main/java/com/exceptionless/storage/InMemoryQueueStorage.java new file mode 100644 index 0000000..74491f6 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/InMemoryQueueStorage.java @@ -0,0 +1,95 @@ +package com.exceptionless.storage; + +import com.exceptionless.internal.Log; +import java.time.InstantSource; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Keeps queued events in memory. When full, the oldest events are dropped. */ +public final class InMemoryQueueStorage implements QueueStorage { + public static final int DEFAULT_MAX_ITEMS = 1000; + private static final Log LOG = Log.get(InMemoryQueueStorage.class); + + private final int maxItems; + private final InstantSource clock; + // Guarded by this. Insertion order is queue order. + private final Map entries = new LinkedHashMap<>(); + private long sequence; + private long dropped; + + public InMemoryQueueStorage() { + this(DEFAULT_MAX_ITEMS); + } + + public InMemoryQueueStorage(int maxItems) { + this(maxItems, InstantSource.system()); + } + + /** + * Creates a storage that drops the oldest events beyond {@code maxItems}. + * + * @param clock records when each event was queued + */ + public InMemoryQueueStorage(int maxItems, InstantSource clock) { + if (maxItems < 1) { + throw new IllegalArgumentException("maxItems must be at least 1"); + } + this.maxItems = maxItems; + this.clock = clock; + } + + @Override + public synchronized void enqueue(String json) { + while (entries.size() >= maxItems) { + Iterator oldest = entries.keySet().iterator(); + oldest.next(); + oldest.remove(); + if (dropped++ % 100 == 0) { + LOG.warn("Event queue is full (" + maxItems + " items); dropping the oldest events"); + } + } + String id = Long.toString(++sequence); + entries.put(id, new QueueEntry(id, json, 0, clock.instant())); + } + + @Override + public synchronized List peek(int maxItems) { + List result = new ArrayList<>(Math.min(maxItems, entries.size())); + for (QueueEntry entry : entries.values()) { + if (result.size() >= maxItems) { + break; + } + result.add(entry); + } + return result; + } + + @Override + public synchronized void remove(Collection toRemove) { + for (QueueEntry entry : toRemove) { + entries.remove(entry.id()); + } + } + + @Override + public synchronized void incrementAttempts(Collection toUpdate) { + for (QueueEntry entry : toUpdate) { + entries.computeIfPresent( + entry.id(), (id, current) -> current.withAttempts(current.attempts() + 1)); + } + } + + @Override + public synchronized void clear() { + entries.clear(); + } + + @Override + public synchronized int size() { + return entries.size(); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/storage/KeyValueStorage.java b/exceptionless-core/src/main/java/com/exceptionless/storage/KeyValueStorage.java new file mode 100644 index 0000000..8d722c3 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/KeyValueStorage.java @@ -0,0 +1,24 @@ +package com.exceptionless.storage; + +import org.jspecify.annotations.Nullable; + +/** Persists small pieces of client state, such as the server settings and the install id. */ +public interface KeyValueStorage { + /** + * Reads a value. + * + * @param key the value's name; implementations may restrict which characters are allowed + * @return the stored value, or {@code null} when nothing is stored under {@code key} or it can't + * be read + */ + @Nullable String read(String key); + + /** + * Stores a value, replacing any existing value for {@code key}. Failures are logged rather than + * thrown: losing client state must never break the application. + * + * @param key the value's name; implementations may restrict which characters are allowed + * @param value the value to store + */ + void write(String key, String value); +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/storage/QueueEntry.java b/exceptionless-core/src/main/java/com/exceptionless/storage/QueueEntry.java new file mode 100644 index 0000000..55241af --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/QueueEntry.java @@ -0,0 +1,18 @@ +package com.exceptionless.storage; + +import java.time.Instant; + +/** + * A serialized event waiting to be sent. + * + * @param id storage specific identifier + * @param json the serialized event + * @param attempts how many times sending it has failed + * @param enqueuedAt when it was queued + */ +public record QueueEntry(String id, String json, int attempts, Instant enqueuedAt) { + /** {@return a copy of this entry with {@code attempts} replaced} */ + public QueueEntry withAttempts(int attempts) { + return new QueueEntry(id, json, attempts, enqueuedAt); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/storage/QueueStorage.java b/exceptionless-core/src/main/java/com/exceptionless/storage/QueueStorage.java new file mode 100644 index 0000000..d363935 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/QueueStorage.java @@ -0,0 +1,25 @@ +package com.exceptionless.storage; + +import java.util.Collection; +import java.util.List; + +/** Stores serialized events until they are sent. Implementations must be thread-safe. */ +public interface QueueStorage { + /** Adds an event. Implementations may drop the oldest entries to stay within a size limit. */ + void enqueue(String json); + + /** Returns up to {@code maxItems} of the oldest entries without removing them. */ + List peek(int maxItems); + + /** Removes entries (after they were sent or discarded). */ + void remove(Collection entries); + + /** Records a failed attempt for each entry. */ + void incrementAttempts(Collection entries); + + /** Removes all entries. */ + void clear(); + + /** {@return the number of stored entries} */ + int size(); +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/storage/package-info.java b/exceptionless-core/src/main/java/com/exceptionless/storage/package-info.java new file mode 100644 index 0000000..a79c186 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/package-info.java @@ -0,0 +1,5 @@ +/** Storage for queued events and persisted client state. */ +@NullMarked +package com.exceptionless.storage; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-core/src/main/java/com/exceptionless/submission/DefaultSubmissionClient.java b/exceptionless-core/src/main/java/com/exceptionless/submission/DefaultSubmissionClient.java new file mode 100644 index 0000000..578610d --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/DefaultSubmissionClient.java @@ -0,0 +1,190 @@ +package com.exceptionless.submission; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.internal.ClientInfo; +import com.exceptionless.internal.Log; +import com.exceptionless.internal.SettingsJson; +import com.exceptionless.json.JsonWriter; +import com.exceptionless.models.UserDescription; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.ProxySelector; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.zip.GZIPOutputStream; +import javax.net.ssl.SSLContext; +import org.jspecify.annotations.Nullable; + +/** Sends requests with the JDK {@link HttpClient}. Bodies over 1 KB are gzip compressed. */ +public final class DefaultSubmissionClient implements SubmissionClient { + static final int COMPRESSION_THRESHOLD = 1024; + private static final Log LOG = Log.get(DefaultSubmissionClient.class); + private static final String CONFIG_VERSION_HEADER = "X-Exceptionless-ConfigVersion"; + + private final ExceptionlessConfiguration config; + private final HttpClient httpClient; + + public DefaultSubmissionClient(ExceptionlessConfiguration config) { + this.config = config; + HttpClient custom = config.getHttpClient(); + this.httpClient = custom != null ? custom : createHttpClient(config); + } + + private static HttpClient createHttpClient(ExceptionlessConfiguration config) { + HttpClient.Builder builder = + HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(config.getConnectTimeout()); + ProxySelector proxy = config.getProxy(); + if (proxy != null) { + builder.proxy(proxy); + } + SSLContext sslContext = config.getSslContext(); + if (sslContext != null) { + builder.sslContext(sslContext); + } + return builder.build(); + } + + @Override + public SubmissionResponse postEvents(String eventsJson) { + return post(config.getServerUrl() + "/api/v2/events", eventsJson); + } + + @Override + public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { + String url = + config.getServerUrl() + + "/api/v2/events/by-ref/" + + URLEncoder.encode(referenceId, StandardCharsets.UTF_8) + + "/user-description"; + return post(url, JsonWriter.toJson(description)); + } + + @Override + public SettingsResponse getSettings(int version) { + String url = config.getConfigServerUrl() + "/api/v2/projects/config?v=" + version; + try { + HttpResponse response = + httpClient.send(request(url).GET().build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 304) { + return new SettingsResponse.NotModified(); + } + if (response.statusCode() != 200) { + return new SettingsResponse.Failed("Unable to get settings: HTTP " + response.statusCode()); + } + return parseSettings(response.body()); + } catch (IOException e) { + return new SettingsResponse.Failed("Unable to get settings: " + e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new SettingsResponse.Failed("Interrupted while getting settings"); + } catch (RuntimeException e) { + return new SettingsResponse.Failed("Unable to get settings: " + e); + } + } + + static SettingsResponse parseSettings(String body) { + SettingsJson.Parsed parsed; + try { + parsed = SettingsJson.parse(body); + } catch (IllegalArgumentException e) { + return new SettingsResponse.Failed("Invalid settings response: " + e.getMessage()); + } + Integer version = parsed.version(); + if (version == null) { + return new SettingsResponse.Failed("Invalid settings response: missing version"); + } + return new SettingsResponse.Updated(parsed.settings(), version); + } + + @Override + public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { + String url = + config.getHeartbeatServerUrl() + + "/api/v2/events/session/heartbeat?id=" + + URLEncoder.encode(sessionIdOrUserId, StandardCharsets.UTF_8) + + "&close=" + + closeSession; + return send(request(url).GET().build()); + } + + private SubmissionResponse post(String url, String json) { + byte[] body = json.getBytes(StandardCharsets.UTF_8); + HttpRequest.Builder builder = + request(url).header("Content-Type", "application/json; charset=utf-8"); + if (body.length > COMPRESSION_THRESHOLD) { + body = gzip(body); + builder.header("Content-Encoding", "gzip"); + } + return send(builder.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build()); + } + + private SubmissionResponse send(HttpRequest request) { + try { + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + String body = response.body(); + int status = response.statusCode(); + if (status >= 300 && status < 400) { + // Redirects would turn a POST into a GET, so they're reported instead of followed. + String location = response.headers().firstValue("Location").orElse("(unknown)"); + LOG.warn( + "The Exceptionless server redirected " + + request.uri() + + " to " + + location + + "; update the server URL"); + body = "Redirected to " + location; + } + return new SubmissionResponse( + status, + body == null || body.isEmpty() ? null : body, + parseVersion(response.headers().firstValue(CONFIG_VERSION_HEADER).orElse(null))); + } catch (IOException e) { + LOG.debug(() -> "Request to " + request.uri() + " failed: " + e); + return SubmissionResponse.failed(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return SubmissionResponse.failed(e); + } catch (RuntimeException e) { + LOG.warn("Request to " + request.uri() + " failed", e); + return SubmissionResponse.failed(e); + } + } + + private HttpRequest.Builder request(String url) { + return HttpRequest.newBuilder(URI.create(url)) + .timeout(config.getRequestTimeout()) + .header("Authorization", "Bearer " + config.getApiKey()) + .header("User-Agent", ClientInfo.USER_AGENT) + .header("Accept", "application/json"); + } + + static @Nullable Integer parseVersion(@Nullable String value) { + if (value == null) { + return null; + } + try { + return Integer.parseInt(value.strip()); + } catch (NumberFormatException e) { + return null; + } + } + + static byte[] gzip(byte[] data) { + ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(64, data.length / 4)); + try (GZIPOutputStream gzip = new GZIPOutputStream(out)) { + gzip.write(data); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return out.toByteArray(); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/submission/SettingsResponse.java b/exceptionless-core/src/main/java/com/exceptionless/submission/SettingsResponse.java new file mode 100644 index 0000000..859a505 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/SettingsResponse.java @@ -0,0 +1,39 @@ +package com.exceptionless.submission; + +import java.util.Map; + +/** + * The result of fetching project settings: {@link Updated new settings}, {@link NotModified no + * change}, or {@link Failed a failure}. + * + *

{@code
+ * if (client.getSettings(version) instanceof SettingsResponse.Updated updated) {
+ *   apply(updated.settings(), updated.version());
+ * }
+ * }
+ */ +public sealed interface SettingsResponse + permits SettingsResponse.Updated, SettingsResponse.NotModified, SettingsResponse.Failed { + + /** + * New settings were received. + * + * @param settings the project settings + * @param version the settings version + */ + record Updated(Map settings, int version) implements SettingsResponse { + public Updated { + settings = Map.copyOf(settings); + } + } + + /** The server's settings version matches ours (HTTP 304). */ + record NotModified() implements SettingsResponse {} + + /** + * The settings could not be fetched. + * + * @param message why + */ + record Failed(String message) implements SettingsResponse {} +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionClient.java b/exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionClient.java new file mode 100644 index 0000000..00210f9 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionClient.java @@ -0,0 +1,18 @@ +package com.exceptionless.submission; + +import com.exceptionless.models.UserDescription; + +/** Talks to the Exceptionless server. Calls block; the client makes them on its own thread. */ +public interface SubmissionClient { + /** Posts a JSON array of events to {@code /api/v2/events}. */ + SubmissionResponse postEvents(String eventsJson); + + /** Attaches a user's email address and description to the event with the reference id. */ + SubmissionResponse postUserDescription(String referenceId, UserDescription description); + + /** Fetches the project settings if they are newer than {@code version}. */ + SettingsResponse getSettings(int version); + + /** Sends a session heartbeat, or ends the session when {@code closeSession} is true. */ + SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession); +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionResponse.java b/exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionResponse.java new file mode 100644 index 0000000..0a0e9f3 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionResponse.java @@ -0,0 +1,61 @@ +package com.exceptionless.submission; + +import org.jspecify.annotations.Nullable; + +/** + * The result of a request to the server. + * + * @param statusCode the HTTP status code, or {@code -1} when the request failed without a response + * @param message the response body or error message, if any + * @param configVersion the {@code X-Exceptionless-ConfigVersion} response header + */ +public record SubmissionResponse( + int statusCode, @Nullable String message, @Nullable Integer configVersion) { + + /** A request that failed without a response. */ + public static SubmissionResponse failed(Throwable error) { + return new SubmissionResponse( + -1, error.getClass().getSimpleName() + ": " + error.getMessage(), null); + } + + /** A request that was not sent, for example because the client is disabled. */ + public static SubmissionResponse notSent(String message) { + return new SubmissionResponse(-1, message, null); + } + + public boolean isSuccess() { + return statusCode >= 200 && statusCode <= 299; + } + + public boolean isBadRequest() { + return statusCode == 400; + } + + public boolean isUnauthorized() { + return statusCode == 401; + } + + public boolean isPaymentRequired() { + return statusCode == 402; + } + + public boolean isForbidden() { + return statusCode == 403; + } + + public boolean isNotFound() { + return statusCode == 404; + } + + public boolean isRequestEntityTooLarge() { + return statusCode == 413; + } + + public boolean isTooManyRequests() { + return statusCode == 429; + } + + public boolean isServiceUnavailable() { + return statusCode == 503; + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/submission/package-info.java b/exceptionless-core/src/main/java/com/exceptionless/submission/package-info.java new file mode 100644 index 0000000..1c71abd --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/package-info.java @@ -0,0 +1,5 @@ +/** Sending events, heartbeats and user descriptions, and fetching settings. */ +@NullMarked +package com.exceptionless.submission; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-core/src/main/java/module-info.java b/exceptionless-core/src/main/java/module-info.java new file mode 100644 index 0000000..0520e95 --- /dev/null +++ b/exceptionless-core/src/main/java/module-info.java @@ -0,0 +1,17 @@ +/** The Exceptionless client for Java. */ +module com.exceptionless.core { + requires transitive java.net.http; + requires static java.management; + requires static jdk.management; + requires static transitive org.jspecify; + + exports com.exceptionless; + exports com.exceptionless.json; + exports com.exceptionless.logging; + exports com.exceptionless.models; + exports com.exceptionless.plugins; + exports com.exceptionless.queue; + exports com.exceptionless.settings; + exports com.exceptionless.storage; + exports com.exceptionless.submission; +} diff --git a/exceptionless-core/src/main/resources/com/exceptionless/client.properties b/exceptionless-core/src/main/resources/com/exceptionless/client.properties new file mode 100644 index 0000000..defbd48 --- /dev/null +++ b/exceptionless-core/src/main/resources/com/exceptionless/client.properties @@ -0,0 +1 @@ +version=${project.version} diff --git a/exceptionless-core/src/test/java/com/exceptionless/ClientSmokeTest.java b/exceptionless-core/src/test/java/com/exceptionless/ClientSmokeTest.java new file mode 100644 index 0000000..b8767f6 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/ClientSmokeTest.java @@ -0,0 +1,38 @@ +package com.exceptionless; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ClientSmokeTest { + @Test + void sendsErrorWithSnakeCaseStackTrace() throws Exception { + try (FakeServer server = new FakeServer(); + ExceptionlessClient client = + new ExceptionlessClient( + new ExceptionlessConfiguration() + .apiKey("LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest") + .serverUrl(server.url()) + .captureUncaughtExceptions(false) + .registerShutdownHook(false))) { + client.submitException(new IllegalStateException("boom", new RuntimeException("cause"))); + assertThat(client.flush(Duration.ofSeconds(10))).isTrue(); + + FakeServer.Request request = server.take("/api/v2/events", Duration.ofSeconds(5)); + assertThat(request.header("Authorization")) + .isEqualTo("Bearer LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"); + assertThat(request.header("User-Agent")).matches("exceptionless-java/\\d+\\.\\d+\\.\\d+"); + List> events = request.events(); + assertThat(events).hasSize(1); + Map event = events.get(0); + assertThat(event.get("type")).isEqualTo("error"); + Map error = TestJson.object(TestJson.object(event.get("data")).get("@error")); + assertThat(error.get("type")).isEqualTo("java.lang.IllegalStateException"); + assertThat(error.get("message")).isEqualTo("boom"); + assertThat(error).containsKey("stack_trace").containsKey("inner"); + } + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessClientTest.java b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessClientTest.java new file mode 100644 index 0000000..8b42f21 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessClientTest.java @@ -0,0 +1,527 @@ +package com.exceptionless; + +import static com.exceptionless.TestClients.path; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.exceptionless.TestClients.CapturingSubmissionClient; +import com.exceptionless.models.Event; +import com.exceptionless.plugins.EventPluginContext; +import com.exceptionless.plugins.HeartbeatPlugin; +import com.exceptionless.submission.SettingsResponse; +import com.exceptionless.submission.SubmissionResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ExceptionlessClientTest { + private final CapturingSubmissionClient submission = new CapturingSubmissionClient(); + private final ExceptionlessConfiguration config = + TestClients.config().submissionClient(submission); + private final ExceptionlessClient client = new ExceptionlessClient(config); + + @AfterEach + void close() { + client.close(); + } + + private List> sent() { + assertThat(client.flush(Duration.ofSeconds(10))).isTrue(); + return submission.events(); + } + + @Test + void startsOnFirstUse() { + assertThat(client.isStarted()).isFalse(); + client.submitLog("hello"); + assertThat(client.isStarted()).isTrue(); + assertThat(sent()) + .singleElement() + .satisfies( + e -> { + assertThat(e.get("type")).isEqualTo("log"); + assertThat(e.get("message")).isEqualTo("hello"); + assertThat(e.get("date")).isNotNull(); + }); + } + + @Test + void datesEventsWithTheClientsClock() { + TestClock clock = new TestClock(); + try (ExceptionlessClient clocked = new ExceptionlessClient(config, clock)) { + assertThat(clocked.createLog("dated").submit().getEvent().getDate()) + .isEqualTo(OffsetDateTime.ofInstant(TestClock.START, ZoneOffset.UTC)); + } + } + + @Test + void startupIsIdempotent() { + assertThat(client.startup()).isSameAs(client); + assertThat(client.startup()).isSameAs(client); + } + + @Test + void backgroundThreadIsADaemon() throws Exception { + client.submitLog("x"); + client.flush(Duration.ofSeconds(5)); + List threads = + Thread.getAllStackTraces().keySet().stream() + .filter(t -> t.getName().startsWith("exceptionless-")) + .toList(); + assertThat(threads).isNotEmpty().allSatisfy(t -> assertThat(t.isDaemon()).isTrue()); + } + + @Test + void builderSetsAllEventFields() { + client + .createFeatureUsage("Export") + .setMessage("Exported report") + .addTags("reports", "csv") + .markAsCritical() + .setGeo(43.5, -96.75) + .setValue(12.5) + .setReferenceId("report-12345") + .setEventReference("parent", "parent-12345") + .setUserIdentity("user@example.com", "Ann") + .setUserDescription("ann@example.com", "It broke") + .setManualStackingKey("exports", "Export failures") + .setProperty("order", Map.of("id", 7, "items", List.of("a", "b"))) + .setProperty("password", "never-sent") + .setVersion("1.0.0") + .setLevel(LogLevel.INFO) + .submit(); + config.addDataExclusions("password"); + Map event = sent().get(0); + assertThat(event.get("type")).isEqualTo("usage"); + assertThat(event.get("source")).isEqualTo("Export"); + assertThat(event.get("tags")).isEqualTo(List.of("reports", "csv", "Critical")); + assertThat(event.get("geo")).isEqualTo("43.5,-96.75"); + assertThat(event.get("value")).hasToString("12.5"); + assertThat(event.get("reference_id")).isEqualTo("report-12345"); + assertThat(path(event, "data.@ref:parent")).isEqualTo("parent-12345"); + assertThat(path(event, "data.@user")) + .isEqualTo(Map.of("identity", "user@example.com", "name", "Ann")); + assertThat(path(event, "data.@user_description")) + .isEqualTo(Map.of("email_address", "ann@example.com", "description", "It broke")); + assertThat(path(event, "data.@stack")) + .isEqualTo( + Map.of( + "title", + "Export failures", + "signature_data", + Map.of("ManualStackingKey", "exports"))); + assertThat(path(event, "data.order")).isEqualTo(Map.of("id", 7L, "items", List.of("a", "b"))); + assertThat(path(event, "data.@version")).isEqualTo("1.0.0"); + assertThat(path(event, "data.@level")).isEqualTo("info"); + assertThat(path(event, "data.@environment.o_s_name")).isNotNull(); + } + + @Test + void dataExclusionsApplyToProperties() { + config.addDataExclusions("*password*"); + client + .createLog("x") + .setProperty("userPassword", "a") + .setProperty("user", Map.of("password", "b", "name", "c")) + .submit(); + Map event = sent().get(0); + assertThat(path(event, "data.userPassword")).isNull(); + assertThat(path(event, "data.user")).isEqualTo(Map.of("name", "c")); + } + + @Test + void builderValidatesInput() { + EventBuilder builder = client.createLog("x"); + assertThatThrownBy(() -> builder.setReferenceId("bad id")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> builder.setGeo(91, 0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> builder.setGeo(0, 181)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> builder.setEventReference(" ", "valid-id-1")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void exceptionsBecomeErrorEventsWithReferenceIds() { + client.submitException(new IllegalStateException("broken", new RuntimeException("cause"))); + Map event = sent().get(0); + assertThat(event.get("type")).isEqualTo("error"); + assertThat(path(event, "data.@error.type")).isEqualTo("java.lang.IllegalStateException"); + assertThat(path(event, "data.@error.inner.message")).isEqualTo("cause"); + assertThat((String) event.get("reference_id")).hasSize(16); + assertThat(client.getLastReferenceId()).isEqualTo(event.get("reference_id")); + } + + @Test + void javaLangErrorsCanBeReported() { + client.submitUnhandledException(new OutOfMemoryError("Java heap space"), "test"); + Map event = sent().get(0); + assertThat(path(event, "data.@error.type")).isEqualTo("java.lang.OutOfMemoryError"); + assertThat(path(event, "data.@submission_method")).isEqualTo("test"); + } + + @Test + void logShortcuts() { + client.submitLog("com.example.Source", "message", LogLevel.ERROR); + client.submitNotFound("/missing"); + client.submitFeatureUsage("Search"); + List> events = sent(); + assertThat(events).extracting(e -> e.get("type")).containsExactly("log", "404", "usage"); + assertThat(events.get(0).get("source")).isEqualTo("com.example.Source"); + assertThat(path(events.get(0), "data.@level")).isEqualTo("error"); + assertThat(events.get(1).get("source")).isEqualTo("/missing"); + } + + @Test + void submittingHandlersCanModifyOrCancel() { + client.addSubmittingEventHandler(e -> e.getEvent().addTags("handled")); + client.addSubmittingEventHandler( + e -> { + if ("drop".equals(e.getEvent().getMessage())) { + e.cancel(); + } + }); + client.addSubmittingEventHandler( + e -> { + throw new IllegalStateException("handler errors are isolated"); + }); + AtomicInteger submitted = new AtomicInteger(); + client.addSubmittedEventHandler(e -> submitted.incrementAndGet()); + EventPluginContext dropped = client.createLog("drop").submit(); + client.submitLog("keep"); + assertThat(dropped.isCancelled()).isTrue(); + List> events = sent(); + assertThat(events) + .singleElement() + .satisfies(e -> assertThat(e.get("tags")).isEqualTo(List.of("handled"))); + assertThat(submitted).hasValue(1); + } + + @Test + void pluginExceptionsDoNotLoseTheEvent() { + config.addPlugin( + "broken", + 15, + ctx -> { + throw new IllegalStateException("plugin bug"); + }); + client.submitLog("still sent"); + assertThat(sent()).hasSize(1); + } + + @Test + void reentrantSubmissionIsIgnored() { + config.addPlugin("reentrant", 1, ctx -> ctx.getClient().submitLog("from plugin")); + client.submitLog("outer"); + assertThat(sent()).extracting(e -> e.get("message")).containsExactly("outer"); + } + + @Test + void disabledClientIsANoOp() { + ExceptionlessClient disabled = + new ExceptionlessClient( + new ExceptionlessConfiguration() + .captureUncaughtExceptions(false) + .registerShutdownHook(false) + .submissionClient(submission)); + try (disabled) { + EventPluginContext context = disabled.createLog("x").submit(); + assertThat(context.isCancelled()).isTrue(); + assertThat(disabled.flush(Duration.ofSeconds(1))).isTrue(); + assertThat(submission.posts).isEmpty(); + } + } + + @Test + void closeSendsQueuedEventsAndStopsAcceptingEvents() { + config.queueProcessInterval(Duration.ofHours(1)); + client.submitLog("queued"); + client.close(); + assertThat(submission.events()).hasSize(1); + assertThat(client.isStarted()).isFalse(); + assertThat(client.createLog("after close").submit().isCancelled()).isTrue(); + client.close(); // idempotent + } + + @Test + void closeIsBoundedByShutdownTimeout() { + CountDownLatch release = new CountDownLatch(1); + submission.eventsResponse = + json -> { + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return new SubmissionResponse(202, null, null); + }; + config.shutdownTimeout(Duration.ofMillis(300)).queueProcessInterval(Duration.ofHours(1)); + client.submitLog("slow"); + long start = System.nanoTime(); + client.close(); + release.countDown(); + assertThat(Duration.ofNanos(System.nanoTime() - start)).isLessThan(Duration.ofSeconds(3)); + } + + @Test + void neverStartedClientClosesQuietly() { + new ExceptionlessClient(TestClients.config()).close(); + } + + @Test + void queueIsProcessedOnTheInterval() throws Exception { + CountDownLatch posted = new CountDownLatch(1); + submission.eventsResponse = + json -> { + posted.countDown(); + return new SubmissionResponse(202, null, null); + }; + config.queueProcessInterval(Duration.ofMillis(100)); + client.submitLog("timer"); + assertThat(posted.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(submission.posts).hasSize(1); + } + + @Test + void configVersionHeaderTriggersSettingsRefresh() { + submission.eventsResponse = json -> new SubmissionResponse(202, null, 5); + submission.settingsResponse = new SettingsResponse.Updated(Map.of("@@log:*", "error"), 5); + client.submitLog("x"); + client.flush(Duration.ofSeconds(5)); + assertThat(submission.settingsRequests).contains(0); + assertThat(config.getSettings().getString("@@log:*")).isEqualTo("error"); + client.submitLog("com.x", "info message is now filtered", LogLevel.INFO); + client.submitLog("com.x", "error message", LogLevel.ERROR); + client.flush(Duration.ofSeconds(5)); + assertThat(submission.events()) + .extracting(e -> e.get("message")) + .containsExactly("x", "error message"); + } + + @Test + void updateSettingsOnDemand() throws Exception { + submission.settingsResponse = new SettingsResponse.Updated(Map.of("feature", "on"), 1); + client.updateSettings().get(5, TimeUnit.SECONDS); + assertThat(config.getSettings().getString("feature")).isEqualTo("on"); + } + + @Test + void userDescriptions() throws Exception { + SubmissionResponse response = + client + .updateUserEmailAndDescription("ref-123456", "a@b.c", "what happened") + .get(5, TimeUnit.SECONDS); + assertThat(response.isSuccess()).isTrue(); + assertThat(submission.userDescriptions) + .containsExactly( + "ref-123456={\"email_address\":\"a@b.c\",\"description\":\"what happened\"}"); + assertThatThrownBy(() -> client.updateUserEmailAndDescription("bad ref", null, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void sessionHeartbeatsAndEnd() throws Exception { + assertThat(client.submitSessionHeartbeat(null).get(5, TimeUnit.SECONDS).statusCode()) + .isEqualTo(-1); + config.userIdentity("user-1"); + client.submitSessionHeartbeat(null).get(5, TimeUnit.SECONDS); + client.submitSessionEnd("explicit-id").get(5, TimeUnit.SECONDS); + assertThat(submission.heartbeats).containsExactly("user-1", "explicit-id:close"); + } + + @Test + void sessionsStartOnStartupAndEndOnClose() { + config.userIdentity("user-1", "Ann").useSessions(false, Duration.ofMinutes(1), false); + client.startup(); + client.close(); + assertThat(submission.events()) + .singleElement() + .satisfies( + e -> { + assertThat(e.get("type")).isEqualTo("session"); + assertThat(path(e, "data.@user.identity")).isEqualTo("user-1"); + }); + assertThat(submission.heartbeats).containsExactly("user-1:close"); + } + + @Test + void sessionIdManagementWithoutUser() { + config.useSessions(false, Duration.ofMinutes(1), true); + client.startup(); + client.submitLog("in session"); + client.close(); + List> events = submission.events(); + assertThat(events).hasSize(2); + String sessionId = (String) events.get(0).get("reference_id"); + assertThat(events.get(0).get("type")).isEqualTo("session"); + assertThat(path(events.get(1), "data.@ref:session")).isEqualTo(sessionId); + assertThat(submission.heartbeats).containsExactly(sessionId + ":close"); + } + + @Test + void heartbeatsAreSentOnTheInterval() throws Exception { + ExceptionlessConfiguration hbConfig = + TestClients.config().submissionClient(submission).userIdentity("hb-user"); + hbConfig.useSessions(true, Duration.ofSeconds(30), false); + // Use the plugin directly with a short interval through reflection-free access: replace with a + // fast heartbeat. + hbConfig.removePlugin("HeartbeatPlugin"); + hbConfig.addPlugin("FastHeartbeat", 100, ctx -> {}); + CountDownLatch sent = new CountDownLatch(2); + try (ExceptionlessClient hbClient = new ExceptionlessClient(hbConfig)) { + hbClient.scheduleAtFixedRate( + () -> hbClient.submitSessionHeartbeat(null).thenRun(sent::countDown), + Duration.ofMillis(10), + Duration.ofMillis(50)); + assertThat(sent.await(5, TimeUnit.SECONDS)).isTrue(); + } + assertThat(submission.heartbeats).contains("hb-user"); + } + + @Test + void sessionEndStopsHeartbeats() throws Exception { + config.userIdentity("user-2").useSessions(true, Duration.ofMinutes(1), false); + client.submitLog("starts the heartbeat"); + HeartbeatPlugin heartbeat = + config.getPlugins().stream() + .filter(HeartbeatPlugin.class::isInstance) + .map(HeartbeatPlugin.class::cast) + .findFirst() + .orElseThrow(); + assertThat(heartbeat.isRunning()).isTrue(); + client.submitSessionEnd(null).get(5, TimeUnit.SECONDS); + assertThat(heartbeat.isRunning()).isFalse(); + } + + @Test + void uncaughtExceptionHandlerReportsAndChains() throws Exception { + Thread.UncaughtExceptionHandler original = Thread.getDefaultUncaughtExceptionHandler(); + AtomicInteger previousCalls = new AtomicInteger(); + Thread.setDefaultUncaughtExceptionHandler((t, e) -> previousCalls.incrementAndGet()); + try { + ExceptionlessConfiguration handlerConfig = + TestClients.config().submissionClient(submission).captureUncaughtExceptions(true); + try (ExceptionlessClient handlerClient = new ExceptionlessClient(handlerConfig).startup()) { + Thread thread = + new Thread( + () -> { + throw new IllegalStateException("thread crashed"); + }, + "worker-1"); + thread.start(); + thread.join(); + handlerClient.flush(Duration.ofSeconds(5)); + assertThat(previousCalls).hasValue(1); + assertThat(submission.events()) + .singleElement() + .satisfies( + e -> { + assertThat(path(e, "data.@error.message")).isEqualTo("thread crashed"); + assertThat(path(e, "data.@submission_method")) + .isEqualTo("uncaughtExceptionHandler"); + assertThat(path(e, "data.thread")).isEqualTo("worker-1"); + }); + } + // Closing restores the previous handler. + assertThat(Thread.getDefaultUncaughtExceptionHandler()).isNotNull(); + Thread.getDefaultUncaughtExceptionHandler() + .uncaughtException(Thread.currentThread(), new RuntimeException()); + assertThat(previousCalls).hasValue(2); + } finally { + Thread.setDefaultUncaughtExceptionHandler(original); + } + } + + @Test + void fileStoragePersistsEventsAcrossRestarts(@TempDir Path dir) throws Exception { + CapturingSubmissionClient failing = new CapturingSubmissionClient(); + failing.eventsResponse = json -> new SubmissionResponse(503, null, null); + ExceptionlessConfiguration first = + TestClients.config() + .submissionClient(failing) + .useFileStorage(dir) + .shutdownTimeout(Duration.ofSeconds(2)); + try (ExceptionlessClient c = new ExceptionlessClient(first)) { + c.submitLog("survives restart"); + c.flush(Duration.ofSeconds(5)); + } + String installId; + ExceptionlessConfiguration second = + TestClients.config().submissionClient(submission).useFileStorage(dir); + try (ExceptionlessClient c = new ExceptionlessClient(second)) { + installId = c.getInstallId(); + assertThat(c.flush(Duration.ofSeconds(5))).isTrue(); + } + assertThat(submission.events()) + .extracting(e -> e.get("message")) + .containsExactly("survives restart"); + try (java.util.stream.Stream files = Files.walk(dir)) { + assertThat(files.map(p -> p.getFileName().toString())).contains("install-id", "queue"); + } + try (ExceptionlessClient c = + new ExceptionlessClient( + TestClients.config().submissionClient(submission).useFileStorage(dir))) { + assertThat(c.getInstallId()).isEqualTo(installId); + } + } + + @Test + void savedSettingsApplyBeforeTheFirstRequest(@TempDir Path dir) { + submission.settingsResponse = new SettingsResponse.Updated(Map.of("@@log:*", "off"), 3); + try (ExceptionlessClient c = + new ExceptionlessClient( + TestClients.config().submissionClient(submission).useFileStorage(dir))) { + c.updateSettings().join(); + } + ExceptionlessConfiguration restarted = + TestClients.config().submissionClient(new CapturingSubmissionClient()).useFileStorage(dir); + try (ExceptionlessClient c = new ExceptionlessClient(restarted).startup()) { + assertThat(restarted.getSettings().getString("@@log:*")).isEqualTo("off"); + assertThat(c.createLog("src", "dropped", LogLevel.FATAL).submit().isCancelled()).isTrue(); + } + } + + @Test + void manualEventSubmission() { + Event event = new Event(); + event.setType("custom"); + event.setMessage("hand built"); + EventPluginContext context = client.submitEvent(event, null); + assertThat(context.isCancelled()).isFalse(); + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e.get("type")).isEqualTo("custom")); + } + + @Test + void concurrentSubmissionsAreAllSent() throws Exception { + config.submissionBatchSize(25); + int threads = 8; + int perThread = 100; + Thread[] workers = new Thread[threads]; + for (int t = 0; t < threads; t++) { + int id = t; + workers[t] = + new Thread( + () -> { + for (int i = 0; i < perThread; i++) { + client.submitLog("thread-" + id, "message " + i); + } + }); + workers[t].start(); + } + for (Thread worker : workers) { + worker.join(); + } + assertThat(sent()).hasSize(threads * perThread); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java new file mode 100644 index 0000000..75ed94d --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java @@ -0,0 +1,212 @@ +package com.exceptionless; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class ExceptionlessConfigurationTest { + @Test + void defaultsMatchTheHostedService() { + ExceptionlessConfiguration config = new ExceptionlessConfiguration(); + assertThat(config.getServerUrl()).isEqualTo("https://collector.exceptionless.io"); + assertThat(config.getConfigServerUrl()).isEqualTo("https://config.exceptionless.io"); + assertThat(config.getHeartbeatServerUrl()).isEqualTo("https://heartbeat.exceptionless.io"); + assertThat(config.isIncludePrivateInformation()).isTrue(); + assertThat(config.isReferenceIdsEnabled()).isTrue(); + assertThat(config.getSubmissionBatchSize()).isEqualTo(50); + assertThat(config.getUpdateSettingsWhenIdleInterval()).isEqualTo(Duration.ofMinutes(2)); + assertThat(config.isEnabled()).isFalse(); // no API key + } + + @Test + void serverUrlSetsAllUrlsAndIsNormalized() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration().serverUrl(" https://ex.example.com/// "); + assertThat(config.getServerUrl()).isEqualTo("https://ex.example.com"); + assertThat(config.getConfigServerUrl()).isEqualTo("https://ex.example.com"); + assertThat(config.getHeartbeatServerUrl()).isEqualTo("https://ex.example.com"); + config.heartbeatServerUrl("https://hb.example.com"); + assertThat(config.getHeartbeatServerUrl()).isEqualTo("https://hb.example.com"); + assertThatThrownBy(() -> config.serverUrl("ftp://x")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> config.serverUrl(" ")).isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @ValueSource(strings = {"API_KEY_HERE", "short", "has spaces in it"}) + void invalidApiKeysDisableTheClient(String key) { + ExceptionlessConfiguration config = new ExceptionlessConfiguration().apiKey(key); + assertThat(config.validate()).isNotEmpty(); + assertThat(config.isEnabled()).isFalse(); + } + + @Test + void validApiKeyEnables() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration().apiKey(" " + TestClients.API_KEY + " "); + assertThat(config.getApiKey()).isEqualTo(TestClients.API_KEY); + assertThat(config.isEnabled()).isTrue(); + assertThat(config.enabled(false).isEnabled()).isFalse(); + } + + @Test + void connectionSettingsLockAfterStartup() { + ExceptionlessConfiguration config = TestClients.config(); + try (ExceptionlessClient client = new ExceptionlessClient(config).startup()) { + assertThatThrownBy(() -> config.apiKey("other-key-123456")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("apiKey"); + assertThatThrownBy(() -> config.serverUrl("https://x")) + .isInstanceOf(IllegalStateException.class); + // Everything else can still change. + config.addDefaultTags("later").version("2.0").includeCookies(false); + assertThat(client.getConfiguration().getDefaultTags()).contains("later"); + } + } + + @Test + void privacyMasterSwitchAndIndividualToggles() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration().includePrivateInformation(false); + assertThat(config.isIncludeCookies()).isFalse(); + assertThat(config.isIncludeHeaders()).isFalse(); + assertThat(config.isIncludeMachineName()).isFalse(); + config.includeHeaders(true); + assertThat(config.isIncludeHeaders()).isTrue(); + assertThat(config.isIncludeCookies()).isFalse(); + } + + @Test + void intervalsAreClamped() { + ExceptionlessConfiguration config = new ExceptionlessConfiguration(); + assertThat( + config + .updateSettingsWhenIdleInterval(Duration.ofSeconds(5)) + .getUpdateSettingsWhenIdleInterval()) + .isEqualTo(Duration.ofMinutes(2)); + assertThat( + config + .updateSettingsWhenIdleInterval(Duration.ZERO) + .getUpdateSettingsWhenIdleInterval()) + .isEqualTo(Duration.ZERO); + assertThat(config.useSessions(true, Duration.ofSeconds(1), false).getHeartbeatInterval()) + .isEqualTo(Duration.ofSeconds(30)); + assertThatThrownBy(() -> config.submissionBatchSize(0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void useSessionsAddsPlugins() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration().useSessions(true, Duration.ofMinutes(1), true); + assertThat(config.isSessionsEnabled()).isTrue(); + assertThat(config.hasPlugin("HeartbeatPlugin")).isTrue(); + assertThat(config.hasPlugin("SessionIdManagementPlugin")).isTrue(); + } + + @Test + void readsValuesFromMaps() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration() + .readFromValues( + Map.of( + "EXCEPTIONLESS_API_KEY", TestClients.API_KEY, + "EXCEPTIONLESS_SERVER_URL", "https://self.hosted/", + "EXCEPTIONLESS_VERSION", "3.1.4", + "EXCEPTIONLESS_DEFAULT_TAGS", "a, b", + "EXCEPTIONLESS_INCLUDE_PRIVATE_INFORMATION", "false", + "EXCEPTIONLESS_STORAGE_PATH", "/tmp/ex", + "EXCEPTIONLESS_SETTINGS_FEATURE_X", "on"), + ExceptionlessConfiguration::environmentName); + assertThat(config.getApiKey()).isEqualTo(TestClients.API_KEY); + assertThat(config.getServerUrl()).isEqualTo("https://self.hosted"); + assertThat(config.getVersion()).isEqualTo("3.1.4"); + assertThat(config.getDefaultTags()).containsExactlyInAnyOrder("a", "b"); + assertThat(config.isIncludePrivateInformation()).isFalse(); + assertThat(config.getStoragePath()).isEqualTo(Path.of("/tmp/ex")); + assertThat(config.getSettings().getString("FEATURE_X")).isEqualTo("on"); + } + + @ParameterizedTest + @CsvSource({ + "apiKey, EXCEPTIONLESS_API_KEY", + "serverUrl, EXCEPTIONLESS_SERVER_URL", + "settings., EXCEPTIONLESS_SETTINGS_", + "enabled, EXCEPTIONLESS_ENABLED" + }) + void environmentNames(String key, String expected) { + assertThat(ExceptionlessConfiguration.environmentName(key)).isEqualTo(expected); + } + + @Test + void readsSystemPropertiesAndClasspathProperties() { + // src/test/resources/exceptionless.properties sets version=from-file and defaultTags=file-tag + System.setProperty("exceptionless.version", "from-system-property"); + try { + ExceptionlessConfiguration config = ExceptionlessConfiguration.fromEnvironment(); + assertThat(config.getVersion()).isEqualTo("from-system-property"); + assertThat(config.getDefaultTags()).contains("file-tag"); + assertThat(config.getSettings().getString("@@log:*")).isEqualTo("warn"); + } finally { + System.clearProperty("exceptionless.version"); + } + } + + @Test + void dataExclusionsCombineLocalAndServer() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration().addDataExclusions("*password*", " "); + config.applyServerSettings(Map.of("@@DataExclusions", "*secret*,ssn")); + assertThat(config.getDataExclusions()).containsExactly("*password*", "*secret*", "ssn"); + } + + @Test + void compiledDataExclusionsFollowLocalAndServerChanges() { + ExceptionlessConfiguration config = new ExceptionlessConfiguration(); + assertThat(config.isDataExcluded("Password")).isFalse(); + + config.addDataExclusions("*password*"); + assertThat(config.isDataExcluded("UserPassword")).isTrue(); + assertThat(config.isDataExcluded("ssn")).isFalse(); + + config.applyServerSettings(Map.of("@@DataExclusions", "ssn")); + assertThat(config.isDataExcluded("SSN")).isTrue(); + + config.applyServerSettings(Map.of()); + assertThat(config.isDataExcluded("ssn")).isFalse(); + assertThat(config.isDataExcluded("password")).isTrue(); + + config.setting("@@DataExclusions", "token*"); + assertThat(config.isDataExcluded("tokenValue")).isTrue(); + config.setting("@@DataExclusions", null); + assertThat(config.isDataExcluded("tokenValue")).isFalse(); + } + + @Test + void isLogLevelEnabled() { + ExceptionlessConfiguration config = new ExceptionlessConfiguration(); + assertThat(config.isLogLevelEnabled("x", LogLevel.INFO, LogLevel.WARN)).isFalse(); + assertThat(config.isLogLevelEnabled("x", LogLevel.INFO, null)).isTrue(); + config.applyServerSettings(Map.of("@@log:x", "info")); + assertThat(config.isLogLevelEnabled("x", LogLevel.INFO, LogLevel.WARN)).isTrue(); + config.applyServerSettings(Map.of("@@log:*", "off")); + assertThat(config.isLogLevelEnabled("x", LogLevel.FATAL, null)).isFalse(); + } + + @Test + void userIdentityAddsDefaultUser() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration().userIdentity("id-1", "Ann"); + assertThat(config.getDefaultData().get("@user")) + .isEqualTo(new com.exceptionless.models.UserInfo("id-1", "Ann")); + config.userIdentity(null); + assertThat(config.getDefaultData()).doesNotContainKey("@user"); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessTest.java b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessTest.java new file mode 100644 index 0000000..67b03f4 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessTest.java @@ -0,0 +1,84 @@ +package com.exceptionless; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.exceptionless.TestClients.CapturingSubmissionClient; +import java.time.Duration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ExceptionlessTest { + @AfterEach + void reset() { + Exceptionless.shutdown(); + } + + @Test + void defaultClientIsConfiguredFromTheEnvironment() { + assertThat(Exceptionless.hasDefault()).isFalse(); + ExceptionlessClient client = Exceptionless.getDefault(); + assertThat(Exceptionless.getDefault()).isSameAs(client); + // From src/test/resources/exceptionless.properties. + assertThat(client.getConfiguration().getVersion()).isEqualTo("from-file"); + } + + @Test + void startupConfiguresAndStartsTheDefaultClient() { + CapturingSubmissionClient submission = new CapturingSubmissionClient(); + Exceptionless.startup( + c -> + c.apiKey(TestClients.API_KEY) + .submissionClient(submission) + .captureUncaughtExceptions(false) + .registerShutdownHook(false)); + Exceptionless.submitLog("static"); + Exceptionless.createFeatureUsage("feature").submit(); + Exceptionless.submitException(new RuntimeException("x")); + assertThat(Exceptionless.flush(Duration.ofSeconds(5))).isTrue(); + assertThat(submission.events()) + .extracting(e -> e.get("type")) + .containsExactly("log", "usage", "error"); + assertThat(Exceptionless.getLastReferenceId()).isNotNull(); + assertThatThrownBy(() -> Exceptionless.startup(c -> {})) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void setDefaultReplacesTheClient() { + ExceptionlessClient mine = new ExceptionlessClient(TestClients.config()); + Exceptionless.setDefault(mine); + assertThat(Exceptionless.getDefault()).isSameAs(mine); + Exceptionless.shutdown(); + assertThat(mine.isStarted()).isFalse(); + assertThat(Exceptionless.getDefault()).isNotSameAs(mine); + } + + @Test + void startupReplacesADefaultClientThatStartedUnconfigured() { + Exceptionless.submitLog("used before configuration"); + ExceptionlessClient unconfigured = Exceptionless.getDefault(); + assertThat(unconfigured.isStarted()).isTrue(); + CapturingSubmissionClient submission = new CapturingSubmissionClient(); + ExceptionlessClient configured = + Exceptionless.startup( + c -> + c.apiKey(TestClients.API_KEY) + .submissionClient(submission) + .captureUncaughtExceptions(false) + .registerShutdownHook(false)); + assertThat(configured).isNotSameAs(unconfigured); + assertThat(unconfigured.isStarted()).isFalse(); + Exceptionless.submitLog("after configuration"); + Exceptionless.flush(Duration.ofSeconds(5)); + assertThat(submission.events()) + .extracting(e -> e.get("message")) + .containsExactly("after configuration"); + } + + @Test + void unconfiguredDefaultClientIsDisabled() { + Exceptionless.submitLog("goes nowhere"); + assertThat(Exceptionless.getConfiguration().isEnabled()).isFalse(); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/ExitProbe.java b/exceptionless-core/src/test/java/com/exceptionless/ExitProbe.java new file mode 100644 index 0000000..e30b167 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/ExitProbe.java @@ -0,0 +1,20 @@ +package com.exceptionless; + +import java.time.Duration; + +/** Run in a child JVM by {@link JvmExitTest}. Never closes the client on purpose. */ +public final class ExitProbe { + private ExitProbe() {} + + public static void main(String[] args) { + Exceptionless.startup( + c -> + c.apiKey(TestClients.API_KEY) + .serverUrl(args[0]) + .queueProcessInterval(Duration.ofHours(1))); + Exceptionless.submitLog("ExitProbe", "before exit"); + if (args.length > 1 && args[1].equals("crash")) { + throw new IllegalStateException("main crashed"); + } + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/JvmExitTest.java b/exceptionless-core/src/test/java/com/exceptionless/JvmExitTest.java new file mode 100644 index 0000000..bd8a0e7 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/JvmExitTest.java @@ -0,0 +1,64 @@ +package com.exceptionless; + +import static com.exceptionless.TestClients.path; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +/** + * Proves the two lifecycle guarantees in a real JVM: the client never keeps the JVM alive, and + * queued events are sent by the shutdown hook even when the app never closes the client. + */ +class JvmExitTest { + private static Process launch(String... args) throws Exception { + String java = ProcessHandle.current().info().command().orElse("java"); + String classpath = + location(ExceptionlessClient.class) + File.pathSeparator + location(ExitProbe.class); + List command = + new ArrayList<>(List.of(java, "-cp", classpath, ExitProbe.class.getName())); + command.addAll(List.of(args)); + return new ProcessBuilder(command).redirectErrorStream(true).start(); + } + + private static String location(Class type) throws Exception { + return Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI()).toString(); + } + + @Test + void jvmExitsAndShutdownHookSendsQueuedEvents() throws Exception { + try (FakeServer server = new FakeServer()) { + Process process = launch(server.url()); + assertThat(process.waitFor(30, TimeUnit.SECONDS)).as("JVM exited").isTrue(); + String output = new String(process.getInputStream().readAllBytes()); + assertThat(process.exitValue()).as(output).isZero(); + List> events = + server.take("/api/v2/events", Duration.ofSeconds(5)).events(); + assertThat(events).extracting(e -> e.get("message")).containsExactly("before exit"); + } + } + + @Test + void uncaughtExceptionInMainIsReported() throws Exception { + try (FakeServer server = new FakeServer()) { + Process process = launch(server.url(), "crash"); + assertThat(process.waitFor(30, TimeUnit.SECONDS)).isTrue(); + String output = new String(process.getInputStream().readAllBytes()); + assertThat(process.exitValue()).isEqualTo(1); + assertThat(output) + .contains("Exception in thread \"main\" java.lang.IllegalStateException: main crashed"); + List> events = + server.take("/api/v2/events", Duration.ofSeconds(5)).events(); + assertThat(events).extracting(e -> e.get("type")).containsExactly("log", "error"); + Map error = events.get(1); + assertThat(path(error, "data.@error.message")).isEqualTo("main crashed"); + assertThat(path(error, "data.@submission_method")).isEqualTo("uncaughtExceptionHandler"); + } + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/TestClock.java b/exceptionless-core/src/test/java/com/exceptionless/TestClock.java new file mode 100644 index 0000000..b5d5966 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/TestClock.java @@ -0,0 +1,61 @@ +package com.exceptionless; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicReference; + +/** + * A clock tests can move. Usable wherever production code takes a {@link Clock} or {@link + * java.time.InstantSource}. Thread-safe. + */ +public final class TestClock extends Clock { + /** The default start time: a fixed instant, so tests don't depend on when they run. */ + public static final Instant START = Instant.parse("2026-05-01T10:07:00Z"); + + private final AtomicReference now; + private final ZoneId zone; + + public TestClock() { + this(START); + } + + public TestClock(Instant start) { + this(new AtomicReference<>(start), ZoneOffset.UTC); + } + + private TestClock(AtomicReference now, ZoneId zone) { + this.now = now; + this.zone = zone; + } + + @Override + public ZoneId getZone() { + return zone; + } + + /** A view of this clock in another zone; moving either moves both. */ + @Override + public TestClock withZone(ZoneId zone) { + return new TestClock(now, zone); + } + + @Override + public Instant instant() { + return now.get(); + } + + /** Moves the clock forward by {@code duration}. */ + public TestClock advance(Duration duration) { + now.updateAndGet(current -> current.plus(duration)); + return this; + } + + /** Sets the current time. */ + public TestClock set(Instant instant) { + now.set(instant); + return this; + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/TestJson.java b/exceptionless-core/src/test/java/com/exceptionless/TestJson.java new file mode 100644 index 0000000..8bedc99 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/TestJson.java @@ -0,0 +1,32 @@ +package com.exceptionless; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** Typed access to parsed JSON in tests, without unchecked casts. */ +public final class TestJson { + private TestJson() {} + + /** Asserts that {@code value} is a JSON object and returns a typed copy of it. */ + public static Map object(@Nullable Object value) { + assertThat(value).isInstanceOf(Map.class); + Map result = new LinkedHashMap<>(); + ((Map) value).forEach((key, item) -> result.put(String.valueOf(key), item)); + return result; + } + + /** Asserts that {@code value} is an array of JSON objects and returns typed copies of them. */ + public static List> objects(@Nullable Object value) { + assertThat(value).isInstanceOf(List.class); + List> result = new ArrayList<>(); + for (Object item : (List) value) { + result.add(object(item)); + } + return result; + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/ClientInfoTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/ClientInfoTest.java new file mode 100644 index 0000000..f5cc972 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/ClientInfoTest.java @@ -0,0 +1,29 @@ +package com.exceptionless.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class ClientInfoTest { + @ParameterizedTest + @CsvSource({ + "2.0.0, 2.0.0", + "2.0.0-SNAPSHOT, 2.0.0", + "2.1, 2.1", + "3, 3.0", + "1.2.3.4.5, 1.2.3.4", + "2.0.0-rc.1+abc, 2.0.0", + "garbage, 0.9.0" + }) + void numericVersionIsParsableByTheServer(String version, String expected) { + assertThat(ClientInfo.numericVersion(version)).isEqualTo(expected); + } + + @Test + void userAgentHasNameAndNumericVersion() { + assertThat(ClientInfo.USER_AGENT).matches("exceptionless-java/\\d+\\.\\d+(\\.\\d+){0,2}"); + assertThat(ClientInfo.VERSION).doesNotStartWith("${"); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/CollectorsTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/CollectorsTest.java new file mode 100644 index 0000000..d368183 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/CollectorsTest.java @@ -0,0 +1,48 @@ +package com.exceptionless.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.models.EnvironmentInfo; +import com.exceptionless.models.ModuleInfo; +import java.util.List; +import org.junit.jupiter.api.Test; + +class CollectorsTest { + @Test + void collectsEnvironment() { + EnvironmentInfo info = EnvironmentCollector.collect(true, true, "install"); + assertThat(info.getProcessorCount()).isPositive(); + assertThat(info.getProcessId()).matches("\\d+"); + assertThat(info.getThreadName()).isEqualTo(Thread.currentThread().getName()); + assertThat(info.getArchitecture()).isNotBlank(); + assertThat(info.getOsName()).isNotBlank(); + assertThat(info.getOsVersion()).isNotBlank(); + assertThat(info.getRuntimeVersion()) + .startsWith("Java " + System.getProperty("java.runtime.version", "")); + assertThat(info.getInstallId()).isEqualTo("install"); + assertThat(info.getTotalPhysicalMemory()).isPositive(); + assertThat(info.getProcessName()).isNotBlank(); + assertThat(info.getCommandLine()).isEqualTo(info.getProcessName()); + } + + @Test + void omitsPrivateFieldsWhenAsked() { + EnvironmentInfo info = EnvironmentCollector.collect(false, false, null); + assertThat(info.getMachineName()).isNull(); + assertThat(info.getIpAddress()).isNull(); + } + + @Test + void collectsModulesFromManifests() { + List modules = ModuleCollector.getModules(); + assertThat(modules).isNotEmpty(); + assertThat(modules) + .anySatisfy( + m -> { + assertThat(m.name()).containsIgnoringCase("assertj"); + assertThat(m.version()).isNotBlank(); + }); + assertThat(modules).extracting(ModuleInfo::moduleId).doesNotHaveDuplicates(); + assertThat(ModuleCollector.getModules()).isSameAs(modules); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/DataNormalizerTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/DataNormalizerTest.java new file mode 100644 index 0000000..4ada732 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/DataNormalizerTest.java @@ -0,0 +1,259 @@ +package com.exceptionless.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.TestJson; +import com.exceptionless.json.JsonWriter; +import com.exceptionless.models.UserInfo; +import java.math.BigDecimal; +import java.net.URI; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Predicate; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +class DataNormalizerTest { + private static final Predicate NONE = name -> false; + + public record Address(String street, String city) {} + + public record Customer(String name, Address address, List tags, String password) {} + + public static class Order { + private final String id; + private final BigDecimal total; + private final Customer customer; + public final int quantity = 3; + + public Order(String id, BigDecimal total, Customer customer) { + this.id = id; + this.total = total; + this.customer = customer; + } + + public String getId() { + return id; + } + + public BigDecimal getTotal() { + return total; + } + + public Customer getCustomer() { + return customer; + } + + public boolean isPaid() { + return true; + } + + public String getBroken() { + throw new IllegalStateException("getter throws"); + } + + public static String getStaticValue() { + return "static"; + } + } + + public static class Node { + public Node next; + public String name = "node"; + } + + private static class Hidden { + @Override + public String toString() { + return "hidden!"; + } + } + + @Test + void keepsSimpleValues() { + assertThat(DataNormalizer.normalize("s", 5, NONE)).isEqualTo("s"); + assertThat(DataNormalizer.normalize(42, 5, NONE)).isEqualTo(42); + assertThat(DataNormalizer.normalize(true, 5, NONE)).isEqualTo(true); + assertThat(DataNormalizer.normalize(Thread.State.RUNNABLE, 5, NONE)).isEqualTo("RUNNABLE"); + assertThat(DataNormalizer.normalize(URI.create("https://x"), 5, NONE)).isEqualTo("https://x"); + assertThat( + DataNormalizer.normalize( + UUID.fromString("00000000-0000-0000-0000-000000000002"), 5, NONE)) + .isEqualTo("00000000-0000-0000-0000-000000000002"); + assertThat(DataNormalizer.normalize(Instant.parse("2026-01-01T00:00:00Z"), 5, NONE)) + .isEqualTo("2026-01-01T00:00:00Z"); + assertThat(DataNormalizer.normalize(String.class, 5, NONE)).isEqualTo("java.lang.String"); + assertThat(DataNormalizer.normalize(Optional.of("x"), 5, NONE)).isEqualTo("x"); + assertThat(DataNormalizer.normalize(new byte[10], 5, NONE)).isEqualTo("[10 bytes]"); + } + + @Test + void convertsRecordsAndBeans() { + Order order = + new Order( + "o-1", + new BigDecimal("9.99"), + new Customer("Ann", new Address("Main", "Dallas"), List.of("vip"), "secret")); + Object normalized = DataNormalizer.normalize(order, 5, NONE); + Map map = TestJson.object(normalized); + assertThat(map) + .containsEntry("id", "o-1") + .containsEntry("total", new BigDecimal("9.99")) + .containsEntry("paid", true) + .containsEntry("quantity", 3) + .doesNotContainKeys("broken", "staticValue", "class"); + assertThat(map.get("customer")) + .isEqualTo( + Map.of( + "name", + "Ann", + "address", + Map.of("street", "Main", "city", "Dallas"), + "tags", + List.of("vip"), + "password", + "secret")); + } + + @Test + void appliesExclusionsAtEveryLevel() { + Map input = new LinkedHashMap<>(); + input.put("password", "x"); + input.put("nested", Map.of("Password", "y", "keep", 1)); + input.put("customer", new Customer("Ann", null, List.of(), "secret")); + Object normalized = DataNormalizer.normalize(input, 5, Exclusions.of(List.of("*password*"))); + assertThat(JsonWriter.toJson(normalized)) + .doesNotContainIgnoringCase("password") + .contains("\"keep\":1") + .contains("\"name\":\"Ann\""); + } + + @Test + void limitsDepth() { + Map deep = Map.of("a", Map.of("b", Map.of("c", Map.of("d", "leaf")))); + assertThat(DataNormalizer.normalize(deep, 3, NONE)) + .isEqualTo(Map.of("a", Map.of("b", Map.of()))); + assertThat(DataNormalizer.normalize(deep, 1, NONE)).isEqualTo(Map.of()); + assertThat(DataNormalizer.normalize(deep, 0, NONE)).isNull(); + assertThat(DataNormalizer.normalize("simple values ignore depth", 0, NONE)) + .isEqualTo("simple values ignore depth"); + } + + @Test + void handlesCircularReferences() { + Node a = new Node(); + Node b = new Node(); + a.next = b; + b.next = a; + Object normalized = DataNormalizer.normalize(a, 10, NONE); + assertThat(normalized).isEqualTo(Map.of("name", "node", "next", Map.of("name", "node"))); + + List list = new ArrayList<>(); + list.add(list); + assertThat(DataNormalizer.normalize(list, 10, NONE)) + .isEqualTo(java.util.Collections.singletonList(null)); + } + + @Test + void sharedButNotCircularReferencesAreKept() { + Address address = new Address("Main", "Dallas"); + Object normalized = DataNormalizer.normalize(List.of(address, address), 5, NONE); + assertThat(normalized) + .isEqualTo( + List.of( + Map.of("street", "Main", "city", "Dallas"), + Map.of("street", "Main", "city", "Dallas"))); + } + + static class PackagePrivateException extends RuntimeException { + public String getOrderId() { + return "o-9"; + } + } + + @Test + void readsGettersOfNonPublicClasses() { + assertThat( + DataNormalizer.normalizeProperties( + new PackagePrivateException(), 3, name -> !name.equals("orderId"))) + .containsEntry("orderId", "o-9"); + } + + @Test + void usesToStringForInaccessibleTypes() { + assertThat(DataNormalizer.normalize(new Hidden(), 5, NONE)).isEqualTo("hidden!"); + } + + @Test + void summarizesThrowables() { + assertThat(DataNormalizer.normalize(new IllegalStateException("bad"), 5, NONE)) + .isEqualTo(Map.of("type", "java.lang.IllegalStateException", "message", "bad")); + } + + @Test + void keepsSerializableModels() { + UserInfo user = new UserInfo("id", "name"); + assertThat(DataNormalizer.normalize(user, 5, NONE)).isSameAs(user); + } + + @Test + void capsCollectionSizes() { + List big = new ArrayList<>(); + for (int i = 0; i < 5000; i++) { + big.add(i); + } + assertThat(DataNormalizer.normalize(big, 5, NONE)) + .asInstanceOf(InstanceOfAssertFactories.LIST) + .hasSize(1000); + Map bigMap = new HashMap<>(); + for (int i = 0; i < 5000; i++) { + bigMap.put(i, i); + } + assertThat(DataNormalizer.normalize(bigMap, 5, NONE)) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .hasSize(1000); + } + + @Test + void convertsSetsAndArrays() { + assertThat(DataNormalizer.normalize(Set.of("a"), 5, NONE)).isEqualTo(List.of("a")); + assertThat(DataNormalizer.normalize(new String[] {"a", "b"}, 5, NONE)) + .isEqualTo(List.of("a", "b")); + assertThat(DataNormalizer.normalize(new int[] {1, 2}, 5, NONE)).isEqualTo(List.of(1, 2)); + } + + @Test + void normalizePropertiesReadsThrowableGetters() { + Map props = + DataNormalizer.normalizeProperties( + new CustomException("m", 42), + 3, + name -> + name.equals("stackTrace") + || name.equals("cause") + || name.equals("message") + || name.equals("localizedMessage") + || name.equals("suppressed")); + assertThat(props).containsEntry("errorCode", 42); + } + + public static class CustomException extends RuntimeException { + private final int errorCode; + + public CustomException(String message, int errorCode) { + super(message); + this.errorCode = errorCode; + } + + public int getErrorCode() { + return errorCode; + } + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/ErrorConverterTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/ErrorConverterTest.java new file mode 100644 index 0000000..7f6a2a6 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/ErrorConverterTest.java @@ -0,0 +1,244 @@ +package com.exceptionless.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.json.JsonWriter; +import com.exceptionless.models.ErrorInfo; +import com.exceptionless.models.KnownDataKeys; +import com.exceptionless.models.ModuleInfo; +import com.exceptionless.models.StackFrame; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.function.Supplier; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class ErrorConverterTest { + public static class PaymentException extends RuntimeException { + private final String orderId; + private final String password = "hunter2"; + + PaymentException(String message, String orderId, Throwable cause) { + super(message, cause); + this.orderId = orderId; + } + + public String getOrderId() { + return orderId; + } + + public String getPassword() { + return password; + } + } + + private static ErrorInfo convert(Throwable t) { + return ErrorConverter.convert(t, Exclusions.of(List.of("*password*")), List.of()); + } + + @Test + void mapsTypeMessageAndFrames() { + ErrorInfo error = convert(new IllegalArgumentException("bad input")); + assertThat(error.getType()).isEqualTo("java.lang.IllegalArgumentException"); + assertThat(error.getMessage()).isEqualTo("bad input"); + StackFrame top = error.getStackTrace().get(0); + assertThat(top.getDeclaringNamespace()).isEqualTo("com.exceptionless.internal"); + assertThat(top.getDeclaringType()).isEqualTo("ErrorConverterTest"); + assertThat(top.getName()).isEqualTo("mapsTypeMessageAndFrames"); + assertThat(top.getFileName()).isEqualTo("ErrorConverterTest.java"); + assertThat(top.getLineNumber()).isPositive(); + } + + @Test + void followsTheCauseChain() { + IOException root = new IOException("disk full"); + UncheckedIOException middle = new UncheckedIOException("write failed", root); + IllegalStateException outer = new IllegalStateException("save failed", middle); + ErrorInfo error = convert(outer); + assertThat(error.getType()).isEqualTo("java.lang.IllegalStateException"); + assertThat(error.getInner().getType()).isEqualTo("java.io.UncheckedIOException"); + assertThat(error.getInner().getInner().getType()).isEqualTo("java.io.IOException"); + assertThat(error.getRootCause().getMessage()).isEqualTo("disk full"); + // Causes keep at least their top frame even when every frame is shared with the parent. + assertThat(error.getInner().getStackTrace()).isNotEmpty(); + assertThat(error.getInner().getInner().getStackTrace()).isNotEmpty(); + } + + @Test + void trimsFramesSharedWithTheEnclosingTrace() { + Exception cause = deeper(() -> new Exception("deep")); + RuntimeException outer = new RuntimeException("outer", cause); + ErrorInfo error = convert(outer); + List causeFrames = error.getInner().getStackTrace(); + assertThat(causeFrames).extracting(StackFrame::getName).contains("deeper"); + assertThat(causeFrames.size()).isLessThan(outer.getCause().getStackTrace().length); + } + + private static Exception deeper(Supplier supplier) { + return supplier.get(); + } + + @Test + void stopsAtCircularCauses() { + RuntimeException a = new RuntimeException("a"); + RuntimeException b = new RuntimeException("b", a); + a.initCause(b); + ErrorInfo error = convert(a); + assertThat(error.getInner().getMessage()).isEqualTo("b"); + assertThat(error.getInner().getInner()).isNull(); + } + + @Test + void limitsCauseDepth() { + Throwable t = new RuntimeException("0"); + for (int i = 1; i < 50; i++) { + t = new RuntimeException(Integer.toString(i), t); + } + ErrorInfo error = convert(t); + int depth = 0; + while (error != null) { + depth++; + error = error.getInner(); + } + assertThat(depth).isEqualTo(ErrorConverter.MAX_CAUSES); + } + + @Test + void recordsSuppressedExceptions() { + Exception primary = new Exception("primary"); + primary.addSuppressed(new IllegalStateException("close failed")); + ErrorInfo error = convert(primary); + assertThat(error.getData().get("suppressed")) + .asInstanceOf(InstanceOfAssertFactories.list(ErrorInfo.class)) + .singleElement() + .satisfies(s -> assertThat(s.getMessage()).isEqualTo("close failed")); + } + + @Test + void addsCustomPropertiesAsExtendedDataWithExclusions() { + ErrorInfo error = convert(new PaymentException("declined", "order-7", null)); + assertThat(error.getData().get(KnownDataKeys.EXTENDED_DATA)) + .asInstanceOf(InstanceOfAssertFactories.map(String.class, Object.class)) + .containsEntry("orderId", "order-7") + .doesNotContainKeys( + "password", "stackTrace", "message", "cause", "localizedMessage", "suppressed"); + } + + @Test + void plainExceptionsHaveNoExtendedData() { + assertThat(convert(new RuntimeException("x")).getData()) + .doesNotContainKey(KnownDataKeys.EXTENDED_DATA); + } + + @Test + void handlesThrowablesWithBrokenAccessors() { + RuntimeException broken = + new RuntimeException() { + @Override + public String getMessage() { + throw new IllegalStateException("nope"); + } + + @Override + public StackTraceElement[] getStackTrace() { + throw new IllegalStateException("nope"); + } + }; + ErrorInfo error = convert(broken); + assertThat(error.getType()).startsWith("com.exceptionless.internal.ErrorConverterTest$"); + assertThat(error.getStackTrace()).isEmpty(); + } + + @Test + void capsFramesAndRecordsOmittedCount() { + StackOverflowError overflow = recurse(0); + ErrorInfo error = convert(overflow); + assertThat(error.getStackTrace()).hasSize(ErrorConverter.MAX_FRAMES); + assertThat((Integer) error.getData().get("frames_omitted")).isPositive(); + } + + private static StackOverflowError recurse(int depth) { + try { + return recurse(depth + 1); + } catch (StackOverflowError e) { + return e; + } + } + + @Test + void attachesModulesToTheOutermostErrorOnly() { + ModuleInfo module = new ModuleInfo(0, "lib", "1.0", false); + ErrorInfo error = + ErrorConverter.convert( + new RuntimeException("x", new RuntimeException("y")), name -> false, List.of(module)); + assertThat(error.getModules()).containsExactly(module); + assertThat(error.getInner().getModules()).isEmpty(); + } + + @Test + void marksNativeFrames() { + StackFrame frame = + ErrorConverter.toFrame(new StackTraceElement("java.lang.Thread", "sleep0", null, -2)); + assertThat(frame.getData()).containsEntry("is_native", true); + assertThat(frame.getLineNumber()).isNull(); + } + + @Test + void handlesClassesWithoutPackage() { + StackFrame frame = + ErrorConverter.toFrame(new StackTraceElement("Main", "main", "Main.java", 3)); + assertThat(frame.getDeclaringNamespace()).isNull(); + assertThat(frame.getDeclaringType()).isEqualTo("Main"); + } + + @ParameterizedTest + @CsvSource({ + "Foo$$Lambda$123/0x0000000800c0b840, Foo$$Lambda", + "Foo$$Lambda/0x000071b8c8003400, Foo$$Lambda", + "Foo$$Lambda$14, Foo$$Lambda", + "OrderService$$EnhancerBySpringCGLIB$$a1b2c3, OrderService$$EnhancerBySpringCGLIB", + "OrderService$$SpringCGLIB$$0, OrderService$$SpringCGLIB", + "Customer$HibernateProxy$AbC123, Customer$HibernateProxy", + "$Proxy42, $Proxy", + "GeneratedMethodAccessor17, GeneratedMethodAccessor", + "Outer$Inner, Outer$Inner", + "Plain, Plain", + }) + void normalizesGeneratedClassNames(String input, String expected) { + assertThat(ErrorConverter.normalizeTypeName(input)).isEqualTo(expected); + } + + @Test + void normalizesJdkProxyPackages() { + StackFrame frame = + ErrorConverter.toFrame(new StackTraceElement("jdk.proxy2.$Proxy12", "call", null, -1)); + assertThat(frame.getDeclaringNamespace()).isEqualTo("jdk.proxy"); + assertThat(frame.getDeclaringType()).isEqualTo("$Proxy"); + } + + @Test + void lambdaFramesAreStableAcrossRuns() { + Supplier a = () -> new RuntimeException("x"); + StackFrame frame = convert(a.get()).getStackTrace().get(0); + assertThat(frame.getName()).startsWith("lambda$"); + assertThat(frame.getDeclaringType()).isEqualTo("ErrorConverterTest"); + } + + @Test + void serializesToTheServerShape() { + String json = + JsonWriter.toJson( + convert(new IllegalStateException("boom", new RuntimeException("cause")))); + assertThat(json) + .contains("\"type\":\"java.lang.IllegalStateException\"") + .contains("\"stack_trace\":[") + .contains("\"declaring_namespace\":\"com.exceptionless.internal\"") + .contains("\"declaring_type\":\"ErrorConverterTest\"") + .contains("\"file_name\":\"ErrorConverterTest.java\"") + .contains("\"line_number\":") + .contains("\"inner\":{"); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/IdsTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/IdsTest.java new file mode 100644 index 0000000..d2ea27e --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/IdsTest.java @@ -0,0 +1,48 @@ +package com.exceptionless.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class IdsTest { + @Test + void referenceIdsAreValidAndUnique() { + Set ids = new HashSet<>(); + for (int i = 0; i < 10_000; i++) { + String id = Ids.newReferenceId(); + assertThat(Ids.isValidReferenceId(id)).isTrue(); + ids.add(id); + } + assertThat(ids).hasSize(10_000); + } + + @ParameterizedTest + @CsvSource( + value = { + "abcd1234, true", + "ABC-123-xyz, true", + "short, false", + "has space1, false", + "under_score, false", + "null, false", + }, + nullValues = "null") + void validatesReferenceIdsLikeTheServer(String id, boolean valid) { + assertThat(Ids.isValidReferenceId(id)).isEqualTo(valid); + } + + @ParameterizedTest + @CsvSource({"8, true", "100, true", "101, false"}) + void limitsReferenceIdLength(int length, boolean valid) { + assertThat(Ids.isValidReferenceId("a".repeat(length))).isEqualTo(valid); + } + + @Test + void guidsHaveNoDashes() { + assertThat(Ids.newGuid()).matches("[0-9a-f]{32}"); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/SettingsManagerTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/SettingsManagerTest.java new file mode 100644 index 0000000..2bc9edf --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/SettingsManagerTest.java @@ -0,0 +1,107 @@ +package com.exceptionless.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.TestClients; +import com.exceptionless.TestClients.CapturingSubmissionClient; +import com.exceptionless.storage.InMemoryKeyValueStorage; +import com.exceptionless.submission.SettingsResponse; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SettingsManagerTest { + private final ExceptionlessConfiguration config = TestClients.config(); + private final CapturingSubmissionClient submission = new CapturingSubmissionClient(); + private final InMemoryKeyValueStorage storage = new InMemoryKeyValueStorage(); + private final SettingsManager manager = new SettingsManager(config, submission, storage); + + @Test + void appliesAndPersistsNewSettings() { + submission.settingsResponse = new SettingsResponse.Updated(Map.of("@@log:*", "info"), 4); + manager.updateSettings(); + assertThat(config.getSettings().getString("@@log:*")).isEqualTo("info"); + assertThat(manager.getVersion()).isEqualTo(4); + assertThat(storage.read(SettingsManager.STORAGE_KEY)) + .contains("\"version\":4") + .contains("@@log:*"); + assertThat(submission.settingsRequests).containsExactly(0); + } + + @Test + void sendsCurrentVersionAndKeepsSettingsWhenNotModified() { + submission.settingsResponse = new SettingsResponse.Updated(Map.of("a", "1"), 2); + manager.updateSettings(); + submission.settingsResponse = new SettingsResponse.NotModified(); + manager.updateSettings(); + assertThat(submission.settingsRequests).containsExactly(0, 2); + assertThat(config.getSettings().getString("a")).isEqualTo("1"); + } + + @Test + void removedServerSettingsAreDropped() { + submission.settingsResponse = new SettingsResponse.Updated(Map.of("a", "1", "b", "2"), 1); + manager.updateSettings(); + submission.settingsResponse = new SettingsResponse.Updated(Map.of("a", "1"), 2); + manager.updateSettings(); + assertThat(config.getSettings().getString("b")).isNull(); + } + + @Test + void localSettingsSurviveButServerWins() { + config.setting("@@log:*", "error").setting("local", "yes"); + submission.settingsResponse = new SettingsResponse.Updated(Map.of("@@log:*", "debug"), 1); + manager.updateSettings(); + assertThat(config.getSettings().getString("@@log:*")).isEqualTo("debug"); + assertThat(config.getSettings().getString("local")).isEqualTo("yes"); + } + + @Test + void failuresKeepCurrentSettings() { + submission.settingsResponse = new SettingsResponse.Updated(Map.of("a", "1"), 1); + manager.updateSettings(); + submission.settingsResponse = new SettingsResponse.Failed("boom"); + manager.updateSettings(); + assertThat(config.getSettings().getString("a")).isEqualTo("1"); + assertThat(manager.getVersion()).isEqualTo(1); + } + + @Test + void loadsSavedSettings() { + storage.write( + SettingsManager.STORAGE_KEY, + "{\"version\":9,\"settings\":{\"@@DataExclusions\":\"*secret*\"}}"); + assertThat(manager.loadSavedSettings()).isTrue(); + assertThat(manager.getVersion()).isEqualTo(9); + assertThat(config.getDataExclusions()).contains("*secret*"); + } + + @Test + void ignoresCorruptSavedSettings() { + storage.write(SettingsManager.STORAGE_KEY, "{not json"); + assertThat(manager.loadSavedSettings()).isFalse(); + assertThat( + new SettingsManager(config, submission, new InMemoryKeyValueStorage()) + .loadSavedSettings()) + .isFalse(); + } + + @Test + void checkVersionOnlyUpdatesWhenNewer() { + submission.settingsResponse = new SettingsResponse.Updated(Map.of(), 3); + manager.checkVersion(null); + manager.checkVersion(0); + assertThat(submission.settingsRequests).isEmpty(); + manager.checkVersion(3); + assertThat(submission.settingsRequests).containsExactly(0); + manager.checkVersion(3); + assertThat(submission.settingsRequests).hasSize(1); + } + + @Test + void doesNothingWhenDisabled() { + config.enabled(false); + manager.updateSettings(); + assertThat(submission.settingsRequests).isEmpty(); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/WildcardTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/WildcardTest.java new file mode 100644 index 0000000..bee85b6 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/WildcardTest.java @@ -0,0 +1,65 @@ +package com.exceptionless.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class WildcardTest { + @ParameterizedTest + @CsvSource({ + "password, password, true", + "Password, password, true", + "password, PASSWORD, true", + "userPassword, *password, true", + "passwordHash, password*, true", + "myPasswordHash, *password*, true", + "anything, *, true", + "pass, password, false", + "userPassword, password*, false", + "passwordHash, *password, false", + "a.b(c)[d]+, a.b(c)[d]+, true", + "x.y, x?y, false", + "com.example.Foo, com.example.*, true", + "com.other.Foo, com.example.*, false", + }) + void matchesLikeTheOtherClients(String value, String pattern, boolean expected) { + assertThat(Wildcard.matches(value, pattern)).isEqualTo(expected); + assertThat(Wildcard.compile(pattern).test(value)).isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource( + value = {"null, *", "value, null", "value, ''", "value, ' '"}, + nullValues = "null") + void nullOrBlankNeverMatches(String value, String pattern) { + assertThat(Wildcard.matches(value, pattern)).isFalse(); + } + + @Test + void matchesAny() { + assertThat(Wildcard.matchesAny("Googlebot/2.1", List.of("*crawler*", "*bot*"))).isTrue(); + assertThat(Wildcard.matchesAny("Mozilla/5.0", List.of("*crawler*", "*bot*"))).isFalse(); + assertThat(Wildcard.matchesAny("x", List.of())).isFalse(); + } + + @Test + void compiledPatternsIgnoreSurroundingWhiteSpace() { + Wildcard pattern = Wildcard.compile(" *Secret* "); + assertThat(pattern.test(" mySECRETvalue ")).isTrue(); + assertThat(pattern.test(null)).isFalse(); + assertThat(Wildcard.compile(null).test("x")).isFalse(); + } + + @Test + void exclusionsMatchAnyPattern() { + Exclusions exclusions = Exclusions.of(List.of("*password*", "token")); + assertThat(exclusions.test("UserPassword")).isTrue(); + assertThat(exclusions.test("TOKEN")).isTrue(); + assertThat(exclusions.test("tokens")).isFalse(); + assertThat(exclusions.plus(Exclusions.of(List.of("tokens"))).test("tokens")).isTrue(); + assertThat(Exclusions.none().test("anything")).isFalse(); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/json/JsonParserTest.java b/exceptionless-core/src/test/java/com/exceptionless/json/JsonParserTest.java new file mode 100644 index 0000000..fcd6484 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/json/JsonParserTest.java @@ -0,0 +1,150 @@ +package com.exceptionless.json; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; + +class JsonParserTest { + @Test + void parsesSettingsResponse() { + Map json = + JsonParser.parseObject( + "{\"version\":7,\"settings\":{\"@@log:*\":\"Info\",\"@@DataExclusions\":\"*password*\"}}"); + assertThat(json.get("version")).isEqualTo(7L); + assertThat(json.get("settings")) + .isEqualTo(Map.of("@@log:*", "Info", "@@DataExclusions", "*password*")); + } + + @Test + void parsesAllValueTypes() { + Object value = JsonParser.parse(" [1, -2.5, 1e3, true, false, null, \"s\", {}, []] "); + assertThat(value) + .isEqualTo( + Arrays.asList( + 1L, + new BigDecimal("-2.5"), + new BigDecimal("1e3"), + true, + false, + null, + "s", + Map.of(), + List.of())); + } + + @Test + void parsesEscapes() { + assertThat(JsonParser.parse("\"a\\\"b\\\\c\\/d\\n\\u00fc\"")).isEqualTo("a\"b\\c/d\nü"); + } + + @Test + void parsesHugeIntegersAsBigDecimal() { + assertThat(JsonParser.parse("123456789012345678901234567890")) + .isEqualTo(new BigDecimal("123456789012345678901234567890")); + } + + static List invalidJson() { + return List.of( + "", + " ", + "{", + "[1,]", + "{\"a\":1,}", + "{\"a\" 1}", + "tru", + "nul", + "\"unterminated", + "{} extra", + "{'a':1}", + "\"\\x\"", + "[\"\u0001\"]", + "\"\\u12\"", + "\"\\u+123\"", + "\"\\u-123\"", + // Numbers outside the RFC 8259 grammar. + "01", + "-01", + "00", + "1.", + ".5", + "-.5", + "-", + "+1", + "1e", + "1e+", + "1.e5", + "1.5.5", + "1-2", + "0x10", + "NaN", + "Infinity", + // A byte order mark is only allowed at the very start. + "[1,\uFEFF2]", + " \uFEFF{}", + "{}\uFEFF", + // Only space, tab, CR and LF are white space. + "\u00A0{}", + "{}\u000B"); + } + + @ParameterizedTest + @MethodSource("invalidJson") + void rejectsInvalidJson(String invalid) { + assertThatThrownBy(() -> JsonParser.parse(invalid)) + .isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "0 | 0", + "-0 | 0", + "10 | 10", + "-12 | -12", + "0.5 | 0.5", + "-0.5 | -0.5", + "1e3 | 1E+3", + "1E-2 | 0.01", + "2.5e+2 | 2.5E+2", + }) + void parsesValidNumbers(String json, String expected) { + Object parsed = JsonParser.parse(json); + if (parsed instanceof Long value) { + assertThat(value).isEqualTo(Long.parseLong(expected)); + } else { + assertThat(parsed).isInstanceOf(BigDecimal.class); + assertThat((BigDecimal) parsed).isEqualByComparingTo(expected); + } + } + + @Test + void ignoresALeadingByteOrderMark() { + assertThat(JsonParser.parse("\uFEFF {\"a\":1}")).isEqualTo(Map.of("a", 1L)); + } + + @Test + void parsesUnicodeEscapesInEitherCase() { + assertThat(JsonParser.parse("\"\\u00E9\\u00e9\"")).isEqualTo("\u00e9\u00e9"); + } + + @Test + void rejectsDeepNesting() { + String deep = "[".repeat(1000) + "]".repeat(1000); + assertThatThrownBy(() -> JsonParser.parse(deep)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void parseObjectRequiresObject() { + assertThatThrownBy(() -> JsonParser.parseObject("[]")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/json/JsonWriterTest.java b/exceptionless-core/src/test/java/com/exceptionless/json/JsonWriterTest.java new file mode 100644 index 0000000..e3f3afa --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/json/JsonWriterTest.java @@ -0,0 +1,124 @@ +package com.exceptionless.json; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.internal.DataNormalizer; +import com.exceptionless.internal.Exclusions; +import java.math.BigDecimal; +import java.nio.file.Path; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class JsonWriterTest { + @Test + void writesPrimitivesAndContainers() { + Map map = new LinkedHashMap<>(); + map.put("s", "text"); + map.put("i", 42); + map.put("l", 9_000_000_000L); + map.put("d", 1.5); + map.put("whole", 2.0); + map.put("b", true); + map.put("n", null); + map.put("list", List.of(1, "two")); + map.put("arr", new int[] {1, 2}); + map.put("nested", Map.of("k", "v")); + assertThat(JsonWriter.toJson(map)) + .isEqualTo( + "{\"s\":\"text\",\"i\":42,\"l\":9000000000,\"d\":1.5,\"whole\":2,\"b\":true,\"n\":null," + + "\"list\":[1,\"two\"],\"arr\":[1,2],\"nested\":{\"k\":\"v\"}}"); + } + + @Test + void escapesStrings() { + assertThat(JsonWriter.toJson("quote\" back\\ nl\n tab\t ctl\u0001 sep\u2028")) + .isEqualTo("\"quote\\\" back\\\\ nl\\n tab\\t ctl\\u0001 sep\\u2028\""); + } + + @Test + void keepsSurrogatePairsAndReplacesLoneSurrogates() { + assertThat(JsonWriter.toJson("😀")).isEqualTo("\"😀\""); + assertThat(JsonWriter.toJson("a\ud800b")).isEqualTo("\"a\ufffdb\""); + } + + @Test + void writesNonFiniteNumbersAsNull() { + assertThat(JsonWriter.toJson(List.of(Double.NaN, Double.POSITIVE_INFINITY, Float.NaN))) + .isEqualTo("[null,null,null]"); + } + + @Test + void writesBigDecimalsWithoutExponent() { + assertThat(JsonWriter.toJson(new BigDecimal("1E+3"))).isEqualTo("1000"); + assertThat(JsonWriter.toJson(new BigDecimal("0.000001"))).isEqualTo("0.000001"); + } + + @Test + void writesDatesAsIso8601() { + OffsetDateTime date = OffsetDateTime.of(2026, 1, 2, 3, 4, 5, 6_000_000, ZoneOffset.ofHours(-5)); + assertThat(JsonWriter.toJson(date)).isEqualTo("\"2026-01-02T03:04:05.006-05:00\""); + assertThat(JsonWriter.toJson(Instant.parse("2026-01-02T03:04:05Z"))) + .isEqualTo("\"2026-01-02T03:04:05Z\""); + } + + @Test + void writesOtherTypesWithToString() { + UUID id = UUID.fromString("00000000-0000-0000-0000-000000000001"); + assertThat(JsonWriter.toJson(id)).isEqualTo("\"00000000-0000-0000-0000-000000000001\""); + assertThat(JsonWriter.toJson(Optional.of("x"))).isEqualTo("\"x\""); + assertThat(JsonWriter.toJson(Optional.empty())).isEqualTo("null"); + assertThat(JsonWriter.toJson(Thread.State.NEW)).isEqualTo("\"NEW\""); + } + + @Test + void writesSimpleValuesLikeTheDataNormalizer() { + Path path = Path.of("app.log"); // a Path is an Iterable of Paths + OffsetDateTime date = OffsetDateTime.of(2026, 5, 1, 10, 7, 0, 0, ZoneOffset.UTC); + List values = List.of(path, date, String.class, Thread.State.NEW); + assertThat(JsonWriter.toJson(values)) + .isEqualTo(JsonWriter.toJson(DataNormalizer.normalize(values, 5, Exclusions.none()))) + .isEqualTo("[\"app.log\",\"2026-05-01T10:07:00Z\",\"java.lang.String\",\"NEW\"]"); + } + + @Test + void fieldHelpersSkipNullAndEmptyValues() { + JsonWriter writer = new JsonWriter(); + writer + .beginObject() + .field("a", null) + .fieldIfNotEmpty("b", "") + .fieldIfNotEmpty("c", " ") + .fieldIfNotEmpty("d", Map.of()) + .fieldIfNotEmpty("e", List.of()) + .field("f", 0) + .endObject(); + assertThat(writer.toString()).isEqualTo("{\"f\":0}"); + } + + @Test + void limitsNestingDepth() { + Object nested = "leaf"; + for (int i = 0; i < 100; i++) { + nested = List.of(nested); + } + String json = JsonWriter.toJson(nested); + assertThat(json).contains("\"...\""); + assertThat(JsonParser.parse(json)).isNotNull(); + } + + @Test + void roundTripsThroughParser() { + Map map = new LinkedHashMap<>(); + map.put("text", "line1\nline2 \"q\" ü 😀"); + map.put("number", 12L); + map.put("list", List.of(true, false)); + assertThat(JsonParser.parse(JsonWriter.toJson(map))).isEqualTo(map); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/logging/LogEventSupportTest.java b/exceptionless-core/src/test/java/com/exceptionless/logging/LogEventSupportTest.java new file mode 100644 index 0000000..2e3905e --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/logging/LogEventSupportTest.java @@ -0,0 +1,288 @@ +package com.exceptionless.logging; + +import static com.exceptionless.TestClients.data; +import static com.exceptionless.TestClients.error; +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.LogLevel; +import com.exceptionless.TestClients; +import com.exceptionless.TestClients.CapturingSubmissionClient; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class LogEventSupportTest { + private final CapturingSubmissionClient server = new CapturingSubmissionClient(); + private final List reported = new CopyOnWriteArrayList<>(); + private final LogEventSupport support = + new LogEventSupport("test", (message, error) -> reported.add(message)); + private ExceptionlessClient client; + + @AfterEach + void tearDown() { + support.close(); + if (client != null) { + client.close(); + } + Exceptionless.setDefault(null); + } + + private Throwable log(String loggerName, LogLevel level, String message) { + return support.submit( + loggerName, + "main", + level, + target -> support.createEvent(target, loggerName, message, level, null, "main")); + } + + private List messages() { + return server.flush(client).stream().map(e -> e.get("message")).toList(); + } + + @ParameterizedTest + @CsvSource( + nullValues = "null", + value = { + "com.exceptionless, main, true", + "com.exceptionless.queue.DefaultEventQueue, main, true", + "com.exceptionlessfan.App, main, false", + "com.example.App, exceptionless-1, true", + "com.example.App, exceptionless, false", + "null, null, false", + "null, exceptionless-2, true" + }) + void recognizesTheClientsOwnLoggersAndThreads( + String loggerName, String threadName, boolean internal) { + assertThat(LogEventSupport.isInternal(loggerName, threadName)).isEqualTo(internal); + } + + @Test + void sendsNothingUntilStartedOrAfterClose() { + client = server.newClient(); + support.setClient(client); + + log("com.example.App", LogLevel.ERROR, "before start"); + support.start(); + log("com.example.App", LogLevel.ERROR, "started"); + support.close(); + log("com.example.App", LogLevel.ERROR, "after close"); + + assertThat(messages()).containsExactly("started"); + assertThat(client.isStarted()).as("an explicit client is never closed").isTrue(); + } + + @Test + void appliesMinimumLevelAndProjectSettings() { + client = server.newClient(); + client.getConfiguration().applyServerSettings(Map.of("@@log:com.example.Chatty", "debug")); + support.setClient(client); + support.start(); + assertThat(support.getMinimumLevel()).isEqualTo(LogLevel.WARN); + + log("com.example.App", LogLevel.INFO, "info"); + log("com.example.App", LogLevel.WARN, "warn"); + log("com.example.Chatty", LogLevel.DEBUG, "chatty debug"); + assertThat(support.setMinimumLevel("information")).isTrue(); + assertThat(support.setMinimumLevel("bogus")).isFalse(); + log("com.example.App", LogLevel.INFO, "info after lowering"); + + assertThat(support.getMinimumLevel()).isEqualTo(LogLevel.INFO); + assertThat(messages()).containsExactly("warn", "chatty debug", "info after lowering"); + } + + @Test + void createsLogAndErrorEvents() { + client = server.newClient(); + support.setClient(client); + support.start(); + + support.submit( + "com.example.Orders", + "worker-1", + LogLevel.WARN, + target -> + support.createEvent( + target, "com.example.Orders", "Low stock", LogLevel.WARN, null, "worker-1")); + support.submit( + "com.example.Orders", + "worker-1", + LogLevel.ERROR, + target -> + support.createEvent( + target, + "com.example.Orders", + null, + LogLevel.ERROR, + new IllegalStateException("declined"), + "worker-1")); + + List> events = server.flush(client); + assertThat(events).hasSize(2); + assertThat(events.get(0)) + .containsEntry("type", "log") + .containsEntry("source", "com.example.Orders") + .containsEntry("message", "Low stock"); + assertThat(data(events.get(0))) + .containsEntry("@level", "warn") + .containsEntry("@submission_method", "test") + .containsEntry("thread", "worker-1"); + assertThat(events.get(1)) + .containsEntry("type", "error") + .containsEntry("source", "com.example.Orders"); + assertThat(error(events.get(1))).containsEntry("message", "declined"); + assertThat(data(events.get(1))) + .containsEntry("@level", "error") + .containsEntry("@submission_method", "test"); + } + + @Test + void dropsEventsLoggedWhileSubmitting() { + client = server.newClient(); + support.setClient(client); + support.start(); + AtomicInteger nested = new AtomicInteger(); + + support.submit( + "com.example.App", + "main", + LogLevel.ERROR, + target -> { + assertThat(log("com.example.App", LogLevel.ERROR, "nested")).isNull(); + nested.incrementAndGet(); + return support.createEvent( + target, "com.example.App", "outer", LogLevel.ERROR, null, null); + }); + + assertThat(nested).hasValue(1); + assertThat(messages()).containsExactly("outer"); + } + + @Test + void usesTheDefaultClientOnlyOnceStarted() { + support.start(); + log("com.example.App", LogLevel.ERROR, "no default client"); + assertThat(Exceptionless.hasDefault()).isFalse(); + + client = new ExceptionlessClient(server.newConfiguration()); + Exceptionless.setDefault(client); + log("com.example.App", LogLevel.ERROR, "not started"); + assertThat(client.isStarted()).isFalse(); + client.startup(); + log("com.example.App", LogLevel.ERROR, "started"); + + assertThat(messages()).containsExactly("started"); + assertThat(support.getClient()).isNull(); + } + + @Test + void anExplicitClientTakesPrecedenceOverTheApiKey() { + client = server.newClient(); + support.setApiKey(TestClients.API_KEY); + support.setClient(client); + support.start(); + + assertThat(support.resolveClient()).isSameAs(client); + assertThat(support.getClient()).isSameAs(client); + } + + @Test + void ownsTheClientForItsApiKey() { + support.setApiKey(" " + TestClients.API_KEY + " "); + support.setServerUrl("http://127.0.0.1:9/"); + assertThat(support.getApiKey()).isEqualTo(TestClients.API_KEY); + assertThat(support.resolveClient()).as("not created before start").isNull(); + support.start(); + + ExceptionlessClient owned = support.resolveClient(); + assertThat(owned).isNotNull().isSameAs(support.getClient()); + assertThat(owned.isStarted()).isTrue(); + ExceptionlessConfiguration config = owned.getConfiguration(); + assertThat(config.getApiKey()).isEqualTo(TestClients.API_KEY); + assertThat(config.getServerUrl()).isEqualTo("http://127.0.0.1:9"); + assertThat(config.isCaptureUncaughtExceptions()).isFalse(); + + support.setApiKey("AnotherApiKey1234567890"); + assertThat(owned.isStarted()).as("changing the key closes the old client").isFalse(); + ExceptionlessClient replaced = support.resolveClient(); + assertThat(replaced).isNotNull().isNotSameAs(owned); + assertThat(replaced.getConfiguration().getApiKey()).isEqualTo("AnotherApiKey1234567890"); + + support.close(Duration.ofMillis(100)); + assertThat(replaced.isStarted()).isFalse(); + assertThat(support.getClient()).isNull(); + assertThat(reported).isEmpty(); + } + + @Test + void reportsAFailureToCreateTheClientOnceUntilRestarted() { + support.setApiKey(TestClients.API_KEY); + support.setServerUrl("ftp://not-http"); + support.start(); + + assertThat(support.resolveClient()).isNull(); + assertThat(support.resolveClient()).isNull(); + assertThat(reported).containsExactly("Unable to create the Exceptionless client"); + + support.start(); + assertThat(support.resolveClient()).isNull(); + assertThat(reported).hasSize(2); + + support.setServerUrl("http://127.0.0.1:9"); + assertThat(support.resolveClient()).isNotNull(); + } + + @Test + void reportsTheFirstSubmitFailureAndReturnsEveryOne() { + client = server.newClient(); + support.setClient(client); + support.start(); + IllegalStateException boom = new IllegalStateException("boom"); + + LogEventSupport.EventFactory failing = + target -> { + throw boom; + }; + assertThat(support.submit("com.example.App", "main", LogLevel.ERROR, failing)).isSameAs(boom); + assertThat(support.submit("com.example.App", "main", LogLevel.ERROR, failing)).isSameAs(boom); + assertThat(reported).containsExactly("Unable to send a log event to Exceptionless"); + + support.start(); + support.submit("com.example.App", "main", LogLevel.ERROR, failing); + assertThat(reported).hasSize(2); + } + + @Test + void sendsNothingWhileTheClientIsDisabled() { + client = + new ExceptionlessClient( + new ExceptionlessConfiguration() + .submissionClient(server) + .captureUncaughtExceptions(false) + .registerShutdownHook(false)) + .startup(); + support.setClient(client); + support.start(); + AtomicInteger created = new AtomicInteger(); + + support.submit( + "com.example.App", + "main", + LogLevel.FATAL, + target -> { + created.incrementAndGet(); + return target.createLog("never"); + }); + + assertThat(created).hasValue(0); + assertThat(server.events()).isEmpty(); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/models/ModelsTest.java b/exceptionless-core/src/test/java/com/exceptionless/models/ModelsTest.java new file mode 100644 index 0000000..eaaac2e --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/models/ModelsTest.java @@ -0,0 +1,79 @@ +package com.exceptionless.models; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ModelsTest { + @Test + void tagsAreStrippedAndOnlyChangedThroughTheEvent() { + Event event = new Event(); + event.addTags(" a ", "", " ", "b"); + assertThat(event.getTags()).containsExactly("a", "b"); + assertThat(event.hasTag(" a")).isTrue(); + assertThat(event.hasTag("c")).isFalse(); + assertThatThrownBy(() -> event.getTags().add("c")) + .isInstanceOf(UnsupportedOperationException.class); + event.removeTags("a ", "missing"); + assertThat(event.getTags()).containsExactly("b"); + } + + @Test + void modelSettersFollowJavaBeanConventions() { + for (Class type : + List.of( + Event.class, + ErrorInfo.class, + StackFrame.class, + RequestInfo.class, + EnvironmentInfo.class)) { + assertThat(Arrays.stream(type.getMethods())) + .as(type.getSimpleName()) + .filteredOn(m -> m.getName().startsWith("set") || m.getName().startsWith("add")) + .isNotEmpty() + .allSatisfy(m -> assertThat(m.getReturnType()).as(m.toString()).isEqualTo(void.class)); + } + } + + @Test + void toStringHandlesMissingValues() { + StackFrame frame = new StackFrame(); + frame.setDeclaringType("Service"); + frame.setName("run"); + frame.setFileName("Service.java"); + assertThat(frame).hasToString("Service.run(Service.java)"); + frame.setLineNumber(12); + assertThat(frame).hasToString("Service.run(Service.java:12)"); + + ErrorInfo error = new ErrorInfo(); + error.setMessage("boom"); + assertThat(error).hasToString(": boom"); + error.setType("java.lang.IllegalStateException"); + assertThat(error).hasToString("java.lang.IllegalStateException: boom"); + } + + @Test + void recordsCopyTheirMaps() { + Map data = new LinkedHashMap<>(); + data.put("b", 1); + data.put("a", 2); + UserInfo user = new UserInfo("id", "name", data); + data.put("c", 3); + assertThat(user.data()).containsExactly(Map.entry("b", 1), Map.entry("a", 2)); + assertThatThrownBy(() -> user.data().put("d", 4)) + .isInstanceOf(UnsupportedOperationException.class); + assertThat(new UserInfo("id").data()).isNull(); + assertThat(new UserDescription("a@b.c", "d", new HashMap<>(data)).data()).hasSize(3); + + Map signature = new HashMap<>(Map.of("k", "v")); + ManualStackingInfo stacking = new ManualStackingInfo("t", signature); + signature.clear(); + assertThat(stacking.signatureData()).containsExactly(Map.entry("k", "v")); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java b/exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java new file mode 100644 index 0000000..85a1e3e --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java @@ -0,0 +1,170 @@ +package com.exceptionless.models; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.TestClients; +import com.exceptionless.TestClients.CapturingSubmissionClient; +import com.exceptionless.TestJson; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** + * Guards the wire format: every property the client writes must exist on the server model + * (Exceptionless.Core/Models, serialized with snake_case naming). The server silently ignores + * unknown properties, so a typo here would drop data without any error. + */ +class WireContractTest { + private static final Set EVENT = + Set.of( + "type", + "source", + "date", + "tags", + "message", + "geo", + "value", + "count", + "data", + "reference_id"); + private static final Set ERROR = + Set.of("message", "type", "code", "data", "inner", "stack_trace", "target_method", "modules"); + private static final Set STACK_FRAME = + Set.of( + "file_name", + "line_number", + "column", + "is_signature_target", + "declaring_namespace", + "declaring_type", + "name", + "module_id", + "data", + "generic_arguments", + "parameters"); + private static final Set MODULE = + Set.of("module_id", "name", "version", "is_entry", "created_date", "modified_date", "data"); + private static final Set REQUEST = + Set.of( + "user_agent", + "http_method", + "is_secure", + "host", + "port", + "path", + "referrer", + "client_ip_address", + "headers", + "cookies", + "post_data", + "query_string", + "data"); + private static final Set ENVIRONMENT = + Set.of( + "processor_count", + "total_physical_memory", + "available_physical_memory", + "command_line", + "process_name", + "process_id", + "process_memory_size", + "thread_name", + "thread_id", + "architecture", + "o_s_name", + "o_s_version", + "ip_address", + "machine_name", + "install_id", + "runtime_version", + "data"); + private static final Set USER = Set.of("identity", "name", "data"); + private static final Set USER_DESCRIPTION = + Set.of("email_address", "description", "data"); + private static final Set MANUAL_STACKING = Set.of("title", "signature_data"); + + @Test + void everyPropertyExistsOnTheServerModel() { + CapturingSubmissionClient submission = new CapturingSubmissionClient(); + try (ExceptionlessClient client = + new ExceptionlessClient( + TestClients.config().includeModules(true).submissionClient(submission))) { + Map> headers = new LinkedHashMap<>(); + headers.put("Accept", List.of("text/html")); + RequestInfo request = new RequestInfo(); + request.setHttpMethod("GET"); + request.setSecure(true); + request.setHost("h"); + request.setPort(443); + request.setPath("/p"); + request.setReferrer("r"); + request.setUserAgent("ua"); + request.setClientIpAddress("1.2.3.4"); + request.setHeaders(headers); + request.setCookies(Map.of("c", "v")); + request.setQueryString(Map.of("q", "v")); + request.setPostData(Map.of("f", "v")); + client + .createException(new IllegalStateException("outer", new RuntimeException("inner"))) + .setRequestInfo(request) + .setUserIdentity("id", "name") + .setUserDescription("a@b.c", "d") + .setManualStackingKey("key", "title") + .setGeo(1, 2) + .setValue(3) + .setCount(2) + .addTags("t") + .setMessage("m") + .setSource("s") + .submit(); + client.flush(Duration.ofSeconds(5)); + } + Map event = submission.events().get(0); + assertThat(event.keySet()).isSubsetOf(EVENT); + Map data = TestJson.object(event.get("data")); + + Map error = TestJson.object(data.get("@error")); + checkError(error); + assertThat(TestJson.objects(error.get("modules"))) + .isNotEmpty() + .allSatisfy(m -> assertThat(m.keySet()).isSubsetOf(MODULE)); + + Map request = TestJson.object(data.get("@request")); + Map environment = TestJson.object(data.get("@environment")); + assertThat(request.keySet()) + .isSubsetOf(REQUEST) + .contains("is_secure", "client_ip_address", "query_string", "http_method"); + assertThat(environment.keySet()) + .isSubsetOf(ENVIRONMENT) + .contains("o_s_name", "o_s_version", "process_id"); + assertThat(TestJson.object(data.get("@user")).keySet()).isSubsetOf(USER); + assertThat(TestJson.object(data.get("@user_description")).keySet()) + .isSubsetOf(USER_DESCRIPTION); + assertThat(TestJson.object(data.get("@stack")).keySet()).isSubsetOf(MANUAL_STACKING); + + // Types the server is strict about. + assertThat(request.get("headers")).isEqualTo(Map.of("Accept", List.of("text/html"))); + assertThat(request.get("cookies")).isEqualTo(Map.of("c", "v")); + assertThat(request.get("query_string")).isEqualTo(Map.of("q", "v")); + assertThat(environment.get("process_id")).isInstanceOf(String.class); + assertThat(environment.get("thread_id")).isInstanceOf(String.class); + assertThat(event.get("count")).isEqualTo(2L); + assertThat(event.get("date")) + .asString() + .matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?([+-]\\d{2}:\\d{2}|Z)"); + } + + private static void checkError(Map error) { + assertThat(error.keySet()).isSubsetOf(ERROR); + assertThat(TestJson.objects(error.get("stack_trace"))) + .isNotEmpty() + .allSatisfy(f -> assertThat(f.keySet()).isSubsetOf(STACK_FRAME)); + if (error.get("inner") != null) { + checkError(TestJson.object(error.get("inner"))); + } + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/plugins/DefaultPluginsTest.java b/exceptionless-core/src/test/java/com/exceptionless/plugins/DefaultPluginsTest.java new file mode 100644 index 0000000..b4d9930 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/DefaultPluginsTest.java @@ -0,0 +1,348 @@ +package com.exceptionless.plugins; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.TestClients; +import com.exceptionless.json.JsonWriter; +import com.exceptionless.models.Event; +import com.exceptionless.models.RequestInfo; +import com.exceptionless.models.UserInfo; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class DefaultPluginsTest { + private final ExceptionlessConfiguration config = TestClients.config(); + private final ExceptionlessClient client = PluginTestSupport.client(config); + + @AfterEach + void close() { + client.close(); + } + + @Test + void defaultPluginsRunInPriorityOrder() { + List names = config.getPlugins().stream().map(EventPlugin::getName).toList(); + assertThat(names) + .containsExactly( + "ConfigurationDefaultsPlugin", + "ErrorPlugin", + "ReferenceIdPlugin", + "EventExclusionPlugin", + "EnvironmentInfoPlugin", + "RequestInfoPlugin", + "IgnoreUserAgentPlugin", + "VersionPlugin", + "SubmissionMethodPlugin", + "CancelSessionsWithNoUserPlugin", + "DuplicateCheckerPlugin"); + } + + @Test + void customPluginsCanBeAddedReplacedAndRemoved() { + config.addPlugin("first", -1, ctx -> {}); + assertThat(config.getPlugins().get(0).getName()).isEqualTo("first"); + config.addPlugin("first", 5000, ctx -> {}); + assertThat(config.getPlugins().get(config.getPlugins().size() - 1).getName()) + .isEqualTo("first"); + config.removePlugin("first").removePlugin(DuplicateCheckerPlugin.class); + assertThat(config.hasPlugin("first")).isFalse(); + assertThat(config.hasPlugin("DuplicateCheckerPlugin")).isFalse(); + } + + @Test + void configurationDefaultsAddTagsAndDataWithoutOverwriting() { + config + .addDefaultTags("backend", " ") + .addDefaultData("tenant", "acme") + .addDefaultData("password", "x") + .addDefaultData("existing", "default"); + config.addDataExclusions("password"); + Event event = new Event(); + event.getData().put("existing", "mine"); + PluginTestSupport.run(new ConfigurationDefaultsPlugin(), client, event); + assertThat(event.getTags()).containsExactly("backend"); + assertThat(event.getData()) + .containsEntry("tenant", "acme") + .containsEntry("existing", "mine") + .doesNotContainKey("password"); + } + + @Test + void configurationDefaultsNormalizeObjects() { + config.addDefaultData("info", Map.of("nested", List.of(1, 2))); + config.userIdentity("user-1", "Ann"); + Event event = new Event(); + PluginTestSupport.run(new ConfigurationDefaultsPlugin(), client, event); + assertThat(event.getData().get("info")).isEqualTo(Map.of("nested", List.of(1, 2))); + assertThat(event.getUserInfo()).isEqualTo(new UserInfo("user-1", "Ann")); + } + + @Test + void errorPluginReportsEachExceptionOnce() { + ErrorPlugin plugin = new ErrorPlugin(); + RuntimeException error = new RuntimeException("once"); + Event first = new Event(); + EventPluginContext ctx = + PluginTestSupport.run(plugin, client, first, new ContextData().setException(error)); + assertThat(ctx.isCancelled()).isFalse(); + assertThat(first.getType()).isEqualTo("error"); + assertThat(first.getError().getMessage()).isEqualTo("once"); + assertThat( + PluginTestSupport.run( + plugin, client, new Event(), new ContextData().setException(error)) + .isCancelled()) + .isTrue(); + assertThat( + PluginTestSupport.run( + plugin, + client, + new Event(), + new ContextData().setException(new RuntimeException("once"))) + .isCancelled()) + .isFalse(); + } + + @Test + void errorPluginTreatsWrappersAndCausesOfReportedExceptionsAsDuplicates() { + ErrorPlugin plugin = new ErrorPlugin(); + RuntimeException cause = new RuntimeException("root"); + assertThat( + PluginTestSupport.run( + plugin, client, new Event(), new ContextData().setException(cause)) + .isCancelled()) + .isFalse(); + RuntimeException wrapper = new RuntimeException("wrapped", cause); + assertThat( + PluginTestSupport.run( + plugin, client, new Event(), new ContextData().setException(wrapper)) + .isCancelled()) + .isTrue(); + + RuntimeException inner = new RuntimeException("inner"); + RuntimeException outer = new RuntimeException("outer", inner); + assertThat( + PluginTestSupport.run( + plugin, client, new Event(), new ContextData().setException(outer)) + .isCancelled()) + .isFalse(); + assertThat( + PluginTestSupport.run( + plugin, client, new Event(), new ContextData().setException(inner)) + .isCancelled()) + .isTrue(); + } + + @Test + void errorPluginAttachesModulesWhenEnabled() { + config.includeModules(true); + Event event = new Event(); + PluginTestSupport.run( + new ErrorPlugin(), client, event, new ContextData().setException(new RuntimeException())); + assertThat(event.getError().getModules()).isNotEmpty(); + assertThat(event.getError().getModules()) + .anySatisfy(m -> assertThat(m.name()).contains("junit")); + } + + @Test + void referenceIdsOnlyForErrors() { + ReferenceIdPlugin plugin = new ReferenceIdPlugin(); + Event error = event("error"); + PluginTestSupport.run(plugin, client, error); + assertThat(error.getReferenceId()).hasSize(16); + Event log = event("log"); + PluginTestSupport.run(plugin, client, log); + assertThat(log.getReferenceId()).isNull(); + Event explicit = event("error"); + explicit.setReferenceId("my-own-id"); + PluginTestSupport.run(plugin, client, explicit); + assertThat(explicit.getReferenceId()).isEqualTo("my-own-id"); + config.useReferenceIds(false); + Event disabled = event("error"); + PluginTestSupport.run(plugin, client, disabled); + assertThat(disabled.getReferenceId()).isNull(); + } + + @Test + void environmentInfoRespectsPrivacy() { + Event event = new Event(); + PluginTestSupport.run(new EnvironmentInfoPlugin(), client, event); + assertThat(event.getEnvironmentInfo().getProcessorCount()).isPositive(); + assertThat(event.getEnvironmentInfo().getRuntimeVersion()).startsWith("Java "); + assertThat(event.getEnvironmentInfo().getOsName()).isNotBlank(); + assertThat(event.getEnvironmentInfo().getInstallId()).isEqualTo(client.getInstallId()); + assertThat(event.getEnvironmentInfo().getMachineName()).isNotBlank(); + + config.includePrivateInformation(false); + Event privateEvent = new Event(); + PluginTestSupport.run(new EnvironmentInfoPlugin(), client, privateEvent); + assertThat(privateEvent.getEnvironmentInfo().getMachineName()).isNull(); + assertThat(privateEvent.getEnvironmentInfo().getIpAddress()).isNull(); + assertThat(JsonWriter.toJson(privateEvent.getEnvironmentInfo())) + .contains("\"o_s_name\"") + .doesNotContain("machine_name"); + } + + private static Event event(String type) { + Event event = new Event(); + event.setType(type); + return event; + } + + private static Event withRequest(RequestInfo request) { + Event event = new Event(); + event.setRequestInfo(request); + return event; + } + + private static Event withUserAgent(String userAgent) { + RequestInfo request = new RequestInfo(); + request.setUserAgent(userAgent); + return withRequest(request); + } + + private static RequestInfo request() { + Map> headers = new LinkedHashMap<>(); + headers.put("Authorization", List.of("Bearer secret")); + headers.put("cookie", List.of("a=b")); + headers.put("Accept", List.of("text/html", "application/json")); + headers.put("X-Password-Hint", List.of("x")); + headers.put("X-Long", List.of("y".repeat(5000))); + Map cookies = new LinkedHashMap<>(); + cookies.put("session", "abc"); + cookies.put("password", "x"); + Map query = new LinkedHashMap<>(); + query.put("q", "search"); + query.put("passwordReset", "1"); + RequestInfo request = new RequestInfo(); + request.setHttpMethod("POST"); + request.setPath("/orders"); + request.setClientIpAddress("10.0.0.1"); + request.setUserAgent("Mozilla/5.0"); + request.setHeaders(headers); + request.setCookies(cookies); + request.setQueryString(query); + request.setPostData(new LinkedHashMap<>(Map.of("name", "Ann", "password", "x"))); + return request; + } + + @Test + void requestInfoIsSanitized() { + config.addDataExclusions("*password*"); + Event event = new Event(); + PluginTestSupport.run( + new RequestInfoPlugin(), client, event, new ContextData().setRequestInfo(request())); + RequestInfo request = event.getRequestInfo(); + assertThat(request.getHeaders()).containsOnlyKeys("Accept", "X-Long"); + assertThat(request.getHeaders().get("Accept")).containsExactly("text/html", "application/json"); + assertThat(request.getHeaders().get("X-Long").get(0)) + .hasSize(RequestInfoPlugin.MAX_VALUE_LENGTH); + assertThat(request.getCookies()).containsOnlyKeys("session"); + assertThat(request.getQueryString()).containsOnlyKeys("q"); + assertThat(request.getPostData()).isEqualTo(Map.of("name", "Ann")); + assertThat(request.getClientIpAddress()).isEqualTo("10.0.0.1"); + assertThat(JsonWriter.toJson(request)) + .contains("\"http_method\":\"POST\"") + .contains("\"client_ip_address\"") + .contains("\"query_string\"") + .contains("\"headers\":{\"Accept\":[\"text/html\",\"application/json\"]"); + } + + @Test + void requestInfoRespectsPrivacySwitches() { + config.includePrivateInformation(false); + Event event = withRequest(request()); + PluginTestSupport.run(new RequestInfoPlugin(), client, event); + RequestInfo request = event.getRequestInfo(); + assertThat(request.getHeaders()).isNull(); + assertThat(request.getCookies()).isNull(); + assertThat(request.getQueryString()).isNull(); + assertThat(request.getPostData()).isNull(); + assertThat(request.getClientIpAddress()).isNull(); + assertThat(request.getPath()).isEqualTo("/orders"); + } + + @Test + void serverCanTurnOffPrivateInformation() { + config.applyServerSettings(Map.of("@@IncludePrivateInformation", "false")); + assertThat(config.isIncludePrivateInformation()).isFalse(); + assertThat(config.isIncludeCookies()).isFalse(); + Event event = withRequest(request()); + PluginTestSupport.run(new RequestInfoPlugin(), client, event); + assertThat(event.getRequestInfo().getCookies()).isNull(); + } + + @Test + void botsAreIgnored() { + config.addUserAgentBotPatterns("*bot*"); + config.applyServerSettings(Map.of("@@UserAgentBotPatterns", "*crawler*")); + IgnoreUserAgentPlugin plugin = new IgnoreUserAgentPlugin(); + assertThat(PluginTestSupport.run(plugin, client, withUserAgent("Googlebot/2.1")).isCancelled()) + .isTrue(); + assertThat(PluginTestSupport.run(plugin, client, withUserAgent("SomeCrawler")).isCancelled()) + .isTrue(); + assertThat(PluginTestSupport.run(plugin, client, withUserAgent("Mozilla/5.0")).isCancelled()) + .isFalse(); + assertThat(PluginTestSupport.run(plugin, client, new Event()).isCancelled()).isFalse(); + } + + @Test + void versionComesFromConfiguration() { + config.version("1.2.3"); + Event event = new Event(); + PluginTestSupport.run(new VersionPlugin(), client, event); + assertThat(event.getVersion()).isEqualTo("1.2.3"); + Event explicit = new Event(); + explicit.setVersion("9.9"); + PluginTestSupport.run(new VersionPlugin(), client, explicit); + assertThat(explicit.getVersion()).isEqualTo("9.9"); + } + + @Test + void submissionMethodComesFromContext() { + Event event = new Event(); + PluginTestSupport.run( + new SubmissionMethodPlugin(), + client, + event, + new ContextData().setSubmissionMethod("servletFilter")); + assertThat(event.getSubmissionMethod()).isEqualTo("servletFilter"); + } + + @Test + void sessionsWithoutUserAreCancelled() { + CancelSessionsWithNoUserPlugin plugin = new CancelSessionsWithNoUserPlugin(); + assertThat(PluginTestSupport.run(plugin, client, event("session")).isCancelled()).isTrue(); + Event withUser = event("session"); + withUser.setUserInfo(new UserInfo("u1")); + assertThat(PluginTestSupport.run(plugin, client, withUser).isCancelled()).isFalse(); + Event withSessionId = event("session"); + withSessionId.setReferenceId("0123456789abcdef"); + assertThat(PluginTestSupport.run(plugin, client, withSessionId).isCancelled()).isFalse(); + assertThat(PluginTestSupport.run(plugin, client, event("log")).isCancelled()).isFalse(); + } + + @Test + void sessionIdManagement() { + SessionIdManagementPlugin plugin = new SessionIdManagementPlugin(); + Event start = event("session"); + PluginTestSupport.run(plugin, client, start); + String sessionId = start.getReferenceId(); + assertThat(sessionId).matches("[0-9a-f]{32}"); + assertThat(config.getCurrentSessionIdentifier()).isEqualTo(sessionId); + + Event log = event("log"); + PluginTestSupport.run(plugin, client, log); + assertThat(log.getSessionId()).isEqualTo(sessionId); + assertThat(log.getData()).containsEntry("@ref:session", sessionId); + + Event end = event("sessionend"); + PluginTestSupport.run(plugin, client, end); + assertThat(end.getReferenceId()).isEqualTo(sessionId); + assertThat(config.getCurrentSessionIdentifier()).isNull(); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/plugins/DuplicateCheckerPluginTest.java b/exceptionless-core/src/test/java/com/exceptionless/plugins/DuplicateCheckerPluginTest.java new file mode 100644 index 0000000..9ee2059 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/DuplicateCheckerPluginTest.java @@ -0,0 +1,145 @@ +package com.exceptionless.plugins; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.TestClients; +import com.exceptionless.TestClients.CapturingSubmissionClient; +import com.exceptionless.TestClock; +import com.exceptionless.internal.ErrorConverter; +import com.exceptionless.models.Event; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class DuplicateCheckerPluginTest { + private final CapturingSubmissionClient submission = new CapturingSubmissionClient(); + private final ExceptionlessClient client = + new ExceptionlessClient(TestClients.config().submissionClient(submission)); + private final TestClock clock = new TestClock(); + private final DuplicateCheckerPlugin plugin = + new DuplicateCheckerPlugin(Duration.ofSeconds(30), clock); + + @AfterEach + void close() { + client.close(); + } + + private static Event error(Throwable t) { + Event event = new Event(); + event.setType("error"); + event.setError(ErrorConverter.convert(t, n -> false, List.of())); + return event; + } + + /** Errors created at the same call site, so they have identical stacks. */ + private static List sameErrors(int count) { + List events = new ArrayList<>(); + for (int i = 0; i < count; i++) { + events.add(error(new RuntimeException("duplicate"))); + } + return events; + } + + @Test + void countsDuplicatesWithinTheWindowAndSendsOneMergedEvent() { + plugin.startup(client); + List events = sameErrors(5); + assertThat(PluginTestSupport.run(plugin, client, events.get(0)).isCancelled()).isFalse(); + for (Event duplicate : events.subList(1, 5)) { + assertThat(PluginTestSupport.run(plugin, client, duplicate).isCancelled()).isTrue(); + } + plugin.flush(); + client.flush(Duration.ofSeconds(5)); + assertThat(submission.events()) + .singleElement() + .satisfies(e -> assertThat(e.get("count")).isEqualTo(4L)); + } + + @Test + void differentErrorsAreNotDuplicates() { + plugin.startup(client); + assertThat( + PluginTestSupport.run(plugin, client, error(new RuntimeException("a"))).isCancelled()) + .isFalse(); + assertThat( + PluginTestSupport.run(plugin, client, error(new RuntimeException("b"))).isCancelled()) + .isFalse(); + assertThat( + PluginTestSupport.run(plugin, client, error(new IllegalStateException("a"))) + .isCancelled()) + .isFalse(); + } + + @Test + void sameErrorAfterTheWindowIsSentAgain() { + plugin.startup(client); + List events = sameErrors(2); + PluginTestSupport.run(plugin, client, events.get(0)); + clock.advance(Duration.ofSeconds(31)); + assertThat(PluginTestSupport.run(plugin, client, events.get(1)).isCancelled()).isFalse(); + } + + @Test + void sameMessageFromDifferentCodeIsNotADuplicate() { + Event a = error(new RuntimeException("duplicate")); + Event b = error(new RuntimeException("duplicate")); + assertThat(DuplicateCheckerPlugin.hash(a)).isNotEqualTo(DuplicateCheckerPlugin.hash(b)); + } + + @Test + void ignoresNonErrors() { + for (int i = 0; i < 2; i++) { + Event log = new Event(); + log.setType("log"); + log.setMessage("x"); + assertThat(PluginTestSupport.run(plugin, client, log).isCancelled()).isFalse(); + } + } + + @Test + void suspendFlushesMergedEvents() { + plugin.startup(client); + for (Event event : sameErrors(2)) { + PluginTestSupport.run(plugin, client, event); + } + plugin.suspend(client); + client.flush(Duration.ofSeconds(5)); + assertThat(submission.events()).hasSize(1); + } + + @Test + void sameErrorJustInsideTheWindowIsStillADuplicate() { + plugin.startup(client); + List events = sameErrors(2); + PluginTestSupport.run(plugin, client, events.get(0)); + clock.advance(Duration.ofSeconds(29)); + assertThat(PluginTestSupport.run(plugin, client, events.get(1)).isCancelled()).isTrue(); + } + + @Test + void canStartAgainAfterSuspend() { + plugin.startup(client); + plugin.suspend(client); + plugin.startup(client); + for (Event event : sameErrors(2)) { + PluginTestSupport.run(plugin, client, event); + } + plugin.flush(); + client.flush(Duration.ofSeconds(5)); + assertThat(submission.events()).hasSize(1); + } + + @Test + void hashIgnoresDateButUsesStack() { + List events = sameErrors(2); + Event a = events.get(0); + a.setDate(OffsetDateTime.now(clock)); + Event b = events.get(1); + b.setDate(OffsetDateTime.now(clock).minusDays(1)); + assertThat(DuplicateCheckerPlugin.hash(a)).isEqualTo(DuplicateCheckerPlugin.hash(b)); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/plugins/EventExclusionPluginTest.java b/exceptionless-core/src/test/java/com/exceptionless/plugins/EventExclusionPluginTest.java new file mode 100644 index 0000000..7acd14e --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/EventExclusionPluginTest.java @@ -0,0 +1,136 @@ +package com.exceptionless.plugins; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.LogLevel; +import com.exceptionless.TestClients; +import com.exceptionless.internal.ErrorConverter; +import com.exceptionless.models.Event; +import java.io.FileNotFoundException; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class EventExclusionPluginTest { + private final ExceptionlessConfiguration config = TestClients.config(); + private final ExceptionlessClient client = PluginTestSupport.client(config); + private final EventExclusionPlugin plugin = new EventExclusionPlugin(); + + @AfterEach + void close() { + client.close(); + } + + private boolean cancelled(Event event) { + return PluginTestSupport.run(plugin, client, event).isCancelled(); + } + + private static Event log(String source, @Nullable String level) { + Event event = event("log", source); + event.setLevel(level); + return event; + } + + private static Event event(String type, @Nullable String source) { + Event event = new Event(); + event.setType(type); + event.setSource(source); + return event; + } + + private static Event message(String message) { + Event event = event("log", null); + event.setMessage(message); + return event; + } + + @ParameterizedTest + @CsvSource({ + "trace, info, true", + "debug, info, true", + "info, info, false", + "warn, info, false", + "error, info, false", + "fatal, error, false", + "error, off, true", + "Error, Warn, false", + "warning, error, true", + }) + void filtersLogsByServerLevel(String level, String minimum, boolean excluded) { + config.applyServerSettings(Map.of("@@log:com.example.*", minimum)); + assertThat(cancelled(log("com.example.Service", level))).isEqualTo(excluded); + } + + @Test + void logsWithoutLevelOrMatchingSettingAreKept() { + config.applyServerSettings(Map.of("@@log:com.example.*", "error")); + assertThat(cancelled(log("com.example.X", null))).isFalse(); + assertThat(cancelled(log("org.other", "trace"))).isFalse(); + } + + @Test + void defaultMinLogLevelAppliesWhenNoSettingMatches() { + config.defaultMinLogLevel(LogLevel.WARN); + assertThat(cancelled(log("x", "info"))).isTrue(); + assertThat(cancelled(log("x", "warn"))).isFalse(); + config.applyServerSettings(Map.of("@@log:x", "debug")); + assertThat(cancelled(log("x", "info"))).isFalse(); + } + + @Test + void excludesErrorTypesAcrossTheCauseChain() { + config.applyServerSettings(Map.of("@@error:java.io.FileNotFoundException", "false")); + Event event = event("error", null); + event.setError( + ErrorConverter.convert( + new IllegalStateException("x", new FileNotFoundException("f")), n -> false, List.of())); + assertThat(cancelled(event)).isTrue(); + + Event other = event("error", null); + other.setError(ErrorConverter.convert(new IllegalStateException("x"), n -> false, List.of())); + assertThat(cancelled(other)).isFalse(); + } + + @Test + void excludesErrorTypesByWildcard() { + config.applyServerSettings(Map.of("@@error:org.springframework.web.*", "false")); + Event event = event("error", null); + com.exceptionless.models.ErrorInfo error = new com.exceptionless.models.ErrorInfo(); + error.setType("org.springframework.web.HttpMediaTypeNotSupportedException"); + event.setError(error); + assertThat(cancelled(event)).isTrue(); + } + + @Test + void excludesTypesAndSources() { + config.applyServerSettings( + Map.of("@@usage:Export", "false", "@@404:/favicon.ico", "false", "@@404:/wp-*", "false")); + assertThat(cancelled(event("usage", "Export"))).isTrue(); + assertThat(cancelled(event("usage", "Import"))).isFalse(); + assertThat(cancelled(event("404", "/favicon.ico"))).isTrue(); + assertThat(cancelled(event("404", "/wp-admin"))).isTrue(); + assertThat(cancelled(event("404", "/api"))).isFalse(); + } + + @Test + void customExclusions() { + config.addEventExclusion(e -> "secret".equals(e.getMessage())); + config.addEventExclusion( + e -> { + throw new IllegalStateException("failing callbacks are ignored"); + }); + assertThat(cancelled(message("secret"))).isTrue(); + assertThat(cancelled(message("public"))).isFalse(); + } + + @Test + void nullTypeIsSafe() { + assertThat(cancelled(new Event())).isFalse(); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/plugins/PluginTestSupport.java b/exceptionless-core/src/test/java/com/exceptionless/plugins/PluginTestSupport.java new file mode 100644 index 0000000..936f9e8 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/PluginTestSupport.java @@ -0,0 +1,27 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.TestClients; +import com.exceptionless.models.Event; + +/** Runs single plugins against a real (offline) client. */ +final class PluginTestSupport { + private PluginTestSupport() {} + + static ExceptionlessClient client(ExceptionlessConfiguration config) { + return new ExceptionlessClient( + config.submissionClient(new TestClients.CapturingSubmissionClient())); + } + + static EventPluginContext run(EventPlugin plugin, ExceptionlessClient client, Event event) { + return run(plugin, client, event, new ContextData()); + } + + static EventPluginContext run( + EventPlugin plugin, ExceptionlessClient client, Event event, ContextData data) { + EventPluginContext context = new EventPluginContext(client, event, data); + plugin.run(context); + return context; + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/queue/DefaultEventQueueTest.java b/exceptionless-core/src/test/java/com/exceptionless/queue/DefaultEventQueueTest.java new file mode 100644 index 0000000..31da2ff --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/queue/DefaultEventQueueTest.java @@ -0,0 +1,260 @@ +package com.exceptionless.queue; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.TestClients; +import com.exceptionless.TestClients.CapturingSubmissionClient; +import com.exceptionless.TestClock; +import com.exceptionless.models.Event; +import com.exceptionless.storage.InMemoryQueueStorage; +import com.exceptionless.submission.SubmissionResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class DefaultEventQueueTest { + private final ExceptionlessConfiguration config = TestClients.config().submissionBatchSize(10); + private final CapturingSubmissionClient submission = new CapturingSubmissionClient(); + private final TestClock clock = new TestClock(); + private final InMemoryQueueStorage storage = new InMemoryQueueStorage(1000, clock); + private final DefaultEventQueue queue = new DefaultEventQueue(config, submission, storage, clock); + + private void enqueue(int count) { + for (int i = 0; i < count; i++) { + queue.enqueue(log("message " + i)); + } + } + + private static Event log(String message) { + Event event = new Event(); + event.setType("log"); + event.setMessage(message); + return event; + } + + private void respond(int status) { + submission.eventsResponse = json -> new SubmissionResponse(status, null, null); + } + + @Test + void sendsAllEventsInBatches() { + enqueue(25); + queue.process(); + assertThat(submission.posts).hasSize(3); + assertThat(submission.events()).hasSize(25); + assertThat(queue.size()).isZero(); + } + + @Test + void batchesAreJsonArraysOfEvents() { + enqueue(2); + queue.process(); + assertThat(submission.posts.get(0)).startsWith("[{\"type\":\"log\"").endsWith("}]"); + } + + @Test + void rateLimitKeepsEventsAndSuspendsUntilNext15MinuteWindow() { + enqueue(3); + respond(429); + queue.process(); + assertThat(queue.size()).isEqualTo(3); + assertThat(queue.isSuspended()).isTrue(); + clock.advance(Duration.ofMinutes(7)); // 10:14 + assertThat(queue.isSuspended()).isTrue(); + clock.advance(Duration.ofMinutes(1)); // 10:15 + assertThat(queue.isSuspended()).isFalse(); + respond(202); + queue.process(); + assertThat(queue.size()).isZero(); + } + + @Test + void serviceUnavailableKeepsEvents() { + enqueue(3); + respond(503); + queue.process(); + assertThat(queue.size()).isEqualTo(3); + clock.advance(Duration.ofMinutes(4)); + assertThat(queue.isSuspended()).isTrue(); + clock.advance(Duration.ofMinutes(1)); + assertThat(queue.isSuspended()).isFalse(); + } + + @Test + void paymentRequiredClearsQueueAndDiscardsNewEventsForAnHour() { + enqueue(3); + respond(402); + queue.process(); + assertThat(queue.size()).isZero(); + enqueue(2); + assertThat(queue.size()).isZero(); + clock.advance(Duration.ofHours(1)); + enqueue(2); + assertThat(queue.size()).isEqualTo(2); + } + + @ParameterizedTest + @ValueSource(ints = {401, 403}) + void unauthorizedDropsBatchAndSuspends15Minutes(int status) { + enqueue(15); + respond(status); + queue.process(); + assertThat(queue.size()).isEqualTo(5); + assertThat(submission.posts).hasSize(1); + clock.advance(Duration.ofMinutes(15)); + assertThat(queue.isSuspended()).isFalse(); + } + + @ParameterizedTest + @ValueSource(ints = {400, 404}) + void badRequestDropsBatchAndSuspends4Hours(int status) { + enqueue(3); + respond(status); + queue.process(); + assertThat(queue.size()).isZero(); + clock.advance(Duration.ofHours(3)); + assertThat(queue.isSuspended()).isTrue(); + clock.advance(Duration.ofHours(1)); + assertThat(queue.isSuspended()).isFalse(); + } + + @Test + void payloadTooLargeShrinksBatchesUntilTheySucceed() { + enqueue(10); + submission.eventsResponse = + json -> { + int count = json.split("\"type\"").length - 1; + return new SubmissionResponse(count > 3 ? 413 : 202, null, null); + }; + queue.process(); + assertThat(queue.size()).isZero(); + assertThat(submission.events().size()).isGreaterThan(10); // retried batches were re-sent + } + + @Test + void smallerBatchSizeLastsForTheRestOfTheRun() { + enqueue(20); + List statuses = new ArrayList<>(); + submission.eventsResponse = + json -> { + int count = json.split("\"type\"").length - 1; + int status = count > 3 ? 413 : 202; + statuses.add(status); + return new SubmissionResponse(status, null, null); + }; + queue.process(); + assertThat(queue.size()).isZero(); + // 10 -> 413, 5 -> 413, then batches of 2 succeed without further rejections. + assertThat(statuses.stream().filter(s -> s == 413).count()).isEqualTo(2); + } + + @Test + void payloadTooLargeDropsSingleOversizedEvent() { + enqueue(1); + respond(413); + queue.process(); + assertThat(queue.size()).isZero(); + assertThat(queue.isSuspended()).isFalse(); + } + + @Test + void networkErrorsRetryWithBackoffAndEventuallyDrop() { + config.queueMaxAttempts(3); + enqueue(1); + submission.eventsResponse = + json -> SubmissionResponse.failed(new java.io.IOException("connection refused")); + List suspensions = new ArrayList<>(); + for (int attempt = 0; attempt < 3; attempt++) { + queue.process(); + Duration waited = Duration.ZERO; + while (queue.isSuspended()) { + clock.advance(Duration.ofSeconds(1)); + waited = waited.plusSeconds(1); + } + suspensions.add(waited); + } + assertThat(suspensions) + .containsExactly(Duration.ofSeconds(15), Duration.ofSeconds(30), Duration.ofSeconds(60)); + assertThat(submission.posts).hasSize(3); + queue.process(); // third failure reached the attempt limit + assertThat(queue.size()).isZero(); + assertThat(submission.posts).hasSize(3); + } + + @Test + void serverErrorsAreRetried() { + enqueue(1); + respond(500); + queue.process(); + assertThat(queue.size()).isEqualTo(1); + assertThat(storage.peek(1).get(0).attempts()).isEqualTo(1); + } + + @Test + void dropsEventsOlderThanMaxAge() { + config.queueMaxAge(Duration.ofHours(1)); + enqueue(2); + clock.advance(Duration.ofHours(2)); + queue.process(); + assertThat(submission.posts).isEmpty(); + assertThat(storage.size()).isZero(); + } + + @Test + void doesNotSendWhileSuspendedOrDisabled() { + enqueue(1); + queue.suspendProcessing(Duration.ofMinutes(1)); + queue.process(); + assertThat(submission.posts).isEmpty(); + queue.resume(); + config.enabled(false); + queue.process(); + assertThat(submission.posts).isEmpty(); + config.enabled(true); + queue.process(); + assertThat(submission.posts).hasSize(1); + } + + @Test + void notifiesHandlersWithCountAndResponse() { + List seen = new ArrayList<>(); + queue.onEventsPosted((count, response) -> seen.add(count + ":" + response.statusCode())); + queue.onEventsPosted( + (count, response) -> { + throw new IllegalStateException("handler failures are isolated"); + }); + enqueue(2); + queue.process(); + assertThat(seen).containsExactly("2:202"); + } + + @Test + void submissionClientExceptionsAreTreatedAsFailures() { + enqueue(1); + submission.eventsResponse = + json -> { + throw new IllegalStateException("boom"); + }; + queue.process(); + assertThat(queue.size()).isEqualTo(1); + assertThat(queue.isSuspended()).isTrue(); + } + + @Test + void limitsBatchBytes() { + String big = "x".repeat(300_000); + for (int i = 0; i < 5; i++) { + queue.enqueue(log(big)); + } + queue.process(); + assertThat(submission.posts.size()).isGreaterThanOrEqualTo(2); + for (String post : submission.posts) { + assertThat(post.length()).isLessThanOrEqualTo(DefaultEventQueue.MAX_BATCH_BYTES + 400_000); + } + assertThat(queue.size()).isZero(); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/settings/ClientSettingsTest.java b/exceptionless-core/src/test/java/com/exceptionless/settings/ClientSettingsTest.java new file mode 100644 index 0000000..6d63afe --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/settings/ClientSettingsTest.java @@ -0,0 +1,174 @@ +package com.exceptionless.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.LogLevel; +import com.exceptionless.internal.Strings; +import com.exceptionless.json.JsonWriter; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class ClientSettingsTest { + @Test + void keysAreCaseInsensitive() { + ClientSettings settings = ClientSettings.of(Map.of("@@DataExclusions", "a,b")); + assertThat(settings.getString("@@dataexclusions")).isEqualTo("a,b"); + } + + @Test + void overridesWin() { + ClientSettings settings = + ClientSettings.of(Map.of("k", "local", "only", "x"), Map.of("K", "server")); + assertThat(settings.getString("k")).isEqualTo("server"); + assertThat(settings.getString("only")).isEqualTo("x"); + } + + @ParameterizedTest + @CsvSource({ + "true,true", + "TRUE,true", + "yes,true", + "1,true", + "false,false", + "No,false", + "0,false", + "junk," + }) + void parsesBooleans(String value, Boolean expected) { + ClientSettings settings = ClientSettings.of(Map.of("k", value)); + if (expected == null) { + assertThat(settings.getBoolean("k", true)).isTrue(); + assertThat(settings.getBoolean("k", false)).isFalse(); + } else { + assertThat(settings.getBoolean("k", !expected)).isEqualTo(expected); + } + } + + @Test + void splitsLists() { + assertThat(Strings.splitList(" *password* , ;secret\n\r token ,")) + .containsExactly("*password*", "secret", "token"); + assertThat(Strings.splitList(null)).isEmpty(); + } + + @ParameterizedTest + @CsvSource({ + "com.example.Noisy, OFF", + "com.example.Service, INFO", + "org.other.Thing, ERROR", + }) + void minLogLevelUsesExactSourceFirst(String source, LogLevel expected) { + ClientSettings settings = + ClientSettings.of( + Map.of( + "@@log:*", "error", + "@@log:com.example.*", "info", + "@@log:com.example.Noisy", "off")); + assertThat(settings.getMinLogLevel(source)).isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({ + "com.example.api.Users, TRACE", + "com.example.db.Pool, INFO", + "com.zzz, WARN", + }) + void longestWildcardWins(String source, LogLevel expected) { + ClientSettings settings = + ClientSettings.of( + Map.of( + "@@log:com.*", "warn", + "@@log:com.example.api.*", "trace", + "@@log:com.example.*", "info")); + assertThat(settings.getMinLogLevel(source)).isEqualTo(expected); + } + + @Test + void minLogLevelIsNullWhenNothingMatches() { + ClientSettings settings = ClientSettings.of(Map.of("@@log:com.example.*", "info")); + assertThat(settings.getMinLogLevel("org.other")).isNull(); + assertThat(settings.getMinLogLevel("org.other")).isNull(); // cached + assertThat(ClientSettings.empty().getMinLogLevel(null)).isNull(); + } + + @Test + void blankSourceUsesGlobalSetting() { + ClientSettings settings = ClientSettings.of(Map.of("@@log:*", "Warn")); + assertThat(settings.getMinLogLevel(null)).isEqualTo(LogLevel.WARN); + assertThat(settings.getMinLogLevel("")).isEqualTo(LogLevel.WARN); + } + + @Test + void logSettingKeysAreCaseInsensitive() { + ClientSettings settings = ClientSettings.of(Map.of("@@LOG:COM.EXAMPLE.*", "Info")); + assertThat(settings.getMinLogLevel("com.example.Foo")).isEqualTo(LogLevel.INFO); + } + + @Test + void typeAndSourceSettings() { + ClientSettings settings = + ClientSettings.of( + Map.of( + "@@error:java.io.IOException", "false", + "@@error:org.hibernate.*", "false", + "@@usage:Export", "off", + "@@404:*", "true")); + assertThat(settings.getTypeAndSourceSetting("error", "java.io.IOException", true)).isFalse(); + assertThat( + settings.getTypeAndSourceSetting( + "error", "org.hibernate.LazyInitializationException", true)) + .isFalse(); + assertThat(settings.getTypeAndSourceSetting("error", "java.lang.IllegalStateException", true)) + .isTrue(); + assertThat(settings.getTypeAndSourceSetting("usage", "Export", true)).isFalse(); + assertThat(settings.getTypeAndSourceSetting("usage", "Import", true)).isTrue(); + assertThat(settings.getTypeAndSourceSetting("404", "/missing", false)).isTrue(); + assertThat(settings.getTypeAndSourceSetting("error", null, true)).isTrue(); + } + + @ParameterizedTest + @CsvSource({ + "trace,TRACE", + "Debug,DEBUG", + "info,INFO", + "Information,INFO", + "warn,WARN", + "Warning,WARN", + "error,ERROR", + "fatal,FATAL", + "critical,FATAL", + "off,OFF", + "false,OFF", + "true,TRACE", + "severe,ERROR", + "fine,DEBUG", + "finest,TRACE" + }) + void parsesLevels(String value, LogLevel expected) { + assertThat(LogLevel.parse(value)).isEqualTo(expected); + } + + @Test + void unknownLevelsAreNull() { + assertThat(LogLevel.parse("bogus")).isNull(); + assertThat(LogLevel.parse(null)).isNull(); + } + + @Test + void levelNamesAndWireValues() { + assertThat(LogLevel.WARN.value()).isEqualTo("warn"); + assertThat(LogLevel.WARN).hasToString("WARN"); + assertThat(JsonWriter.toJson(LogLevel.WARN)).isEqualTo("\"WARN\""); + } + + @Test + void levelComparison() { + assertThat(LogLevel.ERROR.isAtLeast(LogLevel.WARN)).isTrue(); + assertThat(LogLevel.WARN.isAtLeast(LogLevel.WARN)).isTrue(); + assertThat(LogLevel.INFO.isAtLeast(LogLevel.WARN)).isFalse(); + assertThat(LogLevel.OFF.isAtLeast(LogLevel.TRACE)).isFalse(); + assertThat(LogLevel.FATAL.isAtLeast(LogLevel.OFF)).isFalse(); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/storage/QueueStorageTest.java b/exceptionless-core/src/test/java/com/exceptionless/storage/QueueStorageTest.java new file mode 100644 index 0000000..75881c8 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/storage/QueueStorageTest.java @@ -0,0 +1,234 @@ +package com.exceptionless.storage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.exceptionless.TestClock; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.InstantSource; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class QueueStorageTest { + enum Kind { + MEMORY, + FILE; + + QueueStorage create(Path dir, int maxItems) { + return create(dir, maxItems, InstantSource.system()); + } + + QueueStorage create(Path dir, int maxItems, InstantSource clock) { + return this == MEMORY + ? new InMemoryQueueStorage(maxItems, clock) + : new FileQueueStorage(dir, maxItems, clock); + } + } + + @ParameterizedTest + @EnumSource(Kind.class) + void peeksOldestFirstWithoutRemoving(Kind kind, @TempDir Path dir) { + QueueStorage storage = kind.create(dir, 5); + storage.enqueue("{\"n\":1}"); + storage.enqueue("{\"n\":2}"); + storage.enqueue("{\"n\":3}"); + List entries = storage.peek(2); + assertThat(entries).extracting(QueueEntry::json).containsExactly("{\"n\":1}", "{\"n\":2}"); + assertThat(storage.size()).isEqualTo(3); + storage.remove(entries); + assertThat(storage.peek(10)).extracting(QueueEntry::json).containsExactly("{\"n\":3}"); + assertThat(storage.size()).isEqualTo(1); + } + + @ParameterizedTest + @EnumSource(Kind.class) + void tracksAttempts(Kind kind, @TempDir Path dir) { + QueueStorage storage = kind.create(dir, 5); + storage.enqueue("{}"); + storage.incrementAttempts(storage.peek(1)); + storage.incrementAttempts(storage.peek(1)); + assertThat(storage.peek(1).get(0).attempts()).isEqualTo(2); + } + + @ParameterizedTest + @EnumSource(Kind.class) + void recordsWhenEventsWereQueued(Kind kind, @TempDir Path dir) { + TestClock clock = new TestClock(); + QueueStorage storage = kind.create(dir, 5, clock); + storage.enqueue("{\"n\":1}"); + clock.advance(Duration.ofMinutes(1)); + storage.enqueue("{\"n\":2}"); + assertThat(storage.peek(2)) + .extracting(QueueEntry::enqueuedAt) + .containsExactly(TestClock.START, TestClock.START.plus(Duration.ofMinutes(1))); + } + + @ParameterizedTest + @EnumSource(Kind.class) + void dropsOldestWhenFull(Kind kind, @TempDir Path dir) { + QueueStorage storage = kind.create(dir, 5); + for (int i = 0; i < 8; i++) { + storage.enqueue("{\"n\":" + i + "}"); + } + assertThat(storage.size()).isEqualTo(5); + assertThat(storage.peek(1).get(0).json()).isEqualTo("{\"n\":3}"); + } + + @ParameterizedTest + @EnumSource(Kind.class) + void clears(Kind kind, @TempDir Path dir) { + QueueStorage storage = kind.create(dir, 5); + storage.enqueue("{}"); + storage.clear(); + assertThat(storage.size()).isZero(); + assertThat(storage.peek(10)).isEmpty(); + } + + @ParameterizedTest + @EnumSource(Kind.class) + void rejectsInvalidMaxItems(Kind kind, @TempDir Path dir) { + assertThatThrownBy(() -> kind.create(dir, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxItems"); + } + + @ParameterizedTest + @EnumSource(Kind.class) + void isThreadSafe(Kind kind, @TempDir Path dir) throws Exception { + QueueStorage storage = kind.create(dir, 10_000); + ExecutorService pool = Executors.newFixedThreadPool(8); + CountDownLatch start = new CountDownLatch(1); + int perThread = kind == Kind.MEMORY ? 500 : 50; + List> futures = new ArrayList<>(); + for (int t = 0; t < 8; t++) { + futures.add( + pool.submit( + () -> { + start.await(); + for (int i = 0; i < perThread; i++) { + storage.enqueue("{}"); + if (i % 10 == 0) { + storage.remove(storage.peek(1)); + } + } + return null; + })); + } + start.countDown(); + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + pool.shutdown(); + // Concurrent peek+remove may remove the same entry twice, so only the bounds are exact. + assertThat(storage.size()).isBetween(8 * perThread - 8 * (perThread / 10), 8 * perThread); + } + + @Test + void fileStorageSurvivesRestart(@TempDir Path dir) { + TestClock clock = new TestClock(); + FileQueueStorage first = new FileQueueStorage(dir, 10, clock); + first.enqueue("{\"a\":1}"); + clock.advance(Duration.ofSeconds(1)); + first.enqueue("{\"a\":2}"); + first.incrementAttempts(first.peek(1)); + + FileQueueStorage second = new FileQueueStorage(dir, 10, clock); + List entries = second.peek(10); + assertThat(entries).extracting(QueueEntry::json).containsExactly("{\"a\":1}", "{\"a\":2}"); + assertThat(entries).extracting(QueueEntry::attempts).containsExactly(1, 0); + assertThat(entries.get(0).enqueuedAt()).isEqualTo(TestClock.START); + } + + @Test + void fileStorageIndexStaysConsistentAcrossReopen(@TempDir Path dir) throws Exception { + TestClock clock = new TestClock(); + FileQueueStorage first = new FileQueueStorage(dir, 10, clock); + for (int i = 0; i < 4; i++) { + first.enqueue("{\"n\":" + i + "}"); // same millisecond: ordered by sequence + } + List queued = first.peek(4); + first.remove(List.of(queued.get(1))); + first.incrementAttempts(List.of(queued.get(2))); + first.incrementAttempts(first.peek(4).subList(1, 2)); // the same event, with its new id + + FileQueueStorage second = new FileQueueStorage(dir, 10, clock); + assertThat(second.size()).isEqualTo(3); + // New files must neither replace queued ones nor sort before them. + second.enqueue("{\"n\":4}"); + second.enqueue("{\"n\":5}"); + List entries = second.peek(10); + assertThat(entries) + .extracting(QueueEntry::json) + .containsExactly("{\"n\":0}", "{\"n\":2}", "{\"n\":3}", "{\"n\":4}", "{\"n\":5}"); + assertThat(entries).extracting(QueueEntry::attempts).containsExactly(0, 2, 0, 0, 0); + try (Stream files = Files.list(dir)) { + assertThat(files).hasSize(5); + } + } + + @Test + void fileStorageForgetsFilesDeletedOutsideIt(@TempDir Path dir) throws Exception { + FileQueueStorage storage = new FileQueueStorage(dir, 10); + storage.enqueue("{\"n\":1}"); + storage.enqueue("{\"n\":2}"); + Files.delete(dir.resolve(storage.peek(1).get(0).id())); + assertThat(storage.peek(10)).extracting(QueueEntry::json).containsExactly("{\"n\":2}"); + assertThat(storage.size()).isEqualTo(1); + } + + @Test + void fileStorageKeepsTheHighestAttemptCountOfDuplicateFiles(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("0000000001000-000001.0.json"), "{\"old\":true}"); + Files.writeString(dir.resolve("0000000001000-000001.2.json"), "{\"old\":false}"); + FileQueueStorage storage = new FileQueueStorage(dir); + assertThat(storage.peek(10)) + .singleElement() + .satisfies(e -> assertThat(e.attempts()).isEqualTo(2)); + assertThat(dir.resolve("0000000001000-000001.0.json")).doesNotExist(); + } + + @Test + void fileStorageIgnoresForeignAndTempFiles(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("notes.txt"), "x"); + Files.writeString(dir.resolve("123-1.0.json.tmp"), "partial"); + Files.writeString(dir.resolve("bogus.json"), "{}"); + FileQueueStorage storage = new FileQueueStorage(dir); + storage.enqueue("{\"ok\":true}"); + assertThat(storage.peek(10)).extracting(QueueEntry::json).containsExactly("{\"ok\":true}"); + storage.clear(); + assertThat(dir.resolve("bogus.json")).exists(); + } + + @Test + void fileStorageReportsDirectoryProblemsAsUncheckedIoExceptions(@TempDir Path dir) + throws Exception { + Path file = Files.writeString(dir.resolve("not-a-directory"), "x"); + assertThatThrownBy(() -> new FileQueueStorage(file.resolve("queue"))) + .isInstanceOf(UncheckedIOException.class); + assertThatThrownBy(() -> new FileKeyValueStorage(file.resolve("state"))) + .isInstanceOf(UncheckedIOException.class); + } + + @Test + void keyValueStorage(@TempDir Path dir) { + FileKeyValueStorage storage = new FileKeyValueStorage(dir); + assertThat(storage.read("install-id")).isNull(); + storage.write("install-id", "abc"); + assertThat(new FileKeyValueStorage(dir).read("install-id")).isEqualTo("abc"); + assertThatThrownBy(() -> storage.write("../escape", "x")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/submission/DefaultSubmissionClientTest.java b/exceptionless-core/src/test/java/com/exceptionless/submission/DefaultSubmissionClientTest.java new file mode 100644 index 0000000..90f91b4 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/submission/DefaultSubmissionClientTest.java @@ -0,0 +1,158 @@ +package com.exceptionless.submission; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.FakeServer; +import com.exceptionless.TestClients; +import com.exceptionless.models.UserDescription; +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class DefaultSubmissionClientTest { + private final FakeServer server; + private final ExceptionlessConfiguration config; + private final DefaultSubmissionClient client; + + DefaultSubmissionClientTest() throws Exception { + server = new FakeServer(); + config = TestClients.config().serverUrl(server.url()); + client = new DefaultSubmissionClient(config); + } + + @AfterEach + void close() { + server.close(); + } + + @Test + void postsEventsWithBearerTokenAndUserAgent() throws Exception { + SubmissionResponse response = client.postEvents("[{\"type\":\"log\"}]"); + assertThat(response.statusCode()).isEqualTo(202); + assertThat(response.isSuccess()).isTrue(); + FakeServer.Request request = server.take(Duration.ofSeconds(5)); + assertThat(request.method()).isEqualTo("POST"); + assertThat(request.path()).isEqualTo("/api/v2/events"); + assertThat(request.query()).isEmpty(); + assertThat(request.header("Authorization")).isEqualTo("Bearer " + TestClients.API_KEY); + assertThat(request.header("Content-Type")).isEqualTo("application/json; charset=utf-8"); + assertThat(request.header("User-Agent")).startsWith("exceptionless-java/"); + assertThat(request.header("Content-Encoding")).isNull(); + assertThat(request.body()).isEqualTo("[{\"type\":\"log\"}]"); + } + + @Test + void gzipsLargeBodies() throws Exception { + String json = "[{\"message\":\"" + "x".repeat(5000) + "\"}]"; + client.postEvents(json); + FakeServer.Request request = server.take(Duration.ofSeconds(5)); + assertThat(request.header("Content-Encoding")).isEqualTo("gzip"); + assertThat(request.body()).isEqualTo(json); + } + + @Test + void readsConfigVersionHeader() { + server.setConfigVersionHeader(12); + assertThat(client.postEvents("[]").configVersion()).isEqualTo(12); + } + + @Test + void returnsErrorStatusesAndBodies() { + server.onEvents(r -> new FakeServer.Response(400, "{\"title\":\"bad\"}", Map.of())); + SubmissionResponse response = client.postEvents("[]"); + assertThat(response.isBadRequest()).isTrue(); + assertThat(response.message()).contains("bad"); + } + + @Test + void connectionFailuresBecomeFailedResponses() { + server.close(); + SubmissionResponse response = client.postEvents("[]"); + assertThat(response.statusCode()).isEqualTo(-1); + assertThat(response.isSuccess()).isFalse(); + assertThat(response.message()).isNotBlank(); + } + + @Test + void getsSettings() throws Exception { + server.onConfig( + r -> + new FakeServer.Response( + 200, "{\"version\":3,\"settings\":{\"@@log:*\":\"info\",\"n\":5}}", Map.of())); + SettingsResponse response = client.getSettings(2); + assertThat(response).isInstanceOf(SettingsResponse.Updated.class); + SettingsResponse.Updated updated = (SettingsResponse.Updated) response; + assertThat(updated.version()).isEqualTo(3); + assertThat(updated.settings()).containsEntry("@@log:*", "info").containsEntry("n", "5"); + FakeServer.Request request = server.take(Duration.ofSeconds(5)); + assertThat(request.path()).isEqualTo("/api/v2/projects/config"); + assertThat(request.query()).isEqualTo("v=2"); + assertThat(request.header("Authorization")).isEqualTo("Bearer " + TestClients.API_KEY); + } + + @Test + void settingsNotModified() { + server.onConfig(r -> FakeServer.Response.of(304)); + assertThat(client.getSettings(3)).isInstanceOf(SettingsResponse.NotModified.class); + } + + @Test + void settingsErrors() { + server.onConfig(r -> new FakeServer.Response(200, "not json", Map.of())); + assertThat(client.getSettings(0)).isInstanceOf(SettingsResponse.Failed.class); + server.onConfig(r -> new FakeServer.Response(200, "{\"settings\":{}}", Map.of())); + assertThat(client.getSettings(0)).isInstanceOf(SettingsResponse.Failed.class); + server.onConfig(r -> FakeServer.Response.of(401)); + assertThat(client.getSettings(0)) + .isInstanceOfSatisfying( + SettingsResponse.Failed.class, failed -> assertThat(failed.message()).contains("401")); + } + + @Test + void usesConfigAndHeartbeatServerUrls() throws Exception { + try (FakeServer other = new FakeServer()) { + ExceptionlessConfiguration split = + TestClients.config() + .serverUrl(server.url()) + .configServerUrl(other.url()) + .heartbeatServerUrl(other.url()); + DefaultSubmissionClient splitClient = new DefaultSubmissionClient(split); + splitClient.getSettings(0); + splitClient.sendHeartbeat("user 1&x", true); + assertThat(other.take(Duration.ofSeconds(5)).path()).isEqualTo("/api/v2/projects/config"); + FakeServer.Request heartbeat = other.take(Duration.ofSeconds(5)); + assertThat(heartbeat.path()).isEqualTo("/api/v2/events/session/heartbeat"); + assertThat(heartbeat.query()).isEqualTo("id=user+1%26x&close=true"); + assertThat(server.requests()).isEmpty(); + } + } + + @Test + void postsUserDescriptions() throws Exception { + client.postUserDescription("abc-12345", new UserDescription("a@b.c", "desc")); + FakeServer.Request request = server.take(Duration.ofSeconds(5)); + assertThat(request.path()).isEqualTo("/api/v2/events/by-ref/abc-12345/user-description"); + assertThat(request.body()).isEqualTo("{\"email_address\":\"a@b.c\",\"description\":\"desc\"}"); + } + + @Test + void reportsRedirectsInsteadOfFollowingThem() { + server.onEvents( + r -> + new FakeServer.Response( + 301, "", Map.of("Location", "https://secure.example.com/api/v2/events"))); + SubmissionResponse response = client.postEvents("[]"); + assertThat(response.statusCode()).isEqualTo(301); + assertThat(response.message()).contains("https://secure.example.com"); + assertThat(server.requests()).hasSize(1); + } + + @Test + void parsesVersionHeaders() { + assertThat(DefaultSubmissionClient.parseVersion(" 7 ")).isEqualTo(7); + assertThat(DefaultSubmissionClient.parseVersion("x")).isNull(); + assertThat(DefaultSubmissionClient.parseVersion(null)).isNull(); + } +} diff --git a/exceptionless-core/src/test/resources/exceptionless.properties b/exceptionless-core/src/test/resources/exceptionless.properties new file mode 100644 index 0000000..3996bdc --- /dev/null +++ b/exceptionless-core/src/test/resources/exceptionless.properties @@ -0,0 +1,4 @@ +# Used by ExceptionlessConfigurationTest +version=from-file +defaultTags=file-tag +settings.@@log\:*=warn diff --git a/exceptionless-core/src/testFixtures/java/com/exceptionless/FakeServer.java b/exceptionless-core/src/testFixtures/java/com/exceptionless/FakeServer.java new file mode 100644 index 0000000..eec15ea --- /dev/null +++ b/exceptionless-core/src/testFixtures/java/com/exceptionless/FakeServer.java @@ -0,0 +1,169 @@ +package com.exceptionless; + +import com.exceptionless.json.JsonParser; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.zip.GZIPInputStream; + +/** An in-process HTTP server that behaves like the Exceptionless API, for tests. */ +public final class FakeServer implements AutoCloseable { + /** A request received by the server. */ + public record Request( + String method, String path, String query, Map> headers, String body) { + public String header(String name) { + for (Map.Entry> e : headers.entrySet()) { + if (e.getKey().equalsIgnoreCase(name)) { + return e.getValue().get(0); + } + } + return null; + } + + @SuppressWarnings("unchecked") + public List> events() { + Object parsed = JsonParser.parse(body); + return (List>) parsed; + } + } + + /** A canned response. */ + public record Response(int status, String body, Map headers) { + public static Response of(int status) { + return new Response(status, "", Map.of()); + } + } + + private final HttpServer server; + private final LinkedBlockingQueue requests = new LinkedBlockingQueue<>(); + private final List allRequests = new CopyOnWriteArrayList<>(); + private volatile Function eventsHandler = r -> Response.of(202); + private volatile Function configHandler = + r -> new Response(200, "{\"version\":1,\"settings\":{}}", Map.of()); + private final AtomicInteger configVersion = new AtomicInteger(0); + + public FakeServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + } + + public String url() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + public void onEvents(Function handler) { + this.eventsHandler = handler; + } + + public void onConfig(Function handler) { + this.configHandler = handler; + } + + /** The value returned in X-Exceptionless-ConfigVersion on event posts (0 = not sent). */ + public void setConfigVersionHeader(int version) { + configVersion.set(version); + } + + public Request take(Duration timeout) throws InterruptedException { + Request request = requests.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); + if (request == null) { + throw new AssertionError("No request received within " + timeout); + } + return request; + } + + /** Waits for the next request to the given path prefix, skipping others. */ + public Request take(String pathPrefix, Duration timeout) throws InterruptedException { + long deadline = System.nanoTime() + timeout.toNanos(); + while (true) { + long remaining = deadline - System.nanoTime(); + Request request = requests.poll(Math.max(0, remaining), TimeUnit.NANOSECONDS); + if (request == null) { + throw new AssertionError( + "No request to " + pathPrefix + " within " + timeout + "; got " + paths()); + } + if (request.path().startsWith(pathPrefix)) { + return request; + } + } + } + + public List requests() { + return new ArrayList<>(allRequests); + } + + public List paths() { + return allRequests.stream().map(Request::path).toList(); + } + + private void handle(HttpExchange exchange) throws IOException { + try (exchange) { + byte[] raw = readAll(exchange.getRequestBody()); + if ("gzip".equals(exchange.getRequestHeaders().getFirst("Content-Encoding"))) { + raw = readAll(new GZIPInputStream(new java.io.ByteArrayInputStream(raw))); + } + String query = exchange.getRequestURI().getRawQuery(); + Request request = + new Request( + exchange.getRequestMethod(), + exchange.getRequestURI().getPath(), + query == null ? "" : query, + Map.copyOf(exchange.getRequestHeaders()), + new String(raw, StandardCharsets.UTF_8)); + allRequests.add(request); + requests.add(request); + + Response response; + String path = request.path(); + if (path.equals("/api/v2/events")) { + response = eventsHandler.apply(request); + if (configVersion.get() > 0) { + exchange + .getResponseHeaders() + .add("X-Exceptionless-ConfigVersion", Integer.toString(configVersion.get())); + } + } else if (path.equals("/api/v2/projects/config")) { + response = configHandler.apply(request); + } else { + response = Response.of(path.contains("heartbeat") ? 200 : 202); + } + response.headers().forEach((k, v) -> exchange.getResponseHeaders().add(k, v)); + byte[] body = response.body().getBytes(StandardCharsets.UTF_8); + if (response.status() == 304 || body.length == 0) { + exchange.sendResponseHeaders(response.status(), -1); + } else { + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(response.status(), body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } + } + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + in.transferTo(out); + return out.toByteArray(); + } + + @Override + public void close() { + server.stop(0); + } +} diff --git a/exceptionless-core/src/testFixtures/java/com/exceptionless/TestClients.java b/exceptionless-core/src/testFixtures/java/com/exceptionless/TestClients.java new file mode 100644 index 0000000..32f85f4 --- /dev/null +++ b/exceptionless-core/src/testFixtures/java/com/exceptionless/TestClients.java @@ -0,0 +1,184 @@ +package com.exceptionless; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import com.exceptionless.json.JsonParser; +import com.exceptionless.json.JsonWriter; +import com.exceptionless.models.UserDescription; +import com.exceptionless.submission.SettingsResponse; +import com.exceptionless.submission.SubmissionClient; +import com.exceptionless.submission.SubmissionResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +/** + * Test helpers for building clients that never touch the network. Shared with the integration + * modules through the {@code exceptionless-core} test jar. + */ +public final class TestClients { + public static final String API_KEY = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; + + /** How long {@link CapturingSubmissionClient#awaitEvents(ExceptionlessClient, int)} waits. */ + public static final Duration AWAIT_TIMEOUT = Duration.ofSeconds(10); + + private TestClients() {} + + /** A configuration with a valid API key and no global side effects. */ + public static ExceptionlessConfiguration config() { + return new ExceptionlessConfiguration() + .apiKey(API_KEY) + .serverUrl("http://localhost:1") + .captureUncaughtExceptions(false) + .registerShutdownHook(false) + .includeModules(false); + } + + /** Records every request and answers with configurable responses. */ + public static final class CapturingSubmissionClient implements SubmissionClient { + public final List posts = new CopyOnWriteArrayList<>(); + public final List heartbeats = new CopyOnWriteArrayList<>(); + public final List userDescriptions = new CopyOnWriteArrayList<>(); + public final List settingsRequests = new CopyOnWriteArrayList<>(); + public volatile Function eventsResponse = + json -> new SubmissionResponse(202, null, null); + public volatile SettingsResponse settingsResponse = new SettingsResponse.NotModified(); + + private final Object postLock = new Object(); + + /** + * A {@link TestClients#config() test configuration} that sends to this client and doesn't fetch + * settings when idle. + */ + public ExceptionlessConfiguration newConfiguration() { + return config().submissionClient(this).updateSettingsWhenIdleInterval(Duration.ZERO); + } + + /** A started client that sends to this one. */ + public ExceptionlessClient newClient() { + return new ExceptionlessClient(newConfiguration()).startup(); + } + + @Override + public SubmissionResponse postEvents(String eventsJson) { + posts.add(eventsJson); + synchronized (postLock) { + postLock.notifyAll(); + } + return eventsResponse.apply(eventsJson); + } + + @Override + public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { + userDescriptions.add(referenceId + "=" + JsonWriter.toJson(description)); + return new SubmissionResponse(202, null, null); + } + + @Override + public SettingsResponse getSettings(int version) { + settingsRequests.add(version); + return settingsResponse; + } + + @Override + public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { + heartbeats.add(sessionIdOrUserId + (closeSession ? ":close" : "")); + return new SubmissionResponse(200, null, null); + } + + /** All events posted so far, parsed. */ + @SuppressWarnings("unchecked") + public List> events() { + List> events = new ArrayList<>(); + for (String post : posts) { + events.addAll((List>) JsonParser.parse(post)); + } + return events; + } + + /** Sends the client's queued events and returns everything posted so far. */ + public List> flush(ExceptionlessClient client) { + if (!client.flush(AWAIT_TIMEOUT)) { + throw new AssertionError("Queued events were not sent; posted so far: " + events()); + } + return events(); + } + + /** + * Waits until at least {@code count} events have been posted by whatever sends them (for + * example a client being closed), without polling. + */ + public List> awaitEvents(int count, Duration timeout) + throws InterruptedException { + long deadline = System.nanoTime() + timeout.toNanos(); + synchronized (postLock) { + while (events().size() < count) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + throw new AssertionError( + "Expected " + count + " events within " + timeout + "; got " + events()); + } + TimeUnit.NANOSECONDS.timedWait(postLock, remaining); + } + } + return events(); + } + + /** + * Sends {@code client}'s queue until at least {@code count} events have been posted, for events + * that are submitted asynchronously (for example after an HTTP response was sent). + */ + public List> awaitEvents(ExceptionlessClient client, int count) { + await() + .atMost(AWAIT_TIMEOUT) + .pollInterval(Duration.ofMillis(25)) + .untilAsserted( + () -> { + client.flush(Duration.ofSeconds(2)); + assertThat(events()).hasSizeGreaterThanOrEqualTo(count); + }); + return events(); + } + } + + /** The event's {@code data}, or an empty map. */ + public static Map data(Map event) { + return map(event.get("data")); + } + + /** The event's {@code @error}, or an empty map. */ + public static Map error(Map event) { + return map(data(event).get("@error")); + } + + /** The event's {@code @request}, or an empty map. */ + public static Map request(Map event) { + return map(data(event).get("@request")); + } + + @SuppressWarnings("unchecked") + private static Map map(Object value) { + return value instanceof Map map ? (Map) map : Map.of(); + } + + /** Reads a nested value with a dotted path, e.g. {@code data.@error.type}. */ + @SuppressWarnings("unchecked") + public static Object path(Object root, String path) { + Object current = root; + for (String part : path.split("\\.(?![^\\[]*\\])")) { + if (current instanceof Map map) { + current = map.get(part); + } else if (current instanceof List list) { + current = list.get(Integer.parseInt(part)); + } else { + return null; + } + } + return current; + } +} diff --git a/exceptionless-e2e-tests/README.md b/exceptionless-e2e-tests/README.md new file mode 100644 index 0000000..8b94dfa --- /dev/null +++ b/exceptionless-e2e-tests/README.md @@ -0,0 +1,55 @@ +# Exceptionless end-to-end tests + +These tests send events with the Java client to a **real Exceptionless server** and check what the +server stored through its REST API. They prove the client's wire format (snake_case models, error +stacks, request/environment info, sessions, settings, batching and compression) against the server +itself rather than against mocks. This module is not published. + +## Running + +The tests are skipped unless you enable them, so a normal `mvn verify` doesn't need Docker. + +```bash +# Starts the published all-in-one server image with Testcontainers (needs Docker) +mvn -pl exceptionless-e2e-tests verify -De2e=true # or -Pe2e + +# Build exceptionless-core first if it isn't installed in your local repository +mvn -pl exceptionless-e2e-tests -am verify -De2e=true +``` + +Options (system properties): + +| Property | Default | Purpose | +|---|---|---| +| `e2e.image` | `exceptionless/exceptionless:8.10.0` | Server image to start | +| `e2e.serverUrl` | (empty) | Use an already running server (e.g. `http://localhost:7110`) instead of Docker. Use a fresh install: the first user to sign up gets the unlimited plan these tests need. | + +The first run pulls about 900 MB. Starting the server takes 30 to 60 seconds. The whole suite takes +about 4 minutes, mostly waiting for the server's session job to close sessions. + +## How it works + +- `ExceptionlessServer` starts `exceptionless/exceptionless` (built from the server's root + `Dockerfile`, target `exceptionless`), which runs the API, the background jobs + (`EX_RunJobsInProcess=true`) and Elasticsearch in one container. It waits for `/ready`, then + signs up a user (`POST /api/v2/auth/signup`) and creates an organization. +- Each test class creates its own project (`POST /api/v2/projects`) and client API key + (`POST /api/v2/projects/{id}/tokens` with the `client` scope), so settings changes don't leak. +- Clients use the real `DefaultSubmissionClient`, wrapped by `RecordingSubmissionClient` to record + every request and response. The raw request bodies are written to `target/e2e/*.json`. +- The server processes events asynchronously, so tests poll `GET /api/v2/projects/{id}/events`, + `/events/by-ref/{ref}` and `/stacks/{id}` until the data shows up. +- `JsonDiff` compares each event the client sent with what the server stored and fails on any value + the server dropped or changed. Because the server deserializes `@error`, `@request`, + `@environment` and friends into typed models and writes them back, a misnamed or mistyped field + shows up as missing. + +## Tests + +| Class | Covers | +|---|---| +| `EventWireFormatE2ETest` | Errors (cause chain, suppressed, custom properties, modules, stack frames, signature), environment info, submission client, logs with custom data, feature usage, 404 with request info (plus server-parsed browser and OS), user identity and description, reference ids with `updateUserEmailAndDescription`, manual stacking, a 300-event gzip batch | +| `StackingSignatureE2ETest` | Which frame the server stacks Java errors on, and the `UserNamespaces` setting | +| `SessionE2ETest` | Session start, heartbeat and end, for a user identity and for client-managed session ids | +| `SettingsE2ETest` | Project settings round trip, `@@log`, `@@error`, `@@usage` and `@@DataExclusions` filtering, and refresh triggered by the `X-Exceptionless-ConfigVersion` header | +| `ErrorResponseE2ETest` | Invalid API key (401) and request too large (413) | diff --git a/exceptionless-e2e-tests/pom.xml b/exceptionless-e2e-tests/pom.xml new file mode 100644 index 0000000..fc6db62 --- /dev/null +++ b/exceptionless-e2e-tests/pom.xml @@ -0,0 +1,109 @@ + + + 4.0.0 + + + com.exceptionless + exceptionless-parent + ${revision} + ../pom.xml + + + exceptionless-e2e-tests + Exceptionless End-to-End Tests + + Submits events with the Java client to a real Exceptionless server running in Docker and verifies what + the server stored. Not published. Skipped unless run with -De2e=true (or -Pe2e). + + + + com.exceptionless.e2e + 2.0.5 + + exceptionless/exceptionless:8.10.0 + + + true + + true + true + true + + + + + com.exceptionless + exceptionless-core + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.awaitility + awaitility + test + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + + org.slf4j + slf4j-api + ${slf4j.version} + test + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + ${skipE2E} + e2e + true + + ${e2e.image} + ${e2e.serverUrl} + ${project.build.directory}/e2e + info + + + + + + + + + e2e + + + e2e + true + + + + false + + + + diff --git a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/ErrorResponseE2ETest.java b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/ErrorResponseE2ETest.java new file mode 100644 index 0000000..b911ab4 --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/ErrorResponseE2ETest.java @@ -0,0 +1,101 @@ +package com.exceptionless.e2e; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.LogLevel; +import com.exceptionless.e2e.support.Await; +import com.exceptionless.e2e.support.ExceptionlessServer; +import com.exceptionless.e2e.support.Json; +import com.exceptionless.e2e.support.RecordingSubmissionClient.Call; +import com.exceptionless.e2e.support.TestProject; +import com.exceptionless.e2e.support.TestProject.TestClient; +import com.exceptionless.submission.SubmissionResponse; +import java.time.Duration; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** How the client reacts to the real server's error responses. */ +@Tag("e2e") +class ErrorResponseE2ETest { + + @Test + void invalidApiKeyDropsTheBatchAndSuspendsSubmission() throws Exception { + TestProject project = ExceptionlessServer.get().createProject("Errors"); + String invalidKey = "InvalidKey0000000000000000000000000000000"; + try (TestClient testClient = project.newClient(invalidKey, c -> {})) { + ExceptionlessClient client = testClient.client(); + client.createLog("ErrorResponses", "first", LogLevel.ERROR).submit(); + client.createLog("ErrorResponses", "second", LogLevel.ERROR).submit(); + + // 401: the batch is dropped (the queue drains) and submission is suspended. + assertThat(testClient.client().flush(Duration.ofSeconds(30))).isTrue(); + assertThat(testClient.recorder().calls("events")) + .singleElement() + .satisfies(c -> assertThat(c.status()).isEqualTo(401)); + assertThat(client.getQueue().size()).isZero(); + assertThat(client.getQueue().isSuspended()).isTrue(); + + // While suspended nothing is sent; new events wait in the queue. + client.createLog("ErrorResponses", "third", LogLevel.ERROR).submit(); + assertThat(client.flush(Duration.ofSeconds(5))).isFalse(); + assertThat(client.getQueue().size()).isEqualTo(1); + assertThat(testClient.recorder().calls("events")).hasSize(1); + + // Settings and heartbeats are rejected too, without breaking anything. + client.updateSettings().get(); + assertThat(testClient.recorder().calls("settings")) + .allSatisfy(c -> assertThat(c.message()).contains("401")); + SubmissionResponse heartbeat = client.submitSessionHeartbeat("someone@example.com").get(); + assertThat(heartbeat.statusCode()).isEqualTo(401); + testClient.savePayloads("invalid-api-key"); + } + assertThat(project.api().countEvents(project.id(), "source:ErrorResponses")).isZero(); + } + + @Test + void oversizedBatchIsSplitAfter413() { + // The server rejects request bodies over 200,000 bytes (MaximumEventPostSize, measured after + // compression) with 413. Random text barely compresses, so 50 events of 15 KB each don't fit. + TestProject project = ExceptionlessServer.get().createProject("Too large"); + String run = "e2e-" + UUID.randomUUID().toString().substring(0, 8); + Random random = new Random(42); + try (TestClient testClient = project.newClient(c -> {})) { + for (int i = 0; i < 50; i++) { + testClient + .client() + .createLog("TooLarge", "Large event " + i, LogLevel.INFO) + .addTags(run) + .setProperty("payload", randomText(random, 15_000)) + .submit(); + } + testClient.flush(); + testClient.savePayloads("too-large"); + List calls = testClient.recorder().calls("events"); + assertThat(calls).anySatisfy(c -> assertThat(c.status()).isEqualTo(413)); + assertThat(calls) + .filteredOn(c -> c.status() == 202) + .extracting(c -> Json.array(Json.parse(c.body())).size()) + .allSatisfy(size -> assertThat(size).isLessThan(50)); + long count = + Await.value( + "50 large events", + Await.EVENTS, + () -> project.api().countEvents(project.id(), "tag:" + run), + c -> c >= 50); + assertThat(count).isEqualTo(50); + } + } + + private static String randomText(Random random, int length) { + String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(alphabet.charAt(random.nextInt(alphabet.length()))); + } + return sb.toString(); + } +} diff --git a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/EventWireFormatE2ETest.java b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/EventWireFormatE2ETest.java new file mode 100644 index 0000000..6fcf2ff --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/EventWireFormatE2ETest.java @@ -0,0 +1,497 @@ +package com.exceptionless.e2e; + +import static com.exceptionless.e2e.support.Json.array; +import static com.exceptionless.e2e.support.Json.arrayAt; +import static com.exceptionless.e2e.support.Json.at; +import static com.exceptionless.e2e.support.Json.object; +import static com.exceptionless.e2e.support.Json.objectAt; +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.LogLevel; +import com.exceptionless.e2e.support.Await; +import com.exceptionless.e2e.support.ExceptionlessServer; +import com.exceptionless.e2e.support.JsonDiff; +import com.exceptionless.e2e.support.TestProject; +import com.exceptionless.e2e.support.TestProject.TestClient; +import com.exceptionless.models.RequestInfo; +import com.exceptionless.submission.SubmissionResponse; +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +/** + * Submits one of each kind of event with the Java client and checks what the real server stored. + */ +@Tag("e2e") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class EventWireFormatE2ETest { + private static final int BATCH_SIZE = 300; + private static final String CHROME_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.110 Safari/537.36"; + + private final String run = "e2e-" + UUID.randomUUID().toString().substring(0, 8); + private TestProject project; + private TestClient testClient; + private String errorReferenceId; + private SubmissionResponse userDescriptionResponse; + + @BeforeAll + void submitEvents() throws Exception { + project = ExceptionlessServer.get().createProject("Wire format"); + testClient = project.newClient(c -> c.version("1.2.3").addDefaultTags("java-e2e")); + ExceptionlessClient client = testClient.client(); + + // 1. Error with a cause chain, a suppressed exception and custom exception properties. + OrderProcessingException error = Orders.process("ord-42"); + error.addSuppressed(new IllegalArgumentException("cleanup failed")); + errorReferenceId = + client + .createException(error) + .addTags(run, "error") + .markAsCritical() + .setProperty("order", Map.of("id", "ord-42", "total", 99.95)) + .submit() + .getEvent() + .getReferenceId(); + + // 2. Log with level, source, tags and custom properties of several shapes. + Map nested = new LinkedHashMap<>(); + nested.put("enabled", true); + nested.put("limits", Map.of("daily", 5, "monthly", 100)); + client + .createLog("com.example.checkout.CheckoutService", "Payment authorized", LogLevel.INFO) + .addTags(run, "log", "payments") + .setProperty( + "customer", new Customer("Jane Doe", "jane@example.com", new Address("Dallas", "TX"))) + .setProperty("settings", nested) + .setProperty("items", List.of("sku-1", "sku-2", "sku-3")) + .setProperty("amount", new BigDecimal("12.50")) + .setProperty("attempt", 3) + .setValue(12.5) + .submit(); + + // 3. Feature usage. + client.createFeatureUsage("checkout.express").addTags(run, "feature").submit(); + + // 4. 404 with full request info. + Map> headers = new LinkedHashMap<>(); + headers.put("Accept", List.of("text/html", "application/xhtml+xml")); + headers.put("X-Request-Id", List.of("req-123")); + headers.put("Authorization", List.of("Bearer secret")); // removed by the client + RequestInfo request = new RequestInfo(); + request.setHttpMethod("GET"); + request.setSecure(true); + request.setHost("shop.example.com"); + request.setPort(443); + request.setPath("/products/missing"); + request.setReferrer("https://www.example.com/search"); + request.setClientIpAddress("203.0.113.10"); + request.setUserAgent(CHROME_UA); + request.setHeaders(headers); + request.setCookies(Map.of("theme", "dark", "visitor", "v-1")); + request.setQueryString(Map.of("q", "shoes", "page", "2")); + client + .createNotFound("/products/missing") + .setRequestInfo(request) + .addTags(run, "notfound") + .submit(); + + // 5. User identity and user description. + client + .createLog("UserFeedback", "Feedback submitted", LogLevel.WARN) + .setUserIdentity("user-123", "Jane Doe") + .setUserDescription("jane@example.com", "The checkout page froze") + .addTags(run, "user") + .submit(); + + // 6. Manual stacking: two different messages, one stack. + for (int i = 1; i <= 2; i++) { + client + .createLog("Billing", "Invoice " + i + " failed to send", LogLevel.ERROR) + .setManualStackingKey("invoice-delivery-" + run, "Invoice delivery failures") + .addTags(run, "manual", "manual-" + i) + .submit(); + } + + // 7. A large batch, compressed and split by the client. + for (int i = 0; i < BATCH_SIZE; i++) { + client + .createLog("Batch", "Batch event " + i + " " + "x".repeat(200), LogLevel.DEBUG) + .addTags(run, "batch") + .setProperty("index", i) + .submit(); + } + + testClient.flush(); + + // 8. Attach a user description to the error by its reference id. + userDescriptionResponse = + client + .updateUserEmailAndDescription( + errorReferenceId, "support@example.com", "It broke after I paid") + .get(); + testClient.savePayloads("wire-format"); + } + + @AfterAll + void close() { + if (testClient != null) { + testClient.close(); + } + } + + @Test + void clientRequestsWereAccepted() { + assertThat(testClient.recorder().calls("events")) + .isNotEmpty() + .allSatisfy(call -> assertThat(call.status()).as(call.message()).isEqualTo(202)); + assertThat(userDescriptionResponse.statusCode()) + .as(userDescriptionResponse.message()) + .isEqualTo(202); + } + + @Test + void errorRoundTripsWithoutLosingFields() { + Map stored = project.awaitEvent("tag:" + run + " tag:error"); + Map sent = testClient.sentEventWithTag("error"); + assertThat(JsonDiff.diff(sent, stored)).as("fields the server dropped or changed").isEmpty(); + } + + @Test + void errorHasStackTraceCauseChainAndModules() { + Map event = project.awaitEvent("tag:" + run + " tag:error"); + assertThat(event).containsEntry("type", "error"); + assertThat(event.get("message")).isEqualTo("Unable to process order ord-42"); + assertThat(array(event.get("tags"))).contains(run, "error", "java-e2e", "Critical"); + assertThat(event.get("reference_id")).isEqualTo(errorReferenceId); + + Map error = objectAt(event, "data", "@error"); + assertThat(error).containsEntry("type", OrderProcessingException.class.getName()); + assertThat(error).containsEntry("message", "Unable to process order ord-42"); + + Map frame = object(arrayAt(error, "stack_trace").get(0)); + assertThat(frame) + .containsEntry("declaring_namespace", "com.exceptionless.e2e") + .containsEntry("declaring_type", "EventWireFormatE2ETest$Orders") + .containsEntry("name", "process") + .containsEntry("file_name", "EventWireFormatE2ETest.java") + .containsKey("line_number"); + assertThat(((Number) frame.get("line_number")).intValue()).isPositive(); + + // Custom exception properties. + Map ext = objectAt(error, "data", "@ext"); + assertThat(ext).containsEntry("orderId", "ord-42").containsEntry("attempts", 3L); + + // Cause chain. + Map inner = objectAt(error, "inner"); + assertThat(inner) + .containsEntry("type", "java.lang.IllegalStateException") + .containsEntry("message", "Inventory unavailable for ord-42"); + assertThat(object(arrayAt(inner, "stack_trace").get(0))).containsEntry("name", "reserve"); + Map innermost = objectAt(inner, "inner"); + assertThat(innermost).containsEntry("type", InventoryException.class.getName()); + + // Modules (jars on the class path) are attached to the outermost error. + List modules = arrayAt(error, "modules"); + assertThat(modules).isNotEmpty(); + assertThat(modules) + .anySatisfy(m -> assertThat(object(m)).containsKeys("module_id", "name", "version")); + assertThat(modules) + .anySatisfy(m -> assertThat(String.valueOf(object(m).get("name"))).contains("junit")); + + assertThat(event.get("data")) + .satisfies(d -> assertThat(object(d)).containsEntry("@version", "1.2.3")); + assertThat(objectAt(event, "data", "order")).containsEntry("id", "ord-42"); + } + + @Test + void errorIsStackedOnTheInnermostUserFrame() { + Map event = project.awaitEvent("tag:" + run + " tag:error"); + Map stack = project.api().getStack((String) event.get("stack_id")); + Map signature = objectAt(stack, "signature_info"); + // The server walks the cause chain from the innermost error and takes its first "user" frame. + assertThat(signature).containsEntry("ExceptionType", InventoryException.class.getName()); + assertThat(String.valueOf(signature.get("Method"))) + .startsWith("com.exceptionless.e2e.EventWireFormatE2ETest$Inventory.check("); + assertThat(stack.get("title")).isEqualTo("Unable to process order ord-42"); + + // The server flags the frame it used. + Map innermost = objectAt(event, "data", "@error", "inner", "inner"); + assertThat(object(arrayAt(innermost, "stack_trace").get(0))) + .containsEntry("is_signature_target", true); + } + + @Test + void environmentInfoIsStored() { + Map event = project.awaitEvent("tag:" + run + " tag:error"); + Map environment = objectAt(event, "data", "@environment"); + assertThat(environment) + .containsKeys( + "processor_count", + "total_physical_memory", + "available_physical_memory", + "process_name", + "process_id", + "process_memory_size", + "thread_name", + "thread_id", + "architecture", + "o_s_name", + "o_s_version", + "machine_name", + "install_id", + "runtime_version", + "ip_address"); + assertThat(environment.get("o_s_name")).isEqualTo(System.getProperty("os.name")); + assertThat(((Number) environment.get("processor_count")).intValue()) + .isEqualTo(Runtime.getRuntime().availableProcessors()); + assertThat(String.valueOf(environment.get("runtime_version"))) + .contains(System.getProperty("java.version")); + assertThat(environment.get("install_id")).isEqualTo(testClient.client().getInstallId()); + assertThat(environment.get("process_id")) + .isEqualTo(String.valueOf(ProcessHandle.current().pid())); + } + + @Test + void submissionClientIsRecognized() { + Map event = project.awaitEvent("tag:" + run + " tag:error"); + Map submissionClient = objectAt(event, "data", "@submission_client"); + assertThat(submissionClient).containsEntry("user_agent", "exceptionless-java"); + assertThat(String.valueOf(submissionClient.get("version"))) + .matches("\\d+\\.\\d+(\\.\\d+){0,2}"); + } + + @Test + void referenceIdLookupAndUserDescription() { + List byRef = + Await.value( + "event by reference id", + Await.EVENTS, + () -> project.api().getEventsByReference(errorReferenceId), + l -> !l.isEmpty()); + assertThat(object(byRef.get(0))).containsEntry("reference_id", errorReferenceId); + + // The user description is applied by a background job once the event exists. + Map event = + Await.value( + "the user description on the error", + Await.EVENTS, + () -> project.api().getEvent((String) object(byRef.get(0)).get("id")), + e -> at(e, "data", "@user_description") != null); + assertThat(objectAt(event, "data", "@user_description")) + .containsEntry("email_address", "support@example.com") + .containsEntry("description", "It broke after I paid"); + } + + @Test + void logRoundTripsWithLevelSourceTagsAndProperties() { + Map stored = project.awaitEvent("tag:" + run + " tag:log"); + assertThat(JsonDiff.diff(testClient.sentEventWithTag("log"), stored)) + .as("fields the server dropped or changed") + .isEmpty(); + + assertThat(stored) + .containsEntry("type", "log") + .containsEntry("source", "com.example.checkout.CheckoutService") + .containsEntry("message", "Payment authorized"); + assertThat(new BigDecimal(String.valueOf(stored.get("value")))).isEqualByComparingTo("12.5"); + assertThat(array(stored.get("tags"))).contains(run, "log", "payments", "java-e2e"); + Map data = object(stored.get("data")); + assertThat(data).containsEntry("@level", "info"); + assertThat(objectAt(data, "customer")).containsEntry("name", "Jane Doe"); + assertThat(objectAt(data, "customer", "address")).containsEntry("city", "Dallas"); + assertThat(objectAt(data, "settings", "limits")).containsEntry("daily", 5L); + assertThat(arrayAt(data, "items")).containsExactly("sku-1", "sku-2", "sku-3"); + assertThat(data).containsEntry("attempt", 3L); + } + + @Test + void featureUsageIsStored() { + Map stored = project.awaitEvent("tag:" + run + " tag:feature"); + assertThat(JsonDiff.diff(testClient.sentEventWithTag("feature"), stored)).isEmpty(); + assertThat(stored).containsEntry("type", "usage").containsEntry("source", "checkout.express"); + } + + @Test + void notFoundRequestInfoIsStored() { + Map stored = project.awaitEvent("tag:" + run + " tag:notfound"); + Map sent = testClient.sentEventWithTag("notfound"); + // The server drops @environment from 404s (NotFoundPlugin); compare the rest. + Map sentWithoutEnvironment = new LinkedHashMap<>(sent); + Map sentData = new LinkedHashMap<>(object(sent.get("data"))); + sentData.remove("@environment"); + sentWithoutEnvironment.put("data", sentData); + assertThat(JsonDiff.diff(sentWithoutEnvironment, stored)) + .as("fields the server dropped or changed") + .isEmpty(); + + assertThat(stored).containsEntry("type", "404").containsEntry("source", "/products/missing"); + Map request = objectAt(stored, "data", "@request"); + assertThat(request) + .containsEntry("http_method", "GET") + .containsEntry("is_secure", true) + .containsEntry("host", "shop.example.com") + .containsEntry("port", 443L) + .containsEntry("path", "/products/missing") + .containsEntry("referrer", "https://www.example.com/search") + .containsEntry("client_ip_address", "203.0.113.10") + .containsEntry("user_agent", CHROME_UA); + assertThat(objectAt(request, "headers")) + .containsEntry("Accept", List.of("text/html", "application/xhtml+xml")) + .doesNotContainKey("Authorization"); + assertThat(objectAt(request, "cookies")).containsEntry("theme", "dark"); + assertThat(objectAt(request, "query_string")) + .containsEntry("q", "shoes") + .containsEntry("page", "2"); + // Derived by the server from the user agent. + assertThat(objectAt(request, "data")) + .containsEntry("@browser", "Chrome") + .containsEntry("@os", "Windows") + .containsEntry("@is_bot", false); + } + + @Test + void userIdentityAndDescriptionAreStored() { + Map stored = project.awaitEvent("tag:" + run + " tag:user"); + assertThat(JsonDiff.diff(testClient.sentEventWithTag("user"), stored)).isEmpty(); + assertThat(objectAt(stored, "data", "@user")) + .containsEntry("identity", "user-123") + .containsEntry("name", "Jane Doe"); + assertThat(objectAt(stored, "data", "@user_description")) + .containsEntry("email_address", "jane@example.com") + .containsEntry("description", "The checkout page froze"); + assertThat(objectAt(stored, "data")).containsEntry("@level", "warn"); + } + + @Test + void manualStackingKeyGroupsEventsWithTitle() { + List events = project.awaitEvents("tag:" + run + " tag:manual", 2); + assertThat(events).hasSize(2); + String stackId = (String) object(events.get(0)).get("stack_id"); + assertThat(object(events.get(1)).get("stack_id")).isEqualTo(stackId); + Map stack = project.api().getStack(stackId); + assertThat(stack.get("title")).isEqualTo("Invoice delivery failures"); + assertThat(objectAt(stack, "signature_info")) + .containsEntry("ManualStackingKey", "invoice-delivery-" + run); + assertThat(objectAt(object(events.get(0)), "data", "@stack")) + .containsEntry("title", "Invoice delivery failures"); + } + + @Test + void largeBatchIsAcceptedCompletely() { + long batchRequests = testClient.recorder().calls("events").size(); + long count = + Await.value( + BATCH_SIZE + " batch events", + Await.EVENTS, + () -> project.api().countEvents(project.id(), "tag:" + run + " tag:batch"), + c -> c >= BATCH_SIZE); + assertThat(count).isEqualTo(BATCH_SIZE); + // More events than one batch holds, so the client had to split them. + assertThat(batchRequests).isGreaterThan(1); + } + + // --------------------------------------------------------------------------------------------- + // Test fixtures + + static final class Orders { + static OrderProcessingException process(String orderId) { + try { + Inventory.reserve(orderId); + throw new AssertionError("unreachable"); + } catch (IllegalStateException e) { + return new OrderProcessingException("Unable to process order " + orderId, e, orderId, 3); + } + } + } + + static final class Inventory { + static void reserve(String orderId) { + try { + check(orderId); + } catch (InventoryException e) { + throw new IllegalStateException("Inventory unavailable for " + orderId, e); + } + } + + static void check(String orderId) throws InventoryException { + throw new InventoryException("Out of stock: " + orderId); + } + } + + static final class InventoryException extends Exception { + InventoryException(String message) { + super(message); + } + } + + // Must be public: the client only reads custom properties from public exception classes. + public static final class OrderProcessingException extends RuntimeException { + private final String orderId; + private final int attempts; + + OrderProcessingException(String message, Throwable cause, String orderId, int attempts) { + super(message, cause); + this.orderId = orderId; + this.attempts = attempts; + } + + public String getOrderId() { + return orderId; + } + + public int getAttempts() { + return attempts; + } + } + + public static final class Customer { + private final String name; + private final String email; + private final Address address; + + Customer(String name, String email, Address address) { + this.name = name; + this.email = email; + this.address = address; + } + + public String getName() { + return name; + } + + public String getEmail() { + return email; + } + + public Address getAddress() { + return address; + } + } + + public static final class Address { + private final String city; + private final String state; + + Address(String city, String state) { + this.city = city; + this.state = state; + } + + public String getCity() { + return city; + } + + public String getState() { + return state; + } + } +} diff --git a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/SessionE2ETest.java b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/SessionE2ETest.java new file mode 100644 index 0000000..f2255fa --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/SessionE2ETest.java @@ -0,0 +1,123 @@ +package com.exceptionless.e2e; + +import static com.exceptionless.e2e.support.Json.at; +import static com.exceptionless.e2e.support.Json.objectAt; +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.LogLevel; +import com.exceptionless.e2e.support.Await; +import com.exceptionless.e2e.support.ExceptionlessServer; +import com.exceptionless.e2e.support.Json; +import com.exceptionless.e2e.support.TestProject; +import com.exceptionless.e2e.support.TestProject.TestClient; +import com.exceptionless.submission.SubmissionResponse; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +/** + * Sessions: start events, heartbeats and ends. The server closes sessions in a background job + * (CloseInactiveSessionsJob, every 30 s, only for sessions older than a minute), so these tests + * wait up to a few minutes. + */ +@Tag("e2e") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SessionE2ETest { + private static final Duration SESSION_CLOSE = Duration.ofMinutes(3); + private final String run = "e2e-" + UUID.randomUUID().toString().substring(0, 8); + private TestProject project; + + @BeforeAll + void createProject() { + project = ExceptionlessServer.get().createProject("Sessions"); + } + + @Test + void sessionForSignedInUser() throws Exception { + String identity = "session-user-" + run; + try (TestClient client = + project.newClient( + c -> + c.userIdentity(identity, "Session User") + .useSessions() + .addDefaultTags(run, "identity"))) { + client + .client() + .createLog("SessionTest", "Did something", LogLevel.INFO) + .addTags("identity-log") + .submit(); + client.flush(); + + SubmissionResponse heartbeat = client.client().submitSessionHeartbeat(null).get(); + assertThat(heartbeat.statusCode()).as(heartbeat.message()).isEqualTo(200); + + Map start = project.awaitEvent("type:session tag:" + run + " tag:identity"); + assertThat(objectAt(start, "data", "@user")) + .containsEntry("identity", identity) + .containsEntry("name", "Session User"); + assertThat(objectAt(start, "data", "@environment")).containsKey("machine_name"); + String sessionId = (String) start.get("reference_id"); + assertThat(sessionId).as("the server assigns a session id to identity sessions").isNotBlank(); + + Map log = project.awaitEvent("tag:" + run + " tag:identity-log"); + assertThat(at(log, "data", "@ref:session")).isEqualTo(sessionId); + + SubmissionResponse end = client.client().submitSessionEnd(null).get(); + assertThat(end.statusCode()).as(end.message()).isEqualTo(200); + client.savePayloads("session-identity"); + + Map closed = awaitClosedSession(sessionId); + assertThat(closed.get("value")).isNotNull(); + // Note: the periodic HeartbeatPlugin timer is only stopped by a "sessionend" event, not by + // submitSessionEnd(), so a further heartbeat for the ended session may follow after 60 s. + assertThat(client.recorder().calls("heartbeat")) + .extracting(c -> c.detail()) + .startsWith(identity, identity + " close"); + } + } + + @Test + void sessionWithClientManagedSessionId() throws Exception { + try (TestClient client = + project.newClient( + c -> + c.useSessions(false, Duration.ofSeconds(30), true) + .addDefaultTags(run, "managed"))) { + String sessionId = client.client().getConfiguration().getCurrentSessionIdentifier(); + assertThat(sessionId).isNotBlank(); + client.client().createFeatureUsage("managed.feature").addTags("managed-usage").submit(); + client.flush(); + + Map start = project.awaitEvent("type:session tag:" + run + " tag:managed"); + assertThat(start.get("reference_id")).isEqualTo(sessionId); + Map usage = project.awaitEvent("tag:" + run + " tag:managed-usage"); + assertThat(at(usage, "data", "@ref:session")).isEqualTo(sessionId); + + SubmissionResponse end = client.client().submitSessionEnd(null).get(); + assertThat(end.statusCode()).as(end.message()).isEqualTo(200); + client.savePayloads("session-managed"); + + awaitClosedSession(sessionId); + } + } + + private Map awaitClosedSession(String sessionId) { + return Await.value( + "session " + sessionId + " to be closed", + SESSION_CLOSE, + () -> { + List events = project.api().getEventsByReference(sessionId); + return events.stream() + .map(Json::object) + .filter(e -> "session".equals(e.get("type"))) + .findFirst() + .orElse(null); + }, + e -> at(e, "data", "sessionend") != null); + } +} diff --git a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/SettingsE2ETest.java b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/SettingsE2ETest.java new file mode 100644 index 0000000..b787691 --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/SettingsE2ETest.java @@ -0,0 +1,141 @@ +package com.exceptionless.e2e; + +import static com.exceptionless.e2e.support.Json.object; +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.LogLevel; +import com.exceptionless.e2e.support.Await; +import com.exceptionless.e2e.support.ExceptionlessServer; +import com.exceptionless.e2e.support.RecordingSubmissionClient.Call; +import com.exceptionless.e2e.support.TestProject; +import com.exceptionless.e2e.support.TestProject.TestClient; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Project settings: fetched from the server, applied by the client, refreshed on config version + * changes. + */ +@Tag("e2e") +class SettingsE2ETest { + private final String run = "e2e-" + UUID.randomUUID().toString().substring(0, 8); + + @Test + void settingsRoundTripAndAreApplied() throws Exception { + TestProject project = ExceptionlessServer.get().createProject("Settings"); + project.api().setProjectConfig(project.id(), "@@log:*", "Warn"); + project.api().setProjectConfig(project.id(), "@@log:com.example.noisy.*", "Off"); + project + .api() + .setProjectConfig(project.id(), "@@error:java.lang.IllegalArgumentException", "false"); + project.api().setProjectConfig(project.id(), "@@usage:hidden.feature", "false"); + int serverVersion = + ((Number) project.api().getProjectConfig(project.id()).get("version")).intValue(); + + // Polling every 2 hours: after the initial fetch, only the config version header can refresh + // settings. + try (TestClient testClient = + project.newClient(c -> c.updateSettingsWhenIdleInterval(Duration.ofHours(2)))) { + ExceptionlessClient client = testClient.client(); + // The first fetch is scheduled 5 s after startup; also fetch explicitly. + Await.until( + "the scheduled settings fetch", + Duration.ofSeconds(30), + () -> !testClient.recorder().calls("settings").isEmpty()); + client.updateSettings().get(); + + Map settings = client.getConfiguration().getSettings().asMap(); + assertThat(settings) + .containsEntry("@@log:*", "Warn") + .containsEntry("@@log:com.example.noisy.*", "Off") + .containsEntry("@@error:java.lang.IllegalArgumentException", "false") + .containsKey("@@UserAgentBotPatterns"); + assertThat(testClient.recorder().calls("settings")) + .anySatisfy(c -> assertThat(c.configVersion()).isEqualTo(serverVersion)); + + // The client filters with them. + assertThat( + client + .createLog("com.example.App", "info", LogLevel.INFO) + .addTags(run) + .submit() + .isCancelled()) + .isTrue(); + assertThat( + client + .createLog("com.example.App", "warn", LogLevel.WARN) + .addTags(run, "kept-log") + .submit() + .isCancelled()) + .isFalse(); + assertThat( + client + .createLog("com.example.noisy.Worker", "error", LogLevel.ERROR) + .addTags(run) + .submit() + .isCancelled()) + .isTrue(); + assertThat( + client + .createException(new IllegalArgumentException("ignored")) + .addTags(run) + .submit() + .isCancelled()) + .isTrue(); + assertThat( + client + .createException(new IllegalStateException("kept")) + .addTags(run, "kept-error") + .submit() + .isCancelled()) + .isFalse(); + assertThat(client.createFeatureUsage("hidden.feature").addTags(run).submit().isCancelled()) + .isTrue(); + testClient.flush(); + project.awaitEvents("tag:" + run, 2); + assertThat(project.api().countEvents(project.id(), "tag:" + run)).isEqualTo(2); + + // A settings change on the server bumps the config version, which the server returns in the + // X-Exceptionless-ConfigVersion header of every event post; the client then refetches. + int settingsCalls = testClient.recorder().calls("settings").size(); + project.api().setProjectConfig(project.id(), "@@DataExclusions", "*secret*"); + int newVersion = + ((Number) project.api().getProjectConfig(project.id()).get("version")).intValue(); + assertThat(newVersion).isGreaterThan(serverVersion); + + client + .createLog("com.example.App", "trigger", LogLevel.ERROR) + .addTags(run, "trigger") + .submit(); + testClient.flush(); + Await.until( + "the client to apply the new settings", + Duration.ofSeconds(20), + () -> + "*secret*" + .equals(client.getConfiguration().getSettings().getString("@@DataExclusions"))); + List events = testClient.recorder().calls("events"); + assertThat(events.get(events.size() - 1).configVersion()).isEqualTo(newVersion); + assertThat(testClient.recorder().calls("settings")).hasSizeGreaterThan(settingsCalls); + + // And the new data exclusion applies. + client + .createLog("com.example.App", "with data", LogLevel.ERROR) + .addTags(run, "exclusions") + .setProperty("api_secret", "hunter2") + .setProperty("visible", "yes") + .submit(); + testClient.flush(); + testClient.savePayloads("settings"); + Map stored = project.awaitEvent("tag:" + run + " tag:exclusions"); + assertThat(object(stored.get("data"))) + .containsEntry("visible", "yes") + .doesNotContainKey("api_secret"); + } + } +} diff --git a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/StackingSignatureE2ETest.java b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/StackingSignatureE2ETest.java new file mode 100644 index 0000000..40ce4f9 --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/StackingSignatureE2ETest.java @@ -0,0 +1,118 @@ +package com.exceptionless.e2e; + +import static com.exceptionless.e2e.support.Json.objectAt; +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.e2e.support.ExceptionlessServer; +import com.exceptionless.e2e.support.TestProject; +import com.exceptionless.e2e.support.TestProject.TestClient; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; + +/** + * How the server builds stack signatures for Java errors thrown inside the JDK. + * + *

The server's {@code ErrorSignature} starts at the innermost error and takes its first "user" + * frame, where only namespaces starting with {@code System} or {@code Microsoft} are considered + * non-user code. Java's {@code java.*}/{@code jdk.*} frames therefore count as user code, so an + * exception thrown by the JDK is stacked on the JDK method, not the application method that called + * it. These tests document that behaviour (companion server change #1 in issue #91) and the {@code + * UserNamespaces} project setting that works around it. + */ +@Tag("e2e") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class StackingSignatureE2ETest { + private final String run = "e2e-" + UUID.randomUUID().toString().substring(0, 8); + private TestProject project; + + @BeforeAll + void createProject() { + project = ExceptionlessServer.get().createProject("Stacking"); + } + + @Test + @Order(1) + void jdkFramesAreTreatedAsUserCodeByDefault() { + submit("default-a", Quantities::parseCartQuantity); + submit("default-b", Quantities::parseStockLevel); + + Map a = project.awaitEvent("tag:" + run + " tag:default-a"); + Map b = project.awaitEvent("tag:" + run + " tag:default-b"); + Map signature = + objectAt(project.api().getStack((String) a.get("stack_id")), "signature_info"); + System.out.println("[finding] default signature for Integer.parseInt failure: " + signature); + + // The JDK frame that threw is used, so... + assertThat(signature).containsEntry("ExceptionType", "java.lang.NumberFormatException"); + assertThat(String.valueOf(signature.get("Method"))) + .startsWith("java.lang.NumberFormatException.forInputString("); + // ...two unrelated call sites end up in the same stack. + assertThat(b.get("stack_id")).isEqualTo(a.get("stack_id")); + } + + @Test + @Order(2) + void userNamespacesProjectConfigSettingIsIgnored() { + // This is where the UI's "User Namespaces" project setting is saved (POST + // /projects/{id}/config)... + project.api().setProjectConfig(project.id(), "UserNamespaces", "com.exceptionless.e2e"); + submit("config-a", Quantities::parseCartQuantity); + + Map event = project.awaitEvent("tag:" + run + " tag:config-a"); + Map signature = + objectAt(project.api().getStack((String) event.get("stack_id")), "signature_info"); + System.out.println("[finding] signature with UserNamespaces in project config: " + signature); + // ...but the event pipeline only reads UserNamespaces from organization/project *data*. + assertThat(String.valueOf(signature.get("Method"))) + .startsWith("java.lang.NumberFormatException.forInputString("); + } + + @Test + @Order(3) + void userNamespacesProjectDataPicksTheApplicationFrame() { + project.api().setProjectData(project.id(), "UserNamespaces", "com.exceptionless.e2e"); + submit("data-a", Quantities::parseCartQuantity); + submit("data-b", Quantities::parseStockLevel); + + Map a = project.awaitEvent("tag:" + run + " tag:data-a"); + Map b = project.awaitEvent("tag:" + run + " tag:data-b"); + Map signature = + objectAt(project.api().getStack((String) a.get("stack_id")), "signature_info"); + System.out.println("[finding] signature with UserNamespaces in project data: " + signature); + assertThat(signature).containsEntry("ExceptionType", "java.lang.NumberFormatException"); + assertThat(String.valueOf(signature.get("Method"))) + .startsWith("com.exceptionless.e2e.StackingSignatureE2ETest$Quantities.parseCartQuantity("); + assertThat(b.get("stack_id")).isNotEqualTo(a.get("stack_id")); + } + + private void submit(String tag, Runnable failing) { + // A fresh client each time so the client's duplicate checker can't drop the repeat. + try (TestClient client = project.newClient(c -> {})) { + try { + failing.run(); + throw new AssertionError("expected an exception"); + } catch (NumberFormatException e) { + client.client().createException(e).addTags(run, tag).submit(); + } + client.flush(); + } + } + + static final class Quantities { + static void parseCartQuantity() { + Integer.parseInt("two"); + } + + static void parseStockLevel() { + Integer.parseInt("many"); + } + } +} diff --git a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/Await.java b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/Await.java new file mode 100644 index 0000000..2539c1c --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/Await.java @@ -0,0 +1,47 @@ +package com.exceptionless.e2e.support; + +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.function.BooleanSupplier; +import java.util.function.Predicate; +import java.util.function.Supplier; +import org.awaitility.pollinterval.IterativePollInterval; + +/** + * Waits until the server has processed events (it does so asynchronously through queues), polling + * with Awaitility from 250 ms, doubling up to 2 s between attempts. + */ +public final class Await { + /** Default wait for event processing. Usually takes 1-3 seconds against the all-in-one image. */ + public static final Duration EVENTS = Duration.ofSeconds(90); + + private static final Duration FIRST_POLL = Duration.ofMillis(250); + private static final Duration MAX_POLL = Duration.ofSeconds(2); + + private Await() {} + + public static void until(String description, Duration timeout, BooleanSupplier condition) { + value(description, timeout, condition::getAsBoolean, Boolean::booleanValue); + } + + /** + * Returns the first non-null value matching {@code accept}, failing after {@code timeout}. + * Exceptions thrown while polling (e.g. the server isn't ready yet) count as no match. + */ + public static T value( + String description, Duration timeout, Supplier supplier, Predicate accept) { + return await(description) + .atMost(timeout) + .pollDelay(Duration.ZERO) + .pollInterval( + new IterativePollInterval( + previous -> { + Duration doubled = previous.multipliedBy(2); + return doubled.compareTo(MAX_POLL) > 0 ? MAX_POLL : doubled; + }, + FIRST_POLL)) + .ignoreExceptions() + .until(supplier::get, value -> value != null && accept.test(value)); + } +} diff --git a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/ExceptionlessServer.java b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/ExceptionlessServer.java new file mode 100644 index 0000000..0d0a4a5 --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/ExceptionlessServer.java @@ -0,0 +1,112 @@ +package com.exceptionless.e2e.support; + +import java.time.Duration; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * The Exceptionless server under test, started once per test JVM. + * + *

By default this runs the published all-in-one image ({@code exceptionless/exceptionless}, + * built from the server's root {@code Dockerfile} target {@code exceptionless}): the API, the + * background jobs ({@code EX_RunJobsInProcess=true}) and Elasticsearch in one container, with an + * in-memory queue/cache. Set {@code -De2e.serverUrl=http://host:port} to test an already running + * server instead (for example one started from the server repo with Aspire). + * + *

A single account (user, organization) is created on first use; tests create their own projects + * so settings changes can't leak between them. + */ +public final class ExceptionlessServer { + private static final Logger LOG = LoggerFactory.getLogger(ExceptionlessServer.class); + private static final int HTTP_PORT = 8080; + private static ExceptionlessServer instance; + + private final GenericContainer container; + private final ServerApi api; + private final String organizationId; + + private ExceptionlessServer(GenericContainer container, String baseUrl) { + this.container = container; + this.api = new ServerApi(baseUrl); + String suffix = UUID.randomUUID().toString().substring(0, 8); + api.signup("E2E Tester", "e2e-" + suffix + "@example.com", "E2e-Password-" + suffix); + this.organizationId = api.createOrganization("Java E2E " + suffix); + LOG.info("Exceptionless server ready at {} (organization {})", baseUrl, organizationId); + } + + /** Starts the server (once) and bootstraps an account. */ + public static synchronized ExceptionlessServer get() { + if (instance == null) { + String serverUrl = System.getProperty("e2e.serverUrl", ""); + if (!serverUrl.isBlank()) { + instance = new ExceptionlessServer(null, serverUrl.trim()); + } else { + instance = startContainer(); + } + } + return instance; + } + + @SuppressWarnings("resource") // stopped by the shutdown hook below (and by Testcontainers' Ryuk) + private static ExceptionlessServer startContainer() { + String image = System.getProperty("e2e.image", "exceptionless/exceptionless:8.10.0"); + LOG.info( + "Starting {} (first start pulls ~900 MB and boots Elasticsearch; allow a few minutes)", + image); + GenericContainer container = + new GenericContainer<>(DockerImageName.parse(image)) + .withExposedPorts(HTTP_PORT) + .withEnv("EX_AppMode", "Production") + .withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx1g") + .withLogConsumer( + new Slf4jLogConsumer(LoggerFactory.getLogger("exceptionless-server")) + .withSeparateOutputStreams()) + // /ready only succeeds once Elasticsearch, storage and the startup actions are healthy. + .waitingFor( + Wait.forHttp("/ready") + .forPort(HTTP_PORT) + .forStatusCode(200) + .withStartupTimeout(Duration.ofMinutes(5))); + container.start(); + Runtime.getRuntime() + .addShutdownHook(new Thread(container::stop, "exceptionless-e2e-server-stop")); + String baseUrl = "http://" + container.getHost() + ":" + container.getMappedPort(HTTP_PORT); + // The startup actions (index creation) gate requests; wait for the API itself to answer. + Await.until( + "the API to answer", + Duration.ofMinutes(2), + () -> new ServerApi(baseUrl).send("GET", "/api/v2/about", null, null).status() == 200); + return new ExceptionlessServer(container, baseUrl); + } + + public ServerApi api() { + return api; + } + + public String baseUrl() { + return api.baseUrl(); + } + + public String organizationId() { + return organizationId; + } + + /** The container's log so far (empty when testing an external server). */ + public String logs() { + return container == null ? "" : container.getLogs(); + } + + /** Creates a project with a client API key. */ + public TestProject createProject(String name) { + String projectId = + api.createProject( + organizationId, name + " " + UUID.randomUUID().toString().substring(0, 6)); + String apiKey = api.createClientToken(projectId); + return new TestProject(this, projectId, apiKey); + } +} diff --git a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/Json.java b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/Json.java new file mode 100644 index 0000000..14349e3 --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/Json.java @@ -0,0 +1,86 @@ +package com.exceptionless.e2e.support; + +import com.exceptionless.json.JsonParser; +import java.util.List; +import java.util.Map; + +/** Navigation helpers for JSON parsed with the client's {@link JsonParser}. */ +public final class Json { + private Json() {} + + public static Object parse(String json) { + return JsonParser.parse(json); + } + + @SuppressWarnings("unchecked") + public static Map object(Object value) { + if (!(value instanceof Map)) { + throw new AssertionError("Expected a JSON object but got: " + value); + } + return (Map) value; + } + + @SuppressWarnings("unchecked") + public static List array(Object value) { + if (!(value instanceof List)) { + throw new AssertionError("Expected a JSON array but got: " + value); + } + return (List) value; + } + + /** + * Follows a path of object keys and array indexes, e.g. {@code at(event, "data", "@error", + * "stack_trace", 0)}. Returns null when any step is missing. + */ + public static Object at(Object root, Object... path) { + Object current = root; + for (Object step : path) { + if (current == null) { + return null; + } + if (step instanceof Integer index) { + if (!(current instanceof List list) || index >= list.size()) { + return null; + } + current = list.get(index); + } else { + if (!(current instanceof Map map)) { + return null; + } + current = map.get(String.valueOf(step)); + } + } + return current; + } + + public static Map objectAt(Object root, Object... path) { + Object value = at(root, path); + if (value == null) { + throw new AssertionError("Missing " + describe(path) + " in " + root); + } + return object(value); + } + + public static List arrayAt(Object root, Object... path) { + Object value = at(root, path); + if (value == null) { + throw new AssertionError("Missing " + describe(path) + " in " + root); + } + return array(value); + } + + public static String describe(Object... path) { + StringBuilder sb = new StringBuilder(); + for (Object step : path) { + if (step instanceof Integer) { + sb.append('[').append(step).append(']'); + } else { + if (!sb.isEmpty()) { + sb.append('.'); + } + sb.append(step); + } + } + return sb.toString(); + } +} diff --git a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/JsonDiff.java b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/JsonDiff.java new file mode 100644 index 0000000..9144e0c --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/JsonDiff.java @@ -0,0 +1,88 @@ +package com.exceptionless.e2e.support; + +import java.math.BigDecimal; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Compares the JSON the client sent with what the server stored and lists every value the client + * sent that the server dropped or changed. Extra fields added by the server are ignored. + * + *

This is the core wire-format check: the server deserializes known data keys ({@code + * @error}, {@code @request}, {@code @environment}, ...) into its typed models and writes them back, + * so a misnamed or wrongly typed field shows up here as missing. + */ +public final class JsonDiff { + private JsonDiff() {} + + public static List diff(Object sent, Object stored) { + List differences = new ArrayList<>(); + compare("", sent, stored, differences); + return differences; + } + + private static void compare(String path, Object sent, Object stored, List out) { + if (sent == null) { + return; + } + if (stored == null) { + out.add(path + ": sent " + abbreviate(sent) + " but the server did not store it"); + return; + } + if (sent instanceof Map sentMap) { + if (!(stored instanceof Map storedMap)) { + out.add(path + ": sent an object but stored " + abbreviate(stored)); + return; + } + for (Map.Entry entry : sentMap.entrySet()) { + String key = String.valueOf(entry.getKey()); + compare(path.isEmpty() ? key : path + "." + key, entry.getValue(), storedMap.get(key), out); + } + return; + } + if (sent instanceof List sentList) { + if (!(stored instanceof List storedList)) { + out.add(path + ": sent an array but stored " + abbreviate(stored)); + return; + } + if (sentList.size() != storedList.size()) { + out.add(path + ": sent " + sentList.size() + " items but stored " + storedList.size()); + } + for (int i = 0; i < Math.min(sentList.size(), storedList.size()); i++) { + compare(path + "[" + i + "]", sentList.get(i), storedList.get(i), out); + } + return; + } + if (!scalarEquals(sent, stored)) { + out.add(path + ": sent " + abbreviate(sent) + " but stored " + abbreviate(stored)); + } + } + + private static boolean scalarEquals(Object sent, Object stored) { + if (sent instanceof Number a && stored instanceof Number b) { + return new BigDecimal(a.toString()).compareTo(new BigDecimal(b.toString())) == 0; + } + if (sent instanceof String a && stored instanceof String b && !a.equals(b)) { + // Dates are stored with .NET's 100ns precision (Java sends nanoseconds). + try { + Duration delta = + Duration.between( + OffsetDateTime.parse(a).toInstant(), OffsetDateTime.parse(b).toInstant()); + return delta.abs().compareTo(Duration.ofNanos(100)) < 0; + } catch (DateTimeParseException e) { + return false; + } + } + return Objects.equals(sent, stored); + } + + private static String abbreviate(Object value) { + String text = value instanceof String ? "\"" + value + "\"" : String.valueOf(value); + return text.length() > 120 ? text.substring(0, 117) + "..." : text; + } +} diff --git a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/RecordingSubmissionClient.java b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/RecordingSubmissionClient.java new file mode 100644 index 0000000..05e0c5a --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/RecordingSubmissionClient.java @@ -0,0 +1,98 @@ +package com.exceptionless.e2e.support; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.models.UserDescription; +import com.exceptionless.submission.DefaultSubmissionClient; +import com.exceptionless.submission.SettingsResponse; +import com.exceptionless.submission.SubmissionClient; +import com.exceptionless.submission.SubmissionResponse; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Wraps the client's real {@link DefaultSubmissionClient} (so requests really go over HTTP to the + * server) and records each request and response for assertions and evidence. + */ +public final class RecordingSubmissionClient implements SubmissionClient { + /** One request: kind is events, user-description, settings or heartbeat. */ + public record Call( + String kind, String detail, String body, int status, Integer configVersion, String message) {} + + private final SubmissionClient delegate; + private final List calls = new CopyOnWriteArrayList<>(); + + public RecordingSubmissionClient(ExceptionlessConfiguration config) { + this.delegate = new DefaultSubmissionClient(config); + } + + public List calls() { + return List.copyOf(calls); + } + + public List calls(String kind) { + return calls.stream().filter(c -> c.kind().equals(kind)).toList(); + } + + @Override + public SubmissionResponse postEvents(String eventsJson) { + SubmissionResponse response = delegate.postEvents(eventsJson); + calls.add( + new Call( + "events", + null, + eventsJson, + response.statusCode(), + response.configVersion(), + response.message())); + return response; + } + + @Override + public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { + SubmissionResponse response = delegate.postUserDescription(referenceId, description); + calls.add( + new Call( + "user-description", + referenceId, + com.exceptionless.json.JsonWriter.toJson(description), + response.statusCode(), + response.configVersion(), + response.message())); + return response; + } + + @Override + public SettingsResponse getSettings(int version) { + SettingsResponse response = delegate.getSettings(version); + String detail = "v=" + version; + if (response instanceof SettingsResponse.Updated updated) { + calls.add( + new Call( + "settings", + detail, + String.valueOf(updated.settings()), + 200, + updated.version(), + null)); + } else if (response instanceof SettingsResponse.Failed failed) { + calls.add(new Call("settings", detail, null, -1, null, failed.message())); + } else { + calls.add(new Call("settings", detail, null, 304, null, null)); + } + return response; + } + + @Override + public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { + SubmissionResponse response = delegate.sendHeartbeat(sessionIdOrUserId, closeSession); + calls.add( + new Call( + "heartbeat", + sessionIdOrUserId + (closeSession ? " close" : ""), + null, + response.statusCode(), + response.configVersion(), + response.message())); + return response; + } +} diff --git a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/ServerApi.java b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/ServerApi.java new file mode 100644 index 0000000..b258254 --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/ServerApi.java @@ -0,0 +1,216 @@ +package com.exceptionless.e2e.support; + +import com.exceptionless.json.JsonWriter; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * A minimal client for the Exceptionless REST API (src/Exceptionless.Web/Api/Endpoints in the + * server repository), used to bootstrap accounts and read back what the server stored. + */ +public final class ServerApi { + private final HttpClient http = + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .version(HttpClient.Version.HTTP_1_1) + .build(); + private final String baseUrl; + private String userToken; + + public ServerApi(String baseUrl) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + } + + public String baseUrl() { + return baseUrl; + } + + public String userToken() { + return userToken; + } + + // ------------------------------------------------------------------------------------------- + // Bootstrap + + /** POST /api/v2/auth/signup (Signup: name, email, password). Falls back to login if taken. */ + public String signup(String name, String email, String password) { + Map body = Map.of("name", name, "email", email, "password", password); + Response response = send("POST", "/api/v2/auth/signup", body, null); + if (response.status() != 200) { + response = + send("POST", "/api/v2/auth/login", Map.of("email", email, "password", password), null); + } + response.expect(200); + userToken = (String) Json.object(response.json()).get("token"); + return userToken; + } + + /** POST /api/v2/organizations (NewOrganization: name). */ + public String createOrganization(String name) { + return (String) + Json.object(post("/api/v2/organizations", Map.of("name", name)).expect(201).json()) + .get("id"); + } + + /** POST /api/v2/projects (NewProject: organization_id, name). */ + public String createProject(String organizationId, String name) { + Map body = + Map.of("organization_id", organizationId, "name", name, "delete_bot_data_enabled", false); + return (String) Json.object(post("/api/v2/projects", body).expect(201).json()).get("id"); + } + + /** POST /api/v2/projects/{id}/tokens (NewToken) with the client scope: a project API key. */ + public String createClientToken(String projectId) { + Map body = Map.of("scopes", List.of("client"), "notes", "e2e tests"); + return (String) + Json.object(post("/api/v2/projects/" + projectId + "/tokens", body).expect(201).json()) + .get("id"); + } + + /** POST /api/v2/projects/{id}/config?key= with a ValueFromBody<string> body. */ + public void setProjectConfig(String projectId, String key, String value) { + post("/api/v2/projects/" + projectId + "/config?key=" + encode(key), Map.of("value", value)) + .expect(200); + } + + /** DELETE /api/v2/projects/{id}/config?key= */ + public void deleteProjectConfig(String projectId, String key) { + send("DELETE", "/api/v2/projects/" + projectId + "/config?key=" + encode(key), null, userToken) + .expect(200); + } + + /** GET /api/v2/projects/{id}/config: {version, settings}. */ + public Map getProjectConfig(String projectId) { + return Json.object(get("/api/v2/projects/" + projectId + "/config").expect(200).json()); + } + + /** + * POST /api/v2/projects/{id}/data?key= (project data is loaded into the event pipeline context). + */ + public void setProjectData(String projectId, String key, String value) { + post("/api/v2/projects/" + projectId + "/data?key=" + encode(key), Map.of("value", value)) + .expect(200); + } + + // ------------------------------------------------------------------------------------------- + // Queries + + /** GET /api/v2/projects/{projectId}/events?filter=&limit= (full persistent events). */ + public List getEvents(String projectId, String filter, int limit) { + String path = + "/api/v2/projects/" + projectId + "/events?limit=" + limit + "&filter=" + encode(filter); + return Json.array(get(path).expect(200).json()); + } + + /** GET /api/v2/projects/{projectId}/events/count?filter= */ + public long countEvents(String projectId, String filter) { + String path = "/api/v2/projects/" + projectId + "/events/count?filter=" + encode(filter); + return ((Number) Json.object(get(path).expect(200).json()).get("total")).longValue(); + } + + /** GET /api/v2/events/{id} */ + public Map getEvent(String id) { + return Json.object(get("/api/v2/events/" + id).expect(200).json()); + } + + /** GET /api/v2/events/by-ref/{referenceId}: the events with that reference id. */ + public List getEventsByReference(String referenceId) { + Response response = get("/api/v2/events/by-ref/" + encode(referenceId)); + if (response.status() == 404) { + return List.of(); + } + return Json.array(response.expect(200).json()); + } + + /** GET /api/v2/stacks/{id} */ + public Map getStack(String id) { + return Json.object(get("/api/v2/stacks/" + id).expect(200).json()); + } + + /** GET /api/v2/projects/{projectId}/events/sessions?filter= (session start events). */ + public List getSessions(String projectId, String filter) { + String path = + "/api/v2/projects/" + projectId + "/events/sessions?limit=50&filter=" + encode(filter); + return Json.array(get(path).expect(200).json()); + } + + public Optional> findEvent(String projectId, String filter) { + List events = getEvents(projectId, filter, 1); + return events.isEmpty() ? Optional.empty() : Optional.of(Json.object(events.get(0))); + } + + // ------------------------------------------------------------------------------------------- + // HTTP + + public Response get(String path) { + return send("GET", path, null, userToken); + } + + public Response post(String path, Object body) { + return send("POST", path, body, userToken); + } + + /** Sends a request with an arbitrary bearer token (null for anonymous). */ + public Response send(String method, String path, Object body, String token) { + HttpRequest.Builder builder = + HttpRequest.newBuilder(URI.create(baseUrl + path)) + .timeout(Duration.ofSeconds(60)) + .header("Accept", "application/json"); + if (token != null) { + builder.header("Authorization", "Bearer " + token); + } + if (body != null) { + String json = body instanceof String s ? s : JsonWriter.toJson(body); + builder + .header("Content-Type", "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(json)); + } else { + builder.method(method, HttpRequest.BodyPublishers.noBody()); + } + try { + HttpResponse response = + http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + Map headers = new LinkedHashMap<>(); + response + .headers() + .map() + .forEach((name, values) -> headers.put(name.toLowerCase(), String.join(",", values))); + return new Response(method + " " + path, response.statusCode(), response.body(), headers); + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + /** An HTTP response. */ + public record Response(String request, int status, String body, Map headers) { + public Response expect(int expectedStatus) { + if (status != expectedStatus) { + throw new AssertionError( + request + " returned HTTP " + status + " (expected " + expectedStatus + "): " + body); + } + return this; + } + + public Object json() { + return body == null || body.isBlank() ? null : Json.parse(body); + } + } +} diff --git a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/TestProject.java b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/TestProject.java new file mode 100644 index 0000000..0d06527 --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/TestProject.java @@ -0,0 +1,137 @@ +package com.exceptionless.e2e.support; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** A server project with a client API key, plus helpers to create clients and read events back. */ +public final class TestProject { + private final ExceptionlessServer server; + private final String projectId; + private final String apiKey; + + TestProject(ExceptionlessServer server, String projectId, String apiKey) { + this.server = server; + this.projectId = projectId; + this.apiKey = apiKey; + } + + public String id() { + return projectId; + } + + public String apiKey() { + return apiKey; + } + + public ServerApi api() { + return server.api(); + } + + public ExceptionlessServer server() { + return server; + } + + /** The configuration every test client starts from. */ + public ExceptionlessConfiguration configuration(String apiKey) { + return new ExceptionlessConfiguration() + .apiKey(apiKey) + .serverUrl(server.baseUrl()) + .captureUncaughtExceptions(false) + .registerShutdownHook(false) + .shutdownTimeout(Duration.ofSeconds(10)); + } + + /** A started client for this project that records its HTTP calls. */ + public TestClient newClient(Consumer configure) { + return newClient(apiKey, configure); + } + + public TestClient newClient(String apiKey, Consumer configure) { + ExceptionlessConfiguration config = configuration(apiKey); + configure.accept(config); + RecordingSubmissionClient recorder = new RecordingSubmissionClient(config); + config.submissionClient(recorder); + return new TestClient(new ExceptionlessClient(config).startup(), recorder); + } + + /** Waits until {@code count} events match {@code filter} and returns them (newest first). */ + public List awaitEvents(String filter, int count) { + return Await.value( + count + " event(s) matching " + filter, + Await.EVENTS, + () -> api().getEvents(projectId, filter, Math.max(count, 10)), + events -> events.size() >= count); + } + + /** Waits for the single event matching {@code filter}. */ + public Map awaitEvent(String filter) { + return Json.object(awaitEvents(filter, 1).get(0)); + } + + /** A started client and the recorder of its requests. */ + public record TestClient(ExceptionlessClient client, RecordingSubmissionClient recorder) + implements AutoCloseable { + /** Sends everything queued and asserts the queue drained. */ + public void flush() { + if (!client.flush(Duration.ofSeconds(60))) { + throw new AssertionError("Queue did not drain; requests so far: " + recorder.calls()); + } + } + + /** Every event the client posted (successfully or not), parsed. */ + public List> sentEvents() { + return recorder.calls("events").stream() + .flatMap(call -> Json.array(Json.parse(call.body())).stream()) + .map(Json::object) + .toList(); + } + + /** The event the client posted with the given tag. */ + public Map sentEventWithTag(String tag) { + return sentEvents().stream() + .filter(e -> e.get("tags") instanceof List tags && tags.contains(tag)) + .findFirst() + .orElseThrow( + () -> + new AssertionError( + "The client sent no event tagged " + tag + ": " + recorder.calls())); + } + + /** Writes the raw request bodies the client sent to target/e2e/{name}.json for inspection. */ + public void savePayloads(String name) { + Path dir = Path.of(System.getProperty("e2e.outputDirectory", "target/e2e")); + try { + Files.createDirectories(dir); + StringBuilder sb = new StringBuilder(); + for (RecordingSubmissionClient.Call call : recorder.calls()) { + sb.append("// ") + .append(call.kind()) + .append(call.detail() == null ? "" : " " + call.detail()) + .append(" -> HTTP ") + .append(call.status()) + .append(call.configVersion() == null ? "" : " config-version " + call.configVersion()) + .append('\n'); + if (call.body() != null) { + sb.append(call.body()).append('\n'); + } + } + Files.writeString(dir.resolve(name + ".json"), sb.toString()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public void close() { + client.close(); + } + } +} diff --git a/exceptionless-jul/pom.xml b/exceptionless-jul/pom.xml new file mode 100644 index 0000000..4d276a8 --- /dev/null +++ b/exceptionless-jul/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + com.exceptionless + exceptionless-parent + ${revision} + ../pom.xml + + + exceptionless-jul + Exceptionless java.util.logging + java.util.logging handler that sends log messages and exceptions to Exceptionless. + + + com.exceptionless.jul + + + + + com.exceptionless + exceptionless-core + + + + org.awaitility + awaitility + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + + diff --git a/exceptionless-jul/src/main/java/com/exceptionless/jul/ExceptionlessHandler.java b/exceptionless-jul/src/main/java/com/exceptionless/jul/ExceptionlessHandler.java new file mode 100644 index 0000000..6f1ac2d --- /dev/null +++ b/exceptionless-jul/src/main/java/com/exceptionless/jul/ExceptionlessHandler.java @@ -0,0 +1,306 @@ +package com.exceptionless.jul; + +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.LogLevel; +import com.exceptionless.logging.LogEventSupport; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.logging.ErrorManager; +import java.util.logging.Filter; +import java.util.logging.Formatter; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.LogRecord; +import java.util.logging.SimpleFormatter; +import org.jspecify.annotations.Nullable; + +/** + * A {@code java.util.logging} handler that sends log messages and exceptions to Exceptionless. + * + *

Configure it in {@code logging.properties}: + * + *

{@code
+ * handlers = java.util.logging.ConsoleHandler, com.exceptionless.jul.ExceptionlessHandler
+ *
+ * # Optional: without an API key the default client (Exceptionless.getDefault()) is used.
+ * com.exceptionless.jul.ExceptionlessHandler.apiKey = YOUR_API_KEY
+ * # Optional, for self hosted installs.
+ * com.exceptionless.jul.ExceptionlessHandler.serverUrl = https://exceptionless.example.com
+ * # Optional (default warn). The project's @@log settings override it.
+ * com.exceptionless.jul.ExceptionlessHandler.minimumLevel = warn
+ * # Optional handler level (default ALL) and filter, as for any handler.
+ * com.exceptionless.jul.ExceptionlessHandler.level = ALL
+ * }
+ * + * or in code: + * + *
{@code
+ * Logger.getLogger("").addHandler(new ExceptionlessHandler());
+ * }
+ * + *

Records with an exception become error events; the rest become log events whose source is the + * logger name. The thread name is added as a property. Levels map as {@code FINEST}/{@code FINER} + * to trace, {@code FINE}/{@code CONFIG} to debug, {@code INFO} to info, {@code WARNING} to warn and + * {@code SEVERE} to error. A record is sent when its level is at least {@code minimumLevel}, unless + * the project's {@code @@log:} settings say otherwise (in either direction). The handler's + * level and filter and the loggers' levels still apply first. + * + *

Client. With an {@code apiKey}, the handler creates its own client (configured from the + * environment like {@link ExceptionlessConfiguration#fromEnvironment()}, without the uncaught + * exception handler) on first use and closes it, sending queued events, in {@link #close()}. + * Otherwise it uses {@link Exceptionless#getDefault() the default client} once the application has + * started it, so logging never starts or locks the default client's configuration; records + * published before then are not sent. {@link #setClient} uses a specific client instead. See {@link + * LogEventSupport}, which the Logback and Log4j 2 integrations share. + * + *

The client's own diagnostics (loggers under {@code com.exceptionless} and anything logged on + * its background threads) and records logged while an event is being submitted are never sent. + * Publishing never blocks on the network and never throws; the first failure is reported to the + * handler's {@link ErrorManager}. + */ +public final class ExceptionlessHandler extends Handler { + /** The submission method recorded on events sent by this handler. */ + public static final String SUBMISSION_METHOD = "jul"; + + private static final Formatter DEFAULT_FORMATTER = new SimpleFormatter(); + + private final LogEventSupport support = + new LogEventSupport( + SUBMISSION_METHOD, + (message, error) -> + reportError(message, asException(error), ErrorManager.GENERIC_FAILURE)); + + /** + * Creates a handler configured from the {@link LogManager} properties named after this class: + * {@code apiKey}, {@code serverUrl}, {@code minimumLevel}, {@code level}, {@code filter} and + * {@code formatter} (used to format messages, default {@link SimpleFormatter}). + */ + public ExceptionlessHandler() { + configure(ExceptionlessHandler.class.getName()); + support.start(); + } + + /** Creates a handler that sends to the given client, which it doesn't close. */ + public ExceptionlessHandler(ExceptionlessClient client) { + this(); + support.setClient(client); + } + + private void configure(String prefix) { + LogManager manager = LogManager.getLogManager(); + support.setApiKey(manager.getProperty(prefix + ".apiKey")); + support.setServerUrl(manager.getProperty(prefix + ".serverUrl")); + String minimum = trimToNull(manager.getProperty(prefix + ".minimumLevel")); + if (minimum != null) { + setMinimumLevel(minimum); + } + + String level = trimToNull(manager.getProperty(prefix + ".level")); + try { + setLevel(level != null ? Level.parse(level) : Level.ALL); + } catch (IllegalArgumentException e) { + reportError("Unknown level '" + level + "'", e, ErrorManager.GENERIC_FAILURE); + setLevel(Level.ALL); + } + + String filter = trimToNull(manager.getProperty(prefix + ".filter")); + if (filter != null) { + Filter instance = newInstance(filter, Filter.class); + if (instance != null) { + setFilter(instance); + } + } + String formatter = trimToNull(manager.getProperty(prefix + ".formatter")); + if (formatter != null) { + Formatter instance = newInstance(formatter, Formatter.class); + if (instance != null) { + setFormatter(instance); + } + } + } + + /** + * Creates a filter or formatter by class name, looking for the class with the thread context + * class loader (as application servers expect), this handler's class loader, then the system + * class loader (as {@link LogManager} does). + */ + private @Nullable T newInstance(String className, Class type) { + Exception failure = null; + for (ClassLoader loader : classLoaders()) { + try { + Class loaded = Class.forName(className, true, loader); + return type.cast(loaded.getDeclaredConstructor().newInstance()); + } catch (ClassNotFoundException e) { + failure = e; + } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { + failure = asException(e); + break; + } + } + reportError( + "Unable to create " + type.getSimpleName() + " " + className, + failure, + ErrorManager.GENERIC_FAILURE); + return null; + } + + private static Set classLoaders() { + Set loaders = new LinkedHashSet<>(); + ClassLoader context = Thread.currentThread().getContextClassLoader(); + if (context != null) { + loaders.add(context); + } + ClassLoader own = ExceptionlessHandler.class.getClassLoader(); + if (own != null) { + loaders.add(own); + } + loaders.add(ClassLoader.getSystemClassLoader()); + return loaders; + } + + public @Nullable String getApiKey() { + return support.getApiKey(); + } + + /** + * The project API key. When set, the handler creates and owns its own client. Changing it closes + * a client the handler already created. + */ + public void setApiKey(@Nullable String apiKey) { + support.setApiKey(apiKey); + } + + public @Nullable String getServerUrl() { + return support.getServerUrl(); + } + + /** The server URL for self hosted installs. Only used together with {@link #setApiKey}. */ + public void setServerUrl(@Nullable String serverUrl) { + support.setServerUrl(serverUrl); + } + + public LogLevel getMinimumLevel() { + return support.getMinimumLevel(); + } + + /** + * The minimum level sent when the project has no matching {@code @@log} setting (default {@link + * LogLevel#WARN}). {@link LogLevel#OFF} sends only what the project settings enable. + */ + public void setMinimumLevel(LogLevel minimumLevel) { + support.setMinimumLevel(minimumLevel); + } + + /** + * Sets the minimum level by name. Accepts Exceptionless and {@code java.util.logging} level names + * such as {@code warn}, {@code warning} or {@code severe}. + */ + public void setMinimumLevel(String minimumLevel) { + if (!support.setMinimumLevel(minimumLevel)) { + reportError( + "Unknown minimumLevel '" + minimumLevel + "'; using " + support.getMinimumLevel(), + null, + ErrorManager.GENERIC_FAILURE); + } + } + + /** + * The client the handler sends to: the one passed to {@link #setClient}, the one it created for + * its {@code apiKey}, or {@code null} when it uses the default client or hasn't sent anything + * yet. + */ + public @Nullable ExceptionlessClient getClient() { + return support.getClient(); + } + + /** Sends to the given client. The handler doesn't close it. Takes precedence over the API key. */ + public void setClient(@Nullable ExceptionlessClient client) { + support.setClient(client); + } + + @Override + public void publish(@Nullable LogRecord record) { + if (record == null || !support.isStarted() || !isLoggable(record)) { + return; + } + String loggerName = record.getLoggerName(); + String threadName = Thread.currentThread().getName(); + LogLevel level = toLogLevel(record.getLevel()); + support.submit( + loggerName, + threadName, + level, + client -> + support.createEvent( + client, loggerName, formatMessage(record), level, record.getThrown(), threadName)); + } + + private String formatMessage(LogRecord record) { + Formatter formatter = getFormatter(); + String message; + try { + message = (formatter != null ? formatter : DEFAULT_FORMATTER).formatMessage(record); + } catch (RuntimeException e) { + message = record.getMessage(); + } + return message != null ? message : ""; + } + + /** Starts sending queued events, without waiting. */ + @Override + public void flush() { + ExceptionlessClient target = getClient(); + if (target != null) { + CompletableFuture unused = target.processQueue(); + } + } + + /** + * Stops publishing and closes the client the handler created, sending queued events (waiting up + * to the client's shutdown timeout). A client passed to {@link #setClient} is left open. + */ + @Override + public void close() { + support.close(); + } + + /** + * Maps a {@code java.util.logging} level to an Exceptionless level. {@code SEVERE} is the most + * severe level, so {@code OFF} maps to error and {@code ALL} to trace; {@code null} maps to info. + */ + static LogLevel toLogLevel(@Nullable Level level) { + if (level == null) { + return LogLevel.INFO; + } + int value = level.intValue(); + if (value >= Level.SEVERE.intValue()) { + return LogLevel.ERROR; + } + if (value >= Level.WARNING.intValue()) { + return LogLevel.WARN; + } + if (value >= Level.INFO.intValue()) { + return LogLevel.INFO; + } + if (value >= Level.FINE.intValue()) { + return LogLevel.DEBUG; + } + return LogLevel.TRACE; + } + + private static Exception asException(Throwable error) { + return error instanceof Exception e ? e : new RuntimeException(error); + } + + private static @Nullable String trimToNull(@Nullable String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/exceptionless-jul/src/main/java/com/exceptionless/jul/package-info.java b/exceptionless-jul/src/main/java/com/exceptionless/jul/package-info.java new file mode 100644 index 0000000..6786c91 --- /dev/null +++ b/exceptionless-jul/src/main/java/com/exceptionless/jul/package-info.java @@ -0,0 +1,5 @@ +/** {@code java.util.logging} integration: {@link com.exceptionless.jul.ExceptionlessHandler}. */ +@NullMarked +package com.exceptionless.jul; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-jul/src/test/java/com/exceptionless/jul/ExceptionlessHandlerTest.java b/exceptionless-jul/src/test/java/com/exceptionless/jul/ExceptionlessHandlerTest.java new file mode 100644 index 0000000..404dfff --- /dev/null +++ b/exceptionless-jul/src/test/java/com/exceptionless/jul/ExceptionlessHandlerTest.java @@ -0,0 +1,525 @@ +package com.exceptionless.jul; + +import static com.exceptionless.TestClients.data; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.FakeServer; +import com.exceptionless.LogLevel; +import com.exceptionless.TestClients; +import com.exceptionless.TestClients.CapturingSubmissionClient; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.ErrorManager; +import java.util.logging.Filter; +import java.util.logging.Formatter; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class ExceptionlessHandlerTest { + private final CapturingSubmissionClient server = new CapturingSubmissionClient(); + private final List loggers = new ArrayList<>(); + private final List handlers = new ArrayList<>(); + private ExceptionlessClient client; + + @AfterEach + void tearDown() throws IOException { + for (Logger logger : loggers) { + for (Handler handler : logger.getHandlers()) { + logger.removeHandler(handler); + } + } + handlers.forEach(Handler::close); + if (client != null) { + client.close(); + } + Exceptionless.setDefault(null); + } + + private ExceptionlessHandler handler(ExceptionlessClient target) { + ExceptionlessHandler handler = new ExceptionlessHandler(target); + handlers.add(handler); + return handler; + } + + private Logger logger(String name, Handler handler) { + Logger logger = Logger.getLogger(name); + logger.setUseParentHandlers(false); + logger.setLevel(Level.ALL); + logger.addHandler(handler); + loggers.add(logger); + return logger; + } + + /** Applies a logging.properties for the duration of {@code body}, then restores the defaults. */ + private static void withLogManagerProperties(String properties, ThrowingRunnable body) + throws Exception { + LogManager manager = LogManager.getLogManager(); + try (InputStream in = + new ByteArrayInputStream(properties.getBytes(StandardCharsets.ISO_8859_1))) { + manager.readConfiguration(in); + } + try { + body.run(); + } finally { + manager.readConfiguration(); + } + } + + interface ThrowingRunnable { + void run() throws Exception; + } + + @ParameterizedTest + @CsvSource({ + "ALL, TRACE", + "FINEST, TRACE", + "FINER, TRACE", + "FINE, DEBUG", + "CONFIG, DEBUG", + "INFO, INFO", + "WARNING, WARN", + "SEVERE, ERROR", + "OFF, ERROR" + }) + void mapsLevels(String jul, LogLevel expected) { + assertThat(ExceptionlessHandler.toLogLevel(Level.parse(jul))).isEqualTo(expected); + } + + @Test + void mapsAMissingLevelToInfo() { + assertThat(ExceptionlessHandler.toLogLevel(null)).isEqualTo(LogLevel.INFO); + } + + @Test + void sendsWarningAndAboveByDefault() { + client = server.newClient(); + Logger logger = logger("com.example.jul.Orders", handler(client)); + + logger.finest("finest"); + logger.fine("fine"); + logger.config("config"); + logger.info("info"); + logger.log(Level.WARNING, "Low stock for {0}", "widget"); + logger.severe("Order failed"); + + List> events = server.flush(client); + assertThat(events) + .extracting(e -> e.get("message")) + .containsExactly("Low stock for widget", "Order failed"); + Map warn = events.get(0); + assertThat(warn.get("type")).isEqualTo("log"); + assertThat(warn.get("source")).isEqualTo("com.example.jul.Orders"); + assertThat(data(warn)) + .containsEntry("@level", "warn") + .containsEntry("@submission_method", "jul") + .containsEntry("thread", Thread.currentThread().getName()); + assertThat(data(events.get(1))).containsEntry("@level", "error"); + } + + @Test + void minimumLevelIsConfigurable() { + client = server.newClient(); + ExceptionlessHandler handler = handler(client); + handler.setMinimumLevel("information"); + assertThat(handler.getMinimumLevel()).isEqualTo(LogLevel.INFO); + handler.setMinimumLevel("bogus"); + assertThat(handler.getMinimumLevel()).isEqualTo(LogLevel.INFO); + Logger logger = logger("com.example.jul.Minimum", handler); + + logger.fine("fine"); + logger.info("info"); + + assertThat(server.flush(client)).extracting(e -> e.get("message")).containsExactly("info"); + } + + @Test + void projectLogSettingsOverrideTheMinimumLevel() { + client = server.newClient(); + client + .getConfiguration() + .applyServerSettings( + Map.of("@@log:com.example.*", "info", "@@log:com.example.jul.Noisy", "off")); + ExceptionlessHandler handler = handler(client); + Logger orders = logger("com.example.jul.Settings", handler); + Logger noisy = logger("com.example.jul.Noisy", handler); + Logger other = logger("org.other.jul.Thing", handler); + + orders.fine("orders fine"); + orders.info("orders info"); + noisy.severe("noisy severe"); + other.info("other info"); + other.warning("other warning"); + + assertThat(server.flush(client)) + .extracting(e -> e.get("message")) + .containsExactly("orders info", "other warning"); + } + + @Test + void honorsHandlerLevelAndFilter() { + client = server.newClient(); + ExceptionlessHandler handler = handler(client); + handler.setLevel(Level.SEVERE); + handler.setFilter(record -> !record.getMessage().contains("filtered")); + Logger logger = logger("com.example.jul.Filtered", handler); + + logger.warning("below handler level"); + logger.severe("filtered out"); + logger.severe("sent"); + + assertThat(server.flush(client)).extracting(e -> e.get("message")).containsExactly("sent"); + } + + @Test + void sendsExceptionsAsErrors() { + client = server.newClient(); + Logger logger = logger("com.example.jul.Payments", handler(client)); + + logger.log(Level.WARNING, "Payment {0} failed", new Object[] {42}); + logger.log(Level.SEVERE, "Payment failed", new IllegalStateException("card declined")); + + List> events = server.flush(client); + assertThat(events).hasSize(2); + Map event = events.get(1); + assertThat(event.get("type")).isEqualTo("error"); + assertThat(event.get("source")).isEqualTo("com.example.jul.Payments"); + assertThat(event.get("message")).isEqualTo("Payment failed"); + assertThat(data(event)) + .containsEntry("@level", "error") + .containsEntry("@submission_method", "jul"); + @SuppressWarnings("unchecked") + Map error = (Map) data(event).get("@error"); + assertThat(error.get("type")).isEqualTo("java.lang.IllegalStateException"); + assertThat(error.get("message")).isEqualTo("card declined"); + } + + @Test + void handlesAnonymousLoggersAndNullMessages() { + client = server.newClient(); + Logger anonymous = Logger.getAnonymousLogger(); + anonymous.setUseParentHandlers(false); + anonymous.addHandler(handler(client)); + loggers.add(anonymous); + + anonymous.warning("anonymous"); + anonymous.log(Level.SEVERE, null, new IllegalStateException("no message")); + + List> events = server.flush(client); + assertThat(events).hasSize(2); + assertThat(events.get(0)).doesNotContainKey("source").containsEntry("message", "anonymous"); + assertThat(events.get(1).get("type")).isEqualTo("error"); + } + + @Test + void usesTheConfiguredFormatterForMessages() { + client = server.newClient(); + ExceptionlessHandler handler = handler(client); + handler.setFormatter( + new Formatter() { + @Override + public String format(LogRecord record) { + return "unused"; + } + + @Override + public String formatMessage(LogRecord record) { + return "formatted: " + record.getMessage(); + } + }); + logger("com.example.jul.Formatted", handler).warning("hello"); + + assertThat(server.flush(client)) + .extracting(e -> e.get("message")) + .containsExactly("formatted: hello"); + } + + @Test + void ignoresTheClientsOwnLoggersAndThreads() throws Exception { + client = server.newClient(); + ExceptionlessHandler handler = handler(client); + logger("com.exceptionless.DefaultEventQueue", handler).severe("internal"); + logger("com.exceptionless", handler).severe("internal root"); + Logger app = logger("com.exceptionlessfan.App", handler); + Thread thread = new Thread(() -> app.severe("from client thread"), "exceptionless-1"); + thread.start(); + thread.join(); + app.severe("app"); + + assertThat(server.flush(client)).extracting(e -> e.get("message")).containsExactly("app"); + } + + @Test + void doesNotRecurseWhenSubmittingLogs() { + client = server.newClient(); + Logger logger = logger("com.example.jul.Recursion", handler(client)); + AtomicInteger handlerCalls = new AtomicInteger(); + client.addSubmittingEventHandler( + e -> { + handlerCalls.incrementAndGet(); + logger.severe("logged while submitting"); + }); + + logger.severe("Order failed"); + + assertThat(server.flush(client)) + .extracting(e -> e.get("message")) + .containsExactly("Order failed"); + assertThat(handlerCalls).hasValue(1); + } + + @Test + void usesTheDefaultClientOnceStarted() { + ExceptionlessHandler handler = new ExceptionlessHandler(); + handlers.add(handler); + Logger logger = logger("com.example.jul.Default", handler); + + logger.severe("before any default client"); + assertThat(Exceptionless.hasDefault()).isFalse(); + + client = new ExceptionlessClient(server.newConfiguration()); + Exceptionless.setDefault(client); + logger.severe("before startup"); + assertThat(client.isStarted()).isFalse(); + + client.startup(); + logger.severe("after startup"); + + assertThat(server.flush(client)) + .extracting(e -> e.get("message")) + .containsExactly("after startup"); + assertThat(handler.getClient()).isNull(); + } + + @Test + void doesNothingWhenTheClientIsDisabled() { + client = + new ExceptionlessClient( + new ExceptionlessConfiguration() + .submissionClient(server) + .captureUncaughtExceptions(false) + .registerShutdownHook(false)) + .startup(); + Logger logger = logger("com.example.jul.Disabled", handler(client)); + + assertThatCode( + () -> { + logger.severe("one"); + logger.log(Level.SEVERE, "two", new RuntimeException("boom")); + }) + .doesNotThrowAnyException(); + client.flush(Duration.ofSeconds(5)); + assertThat(server.events()).isEmpty(); + } + + @Test + void neverThrowsForAClosedClient() { + client = server.newClient(); + Logger logger = logger("com.example.jul.Closed", handler(client)); + client.close(); + + assertThatCode(() -> logger.log(Level.SEVERE, "after close", new RuntimeException("boom"))) + .doesNotThrowAnyException(); + assertThat(server.events()).isEmpty(); + } + + @Test + void reportsOnlyTheFirstFailure() { + client = server.newClient(); + ExceptionlessHandler handler = handler(client); + List errors = new CopyOnWriteArrayList<>(); + handler.setErrorManager( + new ErrorManager() { + @Override + public void error(String msg, Exception ex, int code) { + errors.add(msg); + } + }); + LogRecord failing = + new LogRecord(Level.SEVERE, "boom") { + @Override + public Throwable getThrown() { + throw new IllegalStateException("cannot read the exception"); + } + }; + failing.setLoggerName("com.example.jul.Failing"); + + assertThatCode( + () -> { + handler.publish(failing); + handler.publish(failing); + }) + .doesNotThrowAnyException(); + assertThat(errors).containsExactly("Unable to send a log event to Exceptionless"); + } + + @Test + void closeLeavesAnExplicitClientOpen() { + client = server.newClient(); + ExceptionlessHandler handler = handler(client); + Logger logger = logger("com.example.jul.Explicit", handler); + + handler.close(); + logger.severe("after handler close"); + + assertThat(client.isStarted()).isTrue(); + assertThat(server.flush(client)).isEmpty(); + } + + @Test + void ownsAndClosesTheClientForItsApiKey() throws Exception { + try (FakeServer fake = new FakeServer()) { + ExceptionlessHandler handler = new ExceptionlessHandler(); + handlers.add(handler); + handler.setApiKey(TestClients.API_KEY); + handler.setServerUrl(fake.url()); + assertThat(handler.getClient()).isNull(); + Logger logger = logger("com.example.jul.Owned", handler); + + logger.severe("Order failed"); + ExceptionlessClient owned = handler.getClient(); + assertThat(owned).isNotNull(); + assertThat(owned.isStarted()).isTrue(); + assertThat(owned.getConfiguration().getServerUrl()).isEqualTo(fake.url()); + assertThat(owned.getConfiguration().isCaptureUncaughtExceptions()).isFalse(); + + handler.close(); + assertThat(owned.isStarted()).isFalse(); + assertThat(handler.getClient()).isNull(); + logger.severe("after close"); + + List> events = + fake.take("/api/v2/events", Duration.ofSeconds(5)).events(); + assertThat(events).extracting(e -> e.get("message")).containsExactly("Order failed"); + assertThat(handler.getClient()).isNull(); + } + } + + @Test + void changingTheApiKeyReplacesTheOwnedClient() { + ExceptionlessHandler handler = new ExceptionlessHandler(); + handlers.add(handler); + handler.setApiKey(TestClients.API_KEY); + handler.setServerUrl("http://127.0.0.1:9"); + Logger logger = logger("com.example.jul.Rekey", handler); + logger.info("creates the client"); + ExceptionlessClient first = handler.getClient(); + assertThat(first).isNotNull(); + + handler.setApiKey("AnotherApiKey1234567890"); + + assertThat(first.isStarted()).isFalse(); + logger.info("creates another client"); + assertThat(handler.getClient()).isNotNull().isNotSameAs(first); + assertThat(handler.getClient().getConfiguration().getApiKey()) + .isEqualTo("AnotherApiKey1234567890"); + } + + /** Rejects records whose message contains "skip"; created by name from logging.properties. */ + public static final class SkipFilter implements Filter { + @Override + public boolean isLoggable(LogRecord record) { + return !record.getMessage().contains("skip"); + } + } + + @Test + void readsLogManagerProperties() throws Exception { + String prefix = ExceptionlessHandler.class.getName(); + withLogManagerProperties( + prefix + + ".apiKey = " + + TestClients.API_KEY + + "\n" + + prefix + + ".serverUrl = http://127.0.0.1:9/\n" + + prefix + + ".minimumLevel = info\n" + + prefix + + ".level = FINE\n" + + prefix + + ".filter = " + + SkipFilter.class.getName() + + "\n" + + prefix + + ".formatter = java.util.logging.XMLFormatter\n", + () -> { + ExceptionlessHandler handler = new ExceptionlessHandler(); + handlers.add(handler); + assertThat(handler.getApiKey()).isEqualTo(TestClients.API_KEY); + assertThat(handler.getServerUrl()).isEqualTo("http://127.0.0.1:9/"); + assertThat(handler.getMinimumLevel()).isEqualTo(LogLevel.INFO); + assertThat(handler.getLevel()).isEqualTo(Level.FINE); + assertThat(handler.getFilter()).isInstanceOf(SkipFilter.class); + assertThat(handler.getFormatter()).isInstanceOf(java.util.logging.XMLFormatter.class); + }); + } + + @Test + void defaultsWithoutLogManagerProperties() { + ExceptionlessHandler handler = new ExceptionlessHandler(); + handlers.add(handler); + assertThat(handler.getApiKey()).isNull(); + assertThat(handler.getServerUrl()).isNull(); + assertThat(handler.getMinimumLevel()).isEqualTo(LogLevel.WARN); + assertThat(handler.getLevel()).isEqualTo(Level.ALL); + assertThat(handler.getFilter()).isNull(); + } + + @Test + void isConfiguredFromLoggingProperties() throws Exception { + try (FakeServer fake = new FakeServer(); + InputStream template = + getClass().getResourceAsStream("/exceptionless-logging-test.properties")) { + String properties = + new String(template.readAllBytes(), StandardCharsets.ISO_8859_1) + .replace("@SERVER_URL@", fake.url()); + withLogManagerProperties( + properties, + () -> { + Logger logger = Logger.getLogger("com.example.jul.Configured"); + assertThat(logger.getHandlers()) + .hasSize(1) + .allMatch(ExceptionlessHandler.class::isInstance); + ExceptionlessHandler handler = (ExceptionlessHandler) logger.getHandlers()[0]; + assertThat(handler.getMinimumLevel()).isEqualTo(LogLevel.INFO); + + logger.fine("not sent"); + logger.info("Configured from logging.properties"); + logger.info("skip me"); + + // Resetting the LogManager closes the handler, which closes its client and sends the + // queue. + LogManager.getLogManager().reset(); + assertThat(handler.getClient()).isNull(); + + List> events = + fake.take("/api/v2/events", Duration.ofSeconds(5)).events(); + assertThat(events).hasSize(1); + assertThat(events.get(0).get("message")) + .isEqualTo("Configured from logging.properties"); + assertThat(data(events.get(0))) + .containsEntry("@level", "info") + .containsEntry("@submission_method", "jul"); + }); + } + } +} diff --git a/exceptionless-jul/src/test/resources/exceptionless-logging-test.properties b/exceptionless-jul/src/test/resources/exceptionless-logging-test.properties new file mode 100644 index 0000000..72994bb --- /dev/null +++ b/exceptionless-jul/src/test/resources/exceptionless-logging-test.properties @@ -0,0 +1,11 @@ +# Template for ExceptionlessHandlerTest; @SERVER_URL@ is replaced with the fake server's URL. +handlers = + +com.example.jul.Configured.handlers = com.exceptionless.jul.ExceptionlessHandler +com.example.jul.Configured.useParentHandlers = false +com.example.jul.Configured.level = ALL + +com.exceptionless.jul.ExceptionlessHandler.apiKey = LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest +com.exceptionless.jul.ExceptionlessHandler.serverUrl = @SERVER_URL@ +com.exceptionless.jul.ExceptionlessHandler.minimumLevel = info +com.exceptionless.jul.ExceptionlessHandler.filter = com.exceptionless.jul.ExceptionlessHandlerTest$SkipFilter diff --git a/exceptionless-log4j2/pom.xml b/exceptionless-log4j2/pom.xml new file mode 100644 index 0000000..6d81b45 --- /dev/null +++ b/exceptionless-log4j2/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + + com.exceptionless + exceptionless-parent + ${revision} + ../pom.xml + + + exceptionless-log4j2 + Exceptionless Log4j 2 + Log4j 2 appender that sends log messages and exceptions to Exceptionless. + + + com.exceptionless.log4j2 + + + + + com.exceptionless + exceptionless-core + + + + org.apache.logging.log4j + log4j-api + ${log4j2.version} + provided + + + org.apache.logging.log4j + log4j-core + ${log4j2.version} + provided + + + + org.awaitility + awaitility + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + org.apache.logging.log4j + log4j-core + ${log4j2.version} + + + + + -Xlint:-classfile + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + + diff --git a/exceptionless-log4j2/src/main/java/com/exceptionless/log4j2/ExceptionlessAppender.java b/exceptionless-log4j2/src/main/java/com/exceptionless/log4j2/ExceptionlessAppender.java new file mode 100644 index 0000000..ecfc164 --- /dev/null +++ b/exceptionless-log4j2/src/main/java/com/exceptionless/log4j2/ExceptionlessAppender.java @@ -0,0 +1,311 @@ +package com.exceptionless.log4j2; + +import com.exceptionless.EventBuilder; +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.LogLevel; +import com.exceptionless.logging.LogEventSupport; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.Marker; +import org.apache.logging.log4j.core.Appender; +import org.apache.logging.log4j.core.Core; +import org.apache.logging.log4j.core.Filter; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.appender.AppenderLoggingException; +import org.apache.logging.log4j.core.config.Property; +import org.apache.logging.log4j.core.config.plugins.Plugin; +import org.apache.logging.log4j.core.config.plugins.PluginBuilderAttribute; +import org.apache.logging.log4j.core.config.plugins.PluginBuilderFactory; +import org.apache.logging.log4j.message.MapMessage; +import org.apache.logging.log4j.message.Message; +import org.apache.logging.log4j.message.StructuredDataMessage; +import org.apache.logging.log4j.util.ReadOnlyStringMap; +import org.jspecify.annotations.Nullable; + +/** + * A Log4j 2 appender that sends log messages and exceptions to Exceptionless. + * + *

{@code
+ * 
+ *   
+ *     
+ *     
+ *   
+ *   
+ *     
+ *       
+ *       
+ *     
+ *   
+ * 
+ * }
+ * + *

Events with an exception become error events; the rest become log events whose source is the + * logger name. Thread context (MDC) entries, the fields of {@link MapMessage}s (including {@code + * StructuredDataMessage}s) and the thread name are added as properties and the marker as a tag. A + * message is sent when its level is at least {@code minimumLevel}, unless the project's + * {@code @@log:} settings say otherwise (in either direction). Log4j's own logger levels + * and filters still apply first, so a logger must be enabled at a level for the project settings to + * lower the threshold to it. + * + *

Client. With an {@code apiKey}, the appender creates its own client (configured from + * the environment like {@link ExceptionlessConfiguration#fromEnvironment()}, without the uncaught + * exception handler) on first use and closes it, sending queued events, when the appender stops. + * Otherwise it uses {@link Exceptionless#getDefault() the default client} once the application has + * started it, so logging never starts or locks the default client's configuration; messages logged + * before then are not sent. {@link Builder#setClient} uses a specific client instead. See {@link + * LogEventSupport}, which the Logback and {@code java.util.logging} integrations share. + * + *

The client's own diagnostics (loggers under {@code com.exceptionless} and anything logged on + * its background threads) and messages logged while an event is being submitted are never sent. + * Appending never blocks on the network. The first failure after the appender starts is reported to + * the appender's error handler; with {@code ignoreExceptions="false"} every failure is also thrown + * as an {@link AppenderLoggingException}, so that, for example, a {@code Failover} appender can + * take over. + */ +@Plugin( + name = ExceptionlessAppender.PLUGIN_NAME, + category = Core.CATEGORY_NAME, + elementType = Appender.ELEMENT_TYPE, + printObject = true) +public final class ExceptionlessAppender extends AbstractAppender { + /** The element name used in configuration files. */ + public static final String PLUGIN_NAME = "Exceptionless"; + + /** The submission method recorded on events sent by this appender. */ + public static final String SUBMISSION_METHOD = "log4j2"; + + private final LogEventSupport support = new LogEventSupport(SUBMISSION_METHOD, this::error); + private final boolean includeMdc; + + private ExceptionlessAppender( + String name, + @Nullable Filter filter, + boolean ignoreExceptions, + Property @Nullable [] properties, + @Nullable String apiKey, + @Nullable String serverUrl, + LogLevel minimumLevel, + boolean includeMdc, + @Nullable ExceptionlessClient client) { + super(name, filter, null, ignoreExceptions, properties); + support.setApiKey(apiKey); + support.setServerUrl(serverUrl); + support.setMinimumLevel(minimumLevel); + support.setClient(client); + this.includeMdc = includeMdc; + } + + /** Creates a builder; used by Log4j to create the appender from configuration. */ + @PluginBuilderFactory + public static > B newBuilder() { + return new Builder().asBuilder(); + } + + public @Nullable String getApiKey() { + return support.getApiKey(); + } + + public @Nullable String getServerUrl() { + return support.getServerUrl(); + } + + public LogLevel getMinimumLevel() { + return support.getMinimumLevel(); + } + + public boolean isIncludeMdc() { + return includeMdc; + } + + /** + * The client the appender sends to: the one passed to {@link Builder#setClient}, the one it + * created for its {@code apiKey}, or {@code null} when it uses the default client or hasn't sent + * anything yet. + */ + public @Nullable ExceptionlessClient getClient() { + return support.getClient(); + } + + @Override + public void start() { + support.start(); + super.start(); + } + + @Override + public void append(LogEvent event) { + LogLevel level = toLogLevel(event.getLevel()); + Throwable failure = + support.submit( + event.getLoggerName(), + event.getThreadName(), + level, + client -> createEvent(client, event, level)); + if (failure != null && !ignoreExceptions()) { + throw new AppenderLoggingException("Unable to send a log event to Exceptionless", failure); + } + } + + private EventBuilder createEvent(ExceptionlessClient client, LogEvent event, LogLevel level) { + Message message = event.getMessage(); + String text; + if (message instanceof StructuredDataMessage structured) { + // The fields become properties, so only send the free text, not the RFC 5424 rendering. + text = structured.getFormat(); + } else { + text = message != null ? message.getFormattedMessage() : null; + } + EventBuilder builder = + support.createEvent( + client, event.getLoggerName(), text, level, event.getThrown(), event.getThreadName()); + if (message instanceof MapMessage map) { + map.getData().forEach(builder::setProperty); + } + if (includeMdc) { + ReadOnlyStringMap contextData = event.getContextData(); + if (contextData != null && !contextData.isEmpty()) { + contextData.forEach((key, value) -> builder.setProperty(key, value)); + } + } + Marker marker = event.getMarker(); + if (marker != null) { + builder.addTags(marker.getName()); + } + return builder; + } + + /** + * Stops the appender and closes the client it created, waiting up to {@code timeout} (or, when it + * isn't positive, the client's shutdown timeout) for queued events to be sent. + */ + @Override + public boolean stop(long timeout, TimeUnit timeUnit) { + setStopping(); + boolean stopped = super.stop(timeout, timeUnit, false); + support.close(timeout > 0 ? Duration.ofNanos(timeUnit.toNanos(timeout)) : null); + setStopped(); + return stopped; + } + + /** + * Maps a Log4j level to an Exceptionless level by severity, so custom levels map to the nearest + * standard level. {@code OFF} (the most severe) maps to fatal, {@code ALL} to trace and {@code + * null} to info. + */ + static LogLevel toLogLevel(@Nullable Level level) { + if (level == null) { + return LogLevel.INFO; + } + int value = level.intLevel(); + if (value <= Level.FATAL.intLevel()) { + return LogLevel.FATAL; + } + if (value <= Level.ERROR.intLevel()) { + return LogLevel.ERROR; + } + if (value <= Level.WARN.intLevel()) { + return LogLevel.WARN; + } + if (value <= Level.INFO.intLevel()) { + return LogLevel.INFO; + } + if (value <= Level.DEBUG.intLevel()) { + return LogLevel.DEBUG; + } + return LogLevel.TRACE; + } + + /** + * Builds an {@link ExceptionlessAppender}. Besides {@code name}, {@code filter} and {@code + * ignoreExceptions}, the configuration attributes are {@code apiKey}, {@code serverUrl}, {@code + * minimumLevel} and {@code includeMdc}. + * + * @param the builder type + */ + public static class Builder> extends AbstractAppender.Builder + implements org.apache.logging.log4j.core.util.Builder { + @PluginBuilderAttribute(sensitive = true) + private @Nullable String apiKey; + + @PluginBuilderAttribute private @Nullable String serverUrl; + + @PluginBuilderAttribute private @Nullable String minimumLevel; + + @PluginBuilderAttribute private boolean includeMdc = true; + + private @Nullable ExceptionlessClient client; + + /** The project API key. When set, the appender creates and owns its own client. */ + public B setApiKey(@Nullable String apiKey) { + this.apiKey = apiKey; + return asBuilder(); + } + + /** The server URL for self hosted installs. Only used together with {@link #setApiKey}. */ + public B setServerUrl(@Nullable String serverUrl) { + this.serverUrl = serverUrl; + return asBuilder(); + } + + /** + * The minimum level sent when the project has no matching {@code @@log} setting (default {@code + * warn}). Accepts Exceptionless and Log4j level names; {@code off} sends only what the project + * settings enable. + */ + public B setMinimumLevel(@Nullable String minimumLevel) { + this.minimumLevel = minimumLevel; + return asBuilder(); + } + + /** Whether thread context (MDC) entries are added as event properties (default true). */ + public B setIncludeMdc(boolean includeMdc) { + this.includeMdc = includeMdc; + return asBuilder(); + } + + /** + * Sends to the given client, which the appender doesn't close. Takes precedence over the API + * key. + */ + public B setClient(@Nullable ExceptionlessClient client) { + this.client = client; + return asBuilder(); + } + + @Override + public @Nullable ExceptionlessAppender build() { + String name = getName(); + if (name == null || name.isBlank()) { + LOGGER.error("No name provided for the Exceptionless appender"); + return null; + } + LogLevel minimum = LogLevel.WARN; + if (minimumLevel != null && !minimumLevel.isBlank()) { + LogLevel parsed = LogLevel.parse(minimumLevel); + if (parsed == null) { + LOGGER.warn( + "Unknown minimumLevel '{}' for the Exceptionless appender; using warn", minimumLevel); + } else { + minimum = parsed; + } + } + return new ExceptionlessAppender( + name, + getFilter(), + isIgnoreExceptions(), + getPropertyArray(), + apiKey, + serverUrl, + minimum, + includeMdc, + client); + } + } +} diff --git a/exceptionless-log4j2/src/main/java/com/exceptionless/log4j2/package-info.java b/exceptionless-log4j2/src/main/java/com/exceptionless/log4j2/package-info.java new file mode 100644 index 0000000..93b84ee --- /dev/null +++ b/exceptionless-log4j2/src/main/java/com/exceptionless/log4j2/package-info.java @@ -0,0 +1,8 @@ +/** + * Log4j 2 integration: the {@code Exceptionless} appender, {@link + * com.exceptionless.log4j2.ExceptionlessAppender}. + */ +@NullMarked +package com.exceptionless.log4j2; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/ExceptionlessAppenderTest.java b/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/ExceptionlessAppenderTest.java new file mode 100644 index 0000000..3f4944b --- /dev/null +++ b/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/ExceptionlessAppenderTest.java @@ -0,0 +1,499 @@ +package com.exceptionless.log4j2; + +import static com.exceptionless.TestClients.data; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.FakeServer; +import com.exceptionless.LogLevel; +import com.exceptionless.TestClients; +import com.exceptionless.TestClients.CapturingSubmissionClient; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.MarkerManager; +import org.apache.logging.log4j.ThreadContext; +import org.apache.logging.log4j.core.Appender; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.appender.AppenderLoggingException; +import org.apache.logging.log4j.core.config.Configuration; +import org.apache.logging.log4j.core.config.Configurator; +import org.apache.logging.log4j.core.config.LoggerConfig; +import org.apache.logging.log4j.core.impl.Log4jLogEvent; +import org.apache.logging.log4j.message.Message; +import org.apache.logging.log4j.message.StringMapMessage; +import org.apache.logging.log4j.message.StructuredDataMessage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class ExceptionlessAppenderTest { + private final CapturingSubmissionClient server = new CapturingSubmissionClient(); + private LoggerContext context; + private ExceptionlessClient client; + + @AfterEach + void tearDown() { + ThreadContext.clearAll(); + if (context != null) { + context.stop(); + } + if (client != null) { + client.close(); + } + Exceptionless.setDefault(null); + } + + /** A logger context whose root logger (level ALL) only appends to {@code appender}. */ + private LoggerContext contextWith(Appender appender) { + context = new LoggerContext("exceptionless-test-" + System.nanoTime()); + context.start(); + Configuration config = context.getConfiguration(); + appender.start(); + config.addAppender(appender); + LoggerConfig root = config.getRootLogger(); + for (String name : root.getAppenders().keySet()) { + root.removeAppender(name); + } + root.setLevel(Level.ALL); + root.addAppender(appender, null, null); + context.updateLoggers(); + return context; + } + + private static ExceptionlessAppender.Builder builder(ExceptionlessClient target) { + return ExceptionlessAppender.newBuilder().setName("Exceptionless").setClient(target); + } + + private Logger logger(String name, ExceptionlessAppender appender) { + return contextWith(appender).getLogger(name); + } + + @ParameterizedTest + @CsvSource({ + "ALL, TRACE", + "TRACE, TRACE", + "DEBUG, DEBUG", + "INFO, INFO", + "WARN, WARN", + "ERROR, ERROR", + "FATAL, FATAL", + "OFF, FATAL" + }) + void mapsLevels(String log4j, LogLevel expected) { + assertThat(ExceptionlessAppender.toLogLevel(Level.getLevel(log4j))).isEqualTo(expected); + } + + @Test + void mapsCustomLevelsByIntensityAndAMissingLevelToInfo() { + assertThat(ExceptionlessAppender.toLogLevel(Level.forName("NOTICE", 350))) + .isEqualTo(LogLevel.INFO); + assertThat(ExceptionlessAppender.toLogLevel(null)).isEqualTo(LogLevel.INFO); + } + + @Test + void sendsWarnAndAboveByDefault() { + client = server.newClient(); + Logger logger = logger("com.example.Orders", builder(client).build()); + + logger.trace("trace"); + logger.debug("debug"); + logger.info("info"); + logger.warn("Low stock for {}", "widget"); + logger.error("Order failed"); + logger.fatal("Out of memory"); + + List> events = server.flush(client); + assertThat(events) + .extracting(e -> e.get("message")) + .containsExactly("Low stock for widget", "Order failed", "Out of memory"); + Map warn = events.get(0); + assertThat(warn.get("type")).isEqualTo("log"); + assertThat(warn.get("source")).isEqualTo("com.example.Orders"); + assertThat(data(warn)) + .containsEntry("@level", "warn") + .containsEntry("@submission_method", "log4j2") + .containsEntry("thread", Thread.currentThread().getName()); + assertThat(data(events.get(1))).containsEntry("@level", "error"); + assertThat(data(events.get(2))).containsEntry("@level", "fatal"); + } + + @Test + void minimumLevelIsConfigurable() { + client = server.newClient(); + assertThat(builder(client).setMinimumLevel("bogus").build().getMinimumLevel()) + .isEqualTo(LogLevel.WARN); + ExceptionlessAppender appender = builder(client).setMinimumLevel("info").build(); + assertThat(appender.getMinimumLevel()).isEqualTo(LogLevel.INFO); + Logger logger = logger("com.example.Orders", appender); + + logger.debug("debug"); + logger.info("info"); + + assertThat(server.flush(client)).extracting(e -> e.get("message")).containsExactly("info"); + } + + @Test + void projectLogSettingsOverrideTheMinimumLevel() { + client = server.newClient(); + client + .getConfiguration() + .applyServerSettings( + Map.of("@@log:com.example.*", "info", "@@log:com.example.Noisy", "off")); + LoggerContext ctx = contextWith(builder(client).build()); + + ctx.getLogger("com.example.Orders").debug("orders debug"); + ctx.getLogger("com.example.Orders").info("orders info"); + ctx.getLogger("com.example.Noisy").error("noisy error"); + ctx.getLogger("org.other.Thing").info("other info"); + ctx.getLogger("org.other.Thing").warn("other warn"); + + assertThat(server.flush(client)) + .extracting(e -> e.get("message")) + .containsExactly("orders info", "other warn"); + } + + @Test + void sendsExceptionsAsErrors() { + client = server.newClient(); + Logger logger = logger("com.example.Payments", builder(client).build()); + + logger.warn("Payment {} failed", 42, new IllegalStateException("card declined")); + + List> events = server.flush(client); + assertThat(events).hasSize(1); + Map event = events.get(0); + assertThat(event.get("type")).isEqualTo("error"); + assertThat(event.get("source")).isEqualTo("com.example.Payments"); + assertThat(event.get("message")).isEqualTo("Payment 42 failed"); + assertThat(data(event)) + .containsEntry("@level", "warn") + .containsEntry("@submission_method", "log4j2"); + @SuppressWarnings("unchecked") + Map error = (Map) data(event).get("@error"); + assertThat(error.get("type")).isEqualTo("java.lang.IllegalStateException"); + assertThat(error.get("message")).isEqualTo("card declined"); + } + + @Test + void addsThreadContextAndMarker() { + client = server.newClient(); + Logger logger = logger("com.example.Orders", builder(client).build()); + + ThreadContext.put("requestId", "r-1"); + logger.error(MarkerManager.getMarker("checkout"), "Order failed"); + + Map event = server.flush(client).get(0); + assertThat(data(event)).containsEntry("requestId", "r-1"); + assertThat(event.get("tags")).isEqualTo(List.of("checkout")); + } + + @Test + void addsMapMessageFields() { + client = server.newClient(); + Logger logger = logger("com.example.Orders", builder(client).build()); + + logger.error(new StringMapMessage().with("orderId", "42").with("status", "declined")); + StructuredDataMessage audit = new StructuredDataMessage("audit@1", "Refund issued", "Audit"); + audit.put("amount", "9.95"); + logger.warn(audit); + + List> events = server.flush(client); + assertThat(events).hasSize(2); + assertThat(data(events.get(0))) + .containsEntry("orderId", "42") + .containsEntry("status", "declined"); + assertThat(events.get(1)).containsEntry("message", "Refund issued"); + assertThat(data(events.get(1))).containsEntry("amount", "9.95"); + } + + @Test + void canLeaveOutThreadContext() { + client = server.newClient(); + Logger logger = logger("com.example.Orders", builder(client).setIncludeMdc(false).build()); + + ThreadContext.put("requestId", "r-1"); + logger.error("Order failed"); + + assertThat(data(server.flush(client).get(0))) + .doesNotContainKey("requestId") + .containsKey("thread"); + } + + @Test + void ignoresTheClientsOwnLoggersAndThreads() throws Exception { + client = server.newClient(); + LoggerContext ctx = contextWith(builder(client).build()); + ctx.getLogger("com.exceptionless.DefaultEventQueue").error("internal"); + ctx.getLogger("com.exceptionless").error("internal root"); + Logger app = ctx.getLogger("com.exceptionlessfan.App"); + Thread thread = new Thread(() -> app.error("from client thread"), "exceptionless-1"); + thread.start(); + thread.join(); + app.error("app"); + + assertThat(server.flush(client)).extracting(e -> e.get("message")).containsExactly("app"); + } + + @Test + void doesNotRecurseWhenSubmittingLogs() { + client = server.newClient(); + Logger logger = logger("com.example.Orders", builder(client).build()); + AtomicInteger handlerCalls = new AtomicInteger(); + client.addSubmittingEventHandler( + e -> { + handlerCalls.incrementAndGet(); + logger.error("logged while submitting"); + }); + + logger.error("Order failed"); + + assertThat(server.flush(client)) + .extracting(e -> e.get("message")) + .containsExactly("Order failed"); + assertThat(handlerCalls).hasValue(1); + } + + @Test + void usesTheDefaultClientOnceStarted() { + ExceptionlessAppender appender = + ExceptionlessAppender.newBuilder().setName("Exceptionless").build(); + Logger logger = logger("com.example.Orders", appender); + + logger.error("before any default client"); + assertThat(Exceptionless.hasDefault()).isFalse(); + + client = new ExceptionlessClient(server.newConfiguration()); + Exceptionless.setDefault(client); + logger.error("before startup"); + assertThat(client.isStarted()).isFalse(); + + client.startup(); + logger.error("after startup"); + + assertThat(server.flush(client)) + .extracting(e -> e.get("message")) + .containsExactly("after startup"); + assertThat(appender.getClient()).isNull(); + } + + @Test + void doesNothingWhenTheClientIsDisabled() { + client = + new ExceptionlessClient( + new ExceptionlessConfiguration() + .submissionClient(server) + .captureUncaughtExceptions(false) + .registerShutdownHook(false)) + .startup(); + Logger logger = logger("com.example.Orders", builder(client).build()); + + assertThatCode( + () -> { + logger.error("one"); + logger.error("two", new RuntimeException("boom")); + }) + .doesNotThrowAnyException(); + client.flush(Duration.ofSeconds(5)); + assertThat(server.events()).isEmpty(); + } + + @Test + void neverThrowsForAClosedClient() { + client = server.newClient(); + Logger logger = + logger("com.example.Orders", builder(client).setIgnoreExceptions(false).build()); + client.close(); + + assertThatCode(() -> logger.error("after close", new RuntimeException("boom"))) + .doesNotThrowAnyException(); + assertThat(server.events()).isEmpty(); + } + + @Test + void reportsTheFirstFailureAndIgnoresItByDefault() { + client = server.newClient(); + ExceptionlessAppender appender = builder(client).build(); + AtomicInteger reported = new AtomicInteger(); + appender.setHandler( + new org.apache.logging.log4j.core.ErrorHandler() { + @Override + public void error(String msg) { + reported.incrementAndGet(); + } + + @Override + public void error(String msg, Throwable t) { + reported.incrementAndGet(); + } + + @Override + public void error(String msg, LogEvent event, Throwable t) { + reported.incrementAndGet(); + } + }); + contextWith(appender); + + assertThatCode( + () -> { + appender.append(failingEvent()); + appender.append(failingEvent()); + }) + .doesNotThrowAnyException(); + assertThat(reported).hasValue(1); + } + + @Test + void throwsFailuresWhenNotIgnoringExceptions() { + client = server.newClient(); + ExceptionlessAppender appender = builder(client).setIgnoreExceptions(false).build(); + contextWith(appender); + + assertThatThrownBy(() -> appender.append(failingEvent())) + .isInstanceOf(AppenderLoggingException.class) + .hasRootCauseMessage("cannot format"); + assertThatThrownBy(() -> appender.append(failingEvent())) + .isInstanceOf(AppenderLoggingException.class); + } + + /** An event whose message can't be formatted. */ + private static LogEvent failingEvent() { + Message message = + new Message() { + @Override + public String getFormattedMessage() { + throw new IllegalStateException("cannot format"); + } + + @Override + public Object[] getParameters() { + return null; + } + + @Override + public Throwable getThrowable() { + return null; + } + }; + return Log4jLogEvent.newBuilder() + .setLoggerName("com.example.Orders") + .setLevel(Level.ERROR) + .setMessage(message) + .build(); + } + + @Test + void stopWaitsAtMostTheGivenTimeoutForQueuedEvents() throws Exception { + CountDownLatch slowServer = new CountDownLatch(1); + try (FakeServer fake = new FakeServer()) { + fake.onEvents( + request -> { + try { + slowServer.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return FakeServer.Response.of(202); + }); + ExceptionlessAppender appender = + ExceptionlessAppender.newBuilder() + .setName("Exceptionless") + .setApiKey(TestClients.API_KEY) + .setServerUrl(fake.url()) + .build(); + logger("com.example.Orders", appender).error("Order failed"); + ExceptionlessClient owned = appender.getClient(); + assertThat(owned).isNotNull(); + + long start = System.nanoTime(); + appender.stop(200, TimeUnit.MILLISECONDS); + assertThat(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)).isLessThan(3_000); + assertThat(owned.isStarted()).isFalse(); + assertThat(owned.getConfiguration().getShutdownTimeout()).hasMillis(200); + // Let the server answer, so closing it doesn't wait for the handler. + slowServer.countDown(); + } + } + + @Test + void ownsAndClosesTheClientForItsApiKey() throws Exception { + try (FakeServer fake = new FakeServer()) { + ExceptionlessAppender appender = + ExceptionlessAppender.newBuilder() + .setName("Exceptionless") + .setApiKey(TestClients.API_KEY) + .setServerUrl(fake.url()) + .build(); + Logger logger = logger("com.example.Orders", appender); + assertThat(appender.getClient()).isNull(); + + logger.error("Order failed"); + ExceptionlessClient owned = appender.getClient(); + assertThat(owned).isNotNull(); + assertThat(owned.isStarted()).isTrue(); + assertThat(owned.getConfiguration().getServerUrl()).isEqualTo(fake.url()); + assertThat(owned.getConfiguration().isCaptureUncaughtExceptions()).isFalse(); + + appender.stop(); + assertThat(appender.isStopped()).isTrue(); + assertThat(owned.isStarted()).isFalse(); + assertThat(appender.getClient()).isNull(); + + List> events = + fake.take("/api/v2/events", Duration.ofSeconds(5)).events(); + assertThat(events).extracting(e -> e.get("message")).containsExactly("Order failed"); + } + } + + @Test + void isDiscoveredByNameFromLog4j2Xml() throws Exception { + try (FakeServer fake = new FakeServer()) { + System.setProperty("exceptionless.test.serverUrl", fake.url()); + try { + context = + Configurator.initialize( + "exceptionless-xml-test", + getClass().getClassLoader(), + "exceptionless-log4j2-test.xml"); + } finally { + System.clearProperty("exceptionless.test.serverUrl"); + } + + Appender configured = context.getConfiguration().getAppender("Exceptionless"); + assertThat(configured).isInstanceOf(ExceptionlessAppender.class); + ExceptionlessAppender appender = (ExceptionlessAppender) configured; + assertThat(appender.isStarted()).isTrue(); + assertThat(appender.getApiKey()).isEqualTo(TestClients.API_KEY); + assertThat(appender.getServerUrl()).isEqualTo(fake.url()); + assertThat(appender.getMinimumLevel()).isEqualTo(LogLevel.INFO); + assertThat(appender.isIncludeMdc()).isFalse(); + + Logger logger = context.getLogger("com.example.Xml"); + logger.debug("not sent"); + logger.info("Configured from XML"); + + // Stopping the context stops the appender, which closes its client and sends the queue. + Configurator.shutdown(context); + context = null; + + List> events = + fake.take("/api/v2/events", Duration.ofSeconds(5)).events(); + assertThat(events).hasSize(1); + assertThat(events.get(0).get("message")).isEqualTo("Configured from XML"); + assertThat(data(events.get(0))) + .containsEntry("@level", "info") + .containsEntry("@submission_method", "log4j2"); + } + } +} diff --git a/exceptionless-log4j2/src/test/resources/exceptionless-log4j2-test.xml b/exceptionless-log4j2/src/test/resources/exceptionless-log4j2-test.xml new file mode 100644 index 0000000..7d82887 --- /dev/null +++ b/exceptionless-log4j2/src/test/resources/exceptionless-log4j2-test.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/exceptionless-logback/pom.xml b/exceptionless-logback/pom.xml new file mode 100644 index 0000000..71857a7 --- /dev/null +++ b/exceptionless-logback/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + + com.exceptionless + exceptionless-parent + ${revision} + ../pom.xml + + + exceptionless-logback + Exceptionless Logback + Logback appender that sends log messages and exceptions to Exceptionless. + + + com.exceptionless.logback + + + + + com.exceptionless + exceptionless-core + + + + ch.qos.logback + logback-classic + ${logback.version} + provided + + + + org.awaitility + awaitility + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + + diff --git a/exceptionless-logback/src/main/java/com/exceptionless/logback/ExceptionlessAppender.java b/exceptionless-logback/src/main/java/com/exceptionless/logback/ExceptionlessAppender.java new file mode 100644 index 0000000..725df80 --- /dev/null +++ b/exceptionless-logback/src/main/java/com/exceptionless/logback/ExceptionlessAppender.java @@ -0,0 +1,211 @@ +package com.exceptionless.logback; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.ThrowableProxy; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; +import ch.qos.logback.core.UnsynchronizedAppenderBase; +import com.exceptionless.EventBuilder; +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.LogLevel; +import com.exceptionless.logging.LogEventSupport; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.slf4j.Marker; +import org.slf4j.event.KeyValuePair; + +/** + * A Logback appender that sends log messages and exceptions to Exceptionless. + * + *

{@code
+ * 
+ *   
+ *     
+ *     YOUR_API_KEY
+ *     
+ *     https://exceptionless.example.com
+ *     
+ *     warn
+ *   
+ *
+ *   
+ *     
+ *     
+ *   
+ * 
+ * }
+ * + *

Events with an exception become error events; the rest become log events whose source is the + * logger name. MDC entries, SLF4J key-value pairs and the thread name are added as properties and + * markers as tags. A message is sent when its level is at least {@code minimumLevel}, unless the + * project's {@code @@log:} settings say otherwise (in either direction). Logback's own + * logger levels and filters still apply first, so a logger must be enabled at a level for the + * project settings to lower the threshold to it. + * + *

Client. With an {@code apiKey}, the appender creates its own client (configured from + * the environment like {@link ExceptionlessConfiguration#fromEnvironment()}, without the uncaught + * exception handler) on first use and closes it, sending queued events, when the appender stops. + * Otherwise it uses {@link Exceptionless#getDefault() the default client} once the application has + * started it, so logging never starts or locks the default client's configuration; messages logged + * before then are not sent. {@link #setClient} uses a specific client instead. See {@link + * LogEventSupport}, which the Log4j 2 and {@code java.util.logging} integrations share. + * + *

The client's own diagnostics (loggers under {@code com.exceptionless} and anything logged on + * its background threads) and messages logged while an event is being submitted are never sent. + * Appending never blocks on the network and never throws; the first failure after the appender + * starts is reported to Logback's status manager. + */ +public final class ExceptionlessAppender extends UnsynchronizedAppenderBase { + /** The submission method recorded on events sent by this appender. */ + public static final String SUBMISSION_METHOD = "logback"; + + private final LogEventSupport support = new LogEventSupport(SUBMISSION_METHOD, this::addError); + private volatile boolean includeMdc = true; + + public @Nullable String getApiKey() { + return support.getApiKey(); + } + + /** The project API key. When set, the appender creates and owns its own client. */ + public void setApiKey(@Nullable String apiKey) { + support.setApiKey(apiKey); + } + + public @Nullable String getServerUrl() { + return support.getServerUrl(); + } + + /** The server URL for self hosted installs. Only used together with {@link #setApiKey}. */ + public void setServerUrl(@Nullable String serverUrl) { + support.setServerUrl(serverUrl); + } + + public LogLevel getMinimumLevel() { + return support.getMinimumLevel(); + } + + /** + * The minimum level sent when the project has no matching {@code @@log} setting (default {@code + * warn}). Accepts Exceptionless and Logback level names; {@code off} sends only what the project + * settings enable. A String (not {@link LogLevel}) so it can be set from {@code logback.xml}. + */ + public void setMinimumLevel(@Nullable String minimumLevel) { + if (!support.setMinimumLevel(minimumLevel)) { + addWarn("Unknown minimumLevel '" + minimumLevel + "'; using " + support.getMinimumLevel()); + } + } + + public boolean isIncludeMdc() { + return includeMdc; + } + + /** Whether MDC entries are added as event properties (default true). */ + public void setIncludeMdc(boolean includeMdc) { + this.includeMdc = includeMdc; + } + + /** + * The client the appender sends to: the one passed to {@link #setClient}, the one it created for + * its {@code apiKey}, or {@code null} when it uses the default client or hasn't sent anything + * yet. + */ + public @Nullable ExceptionlessClient getClient() { + return support.getClient(); + } + + /** + * Sends to the given client. The appender doesn't close it. Takes precedence over the API key. + */ + public void setClient(@Nullable ExceptionlessClient client) { + support.setClient(client); + } + + @Override + public void start() { + support.start(); + super.start(); + } + + /** Stops the appender and closes the client it created, sending queued events. */ + @Override + public void stop() { + super.stop(); + support.close(); + } + + @Override + protected void append(ILoggingEvent event) { + LogLevel level = toLogLevel(event.getLevel()); + support.submit( + event.getLoggerName(), event.getThreadName(), level, client -> createEvent(client, event)); + } + + private EventBuilder createEvent(ExceptionlessClient client, ILoggingEvent event) { + IThrowableProxy proxy = event.getThrowableProxy(); + Throwable exception = proxy instanceof ThrowableProxy p ? p.getThrowable() : null; + EventBuilder builder = + support.createEvent( + client, + event.getLoggerName(), + event.getFormattedMessage(), + toLogLevel(event.getLevel()), + exception, + event.getThreadName()); + if (exception == null && proxy != null) { + // Deserialized events (e.g. from a socket appender) only carry the formatted stack trace. + builder.setProperty("exception", ThrowableProxyUtil.asString(proxy)); + } + + if (includeMdc) { + Map mdc = event.getMDCPropertyMap(); + if (mdc != null) { + mdc.forEach(builder::setProperty); + } + } + List pairs = event.getKeyValuePairs(); + if (pairs != null) { + for (KeyValuePair pair : pairs) { + if (pair != null && pair.key != null) { + builder.setProperty(pair.key, pair.value); + } + } + } + List markers = event.getMarkerList(); + if (markers != null) { + for (Marker marker : markers) { + if (marker != null) { + builder.addTags(marker.getName()); + } + } + } + return builder; + } + + /** + * Maps a Logback level to an Exceptionless level. Logback has no fatal level, so {@code OFF} (the + * most severe) maps to error and {@code ALL} to trace; {@code null} maps to info. + */ + static LogLevel toLogLevel(@Nullable Level level) { + if (level == null) { + return LogLevel.INFO; + } + int value = level.toInt(); + if (value >= Level.ERROR_INT) { + return LogLevel.ERROR; + } + if (value >= Level.WARN_INT) { + return LogLevel.WARN; + } + if (value >= Level.INFO_INT) { + return LogLevel.INFO; + } + if (value >= Level.DEBUG_INT) { + return LogLevel.DEBUG; + } + return LogLevel.TRACE; + } +} diff --git a/exceptionless-logback/src/main/java/com/exceptionless/logback/package-info.java b/exceptionless-logback/src/main/java/com/exceptionless/logback/package-info.java new file mode 100644 index 0000000..02ae23b --- /dev/null +++ b/exceptionless-logback/src/main/java/com/exceptionless/logback/package-info.java @@ -0,0 +1,5 @@ +/** Logback integration: {@link com.exceptionless.logback.ExceptionlessAppender}. */ +@NullMarked +package com.exceptionless.logback; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-logback/src/test/java/com/exceptionless/logback/ExceptionlessAppenderTest.java b/exceptionless-logback/src/test/java/com/exceptionless/logback/ExceptionlessAppenderTest.java new file mode 100644 index 0000000..da11034 --- /dev/null +++ b/exceptionless-logback/src/test/java/com/exceptionless/logback/ExceptionlessAppenderTest.java @@ -0,0 +1,414 @@ +package com.exceptionless.logback; + +import static com.exceptionless.TestClients.data; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.core.status.Status; +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.FakeServer; +import com.exceptionless.LogLevel; +import com.exceptionless.TestClients; +import com.exceptionless.TestClients.CapturingSubmissionClient; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.slf4j.MDC; +import org.slf4j.MarkerFactory; + +class ExceptionlessAppenderTest { + private final CapturingSubmissionClient server = new CapturingSubmissionClient(); + private LoggerContext context; + private ExceptionlessClient client; + + @BeforeEach + void setUp() { + context = new LoggerContext(); + context.setMDCAdapter(MDC.getMDCAdapter()); + context.start(); + } + + @AfterEach + void tearDown() { + MDC.clear(); + context.stop(); + if (client != null) { + client.close(); + } + Exceptionless.setDefault(null); + } + + private ExceptionlessAppender appender(ExceptionlessClient target) { + ExceptionlessAppender appender = new ExceptionlessAppender(); + appender.setContext(context); + appender.setName("EXCEPTIONLESS"); + appender.setClient(target); + appender.start(); + return appender; + } + + private Logger logger(String name, ExceptionlessAppender appender) { + Logger logger = context.getLogger(name); + logger.setLevel(Level.TRACE); + logger.setAdditive(false); + logger.addAppender(appender); + return logger; + } + + @ParameterizedTest + @CsvSource({ + "ALL, TRACE", + "TRACE, TRACE", + "DEBUG, DEBUG", + "INFO, INFO", + "WARN, WARN", + "ERROR, ERROR", + "OFF, ERROR" + }) + void mapsLevels(String logback, LogLevel expected) { + assertThat(ExceptionlessAppender.toLogLevel(Level.toLevel(logback))).isEqualTo(expected); + } + + @Test + void mapsAMissingLevelToInfo() { + assertThat(ExceptionlessAppender.toLogLevel(null)).isEqualTo(LogLevel.INFO); + } + + @Test + void sendsWarnAndAboveByDefault() { + client = server.newClient(); + Logger logger = logger("com.example.Orders", appender(client)); + + logger.trace("trace"); + logger.debug("debug"); + logger.info("info"); + logger.warn("Low stock for {}", "widget"); + logger.error("Order failed"); + + List> events = server.flush(client); + assertThat(events) + .extracting(e -> e.get("message")) + .containsExactly("Low stock for widget", "Order failed"); + Map warn = events.get(0); + assertThat(warn.get("type")).isEqualTo("log"); + assertThat(warn.get("source")).isEqualTo("com.example.Orders"); + assertThat(data(warn)) + .containsEntry("@level", "warn") + .containsEntry("@submission_method", "logback") + .containsEntry("thread", Thread.currentThread().getName()); + assertThat(data(events.get(1))).containsEntry("@level", "error"); + } + + @Test + void minimumLevelIsConfigurable() { + client = server.newClient(); + ExceptionlessAppender appender = appender(client); + appender.setMinimumLevel("info"); + assertThat(appender.getMinimumLevel()).isEqualTo(LogLevel.INFO); + appender.setMinimumLevel("bogus"); + assertThat(appender.getMinimumLevel()).isEqualTo(LogLevel.INFO); + Logger logger = logger("com.example.Orders", appender); + + logger.debug("debug"); + logger.info("info"); + + assertThat(server.flush(client)).extracting(e -> e.get("message")).containsExactly("info"); + } + + @Test + void projectLogSettingsOverrideTheMinimumLevel() { + client = server.newClient(); + client + .getConfiguration() + .applyServerSettings( + Map.of("@@log:com.example.*", "info", "@@log:com.example.Noisy", "off")); + ExceptionlessAppender appender = appender(client); + Logger orders = logger("com.example.Orders", appender); + Logger noisy = logger("com.example.Noisy", appender); + Logger other = logger("org.other.Thing", appender); + + orders.debug("orders debug"); + orders.info("orders info"); + noisy.error("noisy error"); + other.info("other info"); + other.warn("other warn"); + + assertThat(server.flush(client)) + .extracting(e -> e.get("message")) + .containsExactly("orders info", "other warn"); + } + + @Test + void sendsExceptionsAsErrors() { + client = server.newClient(); + Logger logger = logger("com.example.Payments", appender(client)); + + logger.warn("Payment {} failed", 42, new IllegalStateException("card declined")); + + List> events = server.flush(client); + assertThat(events).hasSize(1); + Map event = events.get(0); + assertThat(event.get("type")).isEqualTo("error"); + assertThat(event.get("source")).isEqualTo("com.example.Payments"); + assertThat(event.get("message")).isEqualTo("Payment 42 failed"); + assertThat(data(event)) + .containsEntry("@level", "warn") + .containsEntry("@submission_method", "logback"); + @SuppressWarnings("unchecked") + Map error = (Map) data(event).get("@error"); + assertThat(error.get("type")).isEqualTo("java.lang.IllegalStateException"); + assertThat(error.get("message")).isEqualTo("card declined"); + } + + @Test + void addsMdcKeyValuePairsAndMarkers() { + client = server.newClient(); + Logger logger = logger("com.example.Orders", appender(client)); + + MDC.put("requestId", "r-1"); + logger + .atError() + .addMarker(MarkerFactory.getMarker("checkout")) + .addMarker(MarkerFactory.getMarker("billing")) + .addKeyValue("orderId", 42) + .log("Order failed"); + + Map event = server.flush(client).get(0); + assertThat(data(event)).containsEntry("requestId", "r-1").containsEntry("orderId", 42L); + @SuppressWarnings("unchecked") + List tags = (List) event.get("tags"); + assertThat(tags).containsExactlyInAnyOrder("checkout", "billing"); + } + + @Test + void canLeaveOutMdc() { + client = server.newClient(); + ExceptionlessAppender appender = appender(client); + appender.setIncludeMdc(false); + Logger logger = logger("com.example.Orders", appender); + + MDC.put("requestId", "r-1"); + logger.atError().addKeyValue("orderId", 42).log("Order failed"); + + Map event = server.flush(client).get(0); + assertThat(data(event)).doesNotContainKey("requestId").containsEntry("orderId", 42L); + } + + @Test + void appliesDataExclusionsToMdc() { + client = + new ExceptionlessClient(server.newConfiguration().addDataExclusions("*password*")) + .startup(); + Logger logger = logger("com.example.Login", appender(client)); + + MDC.put("userPassword", "secret"); + MDC.put("user", "eric"); + logger.error("Login failed"); + + assertThat(data(server.flush(client).get(0))) + .doesNotContainKey("userPassword") + .containsEntry("user", "eric"); + } + + @Test + void ignoresTheClientsOwnLoggersAndThreads() throws Exception { + client = server.newClient(); + ExceptionlessAppender appender = appender(client); + logger("com.exceptionless.DefaultEventQueue", appender).error("internal"); + logger("com.exceptionless", appender).error("internal root"); + Logger app = logger("com.exceptionlessfan.App", appender); + Thread thread = new Thread(() -> app.error("from client thread"), "exceptionless-1"); + thread.start(); + thread.join(); + app.error("app"); + + assertThat(server.flush(client)).extracting(e -> e.get("message")).containsExactly("app"); + } + + @Test + void doesNotRecurseWhenSubmittingLogs() { + client = server.newClient(); + Logger logger = logger("com.example.Orders", appender(client)); + AtomicInteger handlerCalls = new AtomicInteger(); + client.addSubmittingEventHandler( + e -> { + handlerCalls.incrementAndGet(); + logger.error("logged while submitting"); + }); + + logger.error("Order failed"); + + assertThat(server.flush(client)) + .extracting(e -> e.get("message")) + .containsExactly("Order failed"); + assertThat(handlerCalls).hasValue(1); + } + + @Test + void usesTheDefaultClientOnceStarted() { + ExceptionlessAppender appender = new ExceptionlessAppender(); + appender.setContext(context); + appender.start(); + Logger logger = logger("com.example.Orders", appender); + + logger.error("before any default client"); + assertThat(Exceptionless.hasDefault()).isFalse(); + + client = new ExceptionlessClient(server.newConfiguration()); + Exceptionless.setDefault(client); + logger.error("before startup"); + assertThat(client.isStarted()).isFalse(); + + client.startup(); + logger.error("after startup"); + + assertThat(server.flush(client)) + .extracting(e -> e.get("message")) + .containsExactly("after startup"); + assertThat(appender.getClient()).isNull(); + } + + @Test + void doesNothingWhenTheClientIsDisabled() { + client = + new ExceptionlessClient( + new ExceptionlessConfiguration() + .submissionClient(server) + .captureUncaughtExceptions(false) + .registerShutdownHook(false)) + .startup(); + Logger logger = logger("com.example.Orders", appender(client)); + + assertThatCode( + () -> { + logger.error("one"); + logger.error("two", new RuntimeException("boom")); + }) + .doesNotThrowAnyException(); + client.flush(Duration.ofSeconds(5)); + assertThat(server.events()).isEmpty(); + } + + @Test + void neverThrowsForAClosedClient() { + client = server.newClient(); + Logger logger = logger("com.example.Orders", appender(client)); + client.close(); + + assertThatCode(() -> logger.error("after close", new RuntimeException("boom"))) + .doesNotThrowAnyException(); + assertThat(server.events()).isEmpty(); + } + + @Test + void reportsTheFirstFailureUntilRestarted() { + client = server.newClient(); + ExceptionlessAppender appender = appender(client); + Logger logger = logger("com.example.Orders", appender); + + assertThatCode( + () -> { + appender.doAppend(failingEvent(logger)); + appender.doAppend(failingEvent(logger)); + }) + .doesNotThrowAnyException(); + assertThat(sendFailures()).hasSize(1); + + appender.stop(); + appender.start(); + appender.doAppend(failingEvent(logger)); + assertThat(sendFailures()).hasSize(2); + } + + /** An event whose message can't be formatted. */ + private static LoggingEvent failingEvent(Logger logger) { + return new LoggingEvent(Logger.FQCN, logger, Level.ERROR, "unused", null, null) { + @Override + public String getFormattedMessage() { + throw new IllegalStateException("cannot format"); + } + }; + } + + private List sendFailures() { + return context.getStatusManager().getCopyOfStatusList().stream() + .filter(s -> s.getMessage().startsWith("Unable to send a log event")) + .toList(); + } + + @Test + void ownsAndClosesTheClientForItsApiKey() throws Exception { + try (FakeServer fake = new FakeServer()) { + ExceptionlessAppender appender = new ExceptionlessAppender(); + appender.setContext(context); + appender.setApiKey(TestClients.API_KEY); + appender.setServerUrl(fake.url()); + appender.start(); + assertThat(appender.getClient()).isNull(); + Logger logger = logger("com.example.Orders", appender); + + logger.error("Order failed"); + ExceptionlessClient owned = appender.getClient(); + assertThat(owned).isNotNull(); + assertThat(owned.isStarted()).isTrue(); + assertThat(owned.getConfiguration().getServerUrl()).isEqualTo(fake.url()); + assertThat(owned.getConfiguration().isCaptureUncaughtExceptions()).isFalse(); + + appender.stop(); + assertThat(owned.isStarted()).isFalse(); + assertThat(appender.getClient()).isNull(); + + List> events = + fake.take("/api/v2/events", Duration.ofSeconds(5)).events(); + assertThat(events).extracting(e -> e.get("message")).containsExactly("Order failed"); + } + } + + @Test + void isConfiguredFromLogbackXml() throws Exception { + try (FakeServer fake = new FakeServer()) { + context.putProperty("exceptionlessServerUrl", fake.url()); + JoranConfigurator configurator = new JoranConfigurator(); + configurator.setContext(context); + configurator.doConfigure(getClass().getResource("/exceptionless-logback-test.xml")); + + ExceptionlessAppender appender = + (ExceptionlessAppender) + context.getLogger(Logger.ROOT_LOGGER_NAME).getAppender("EXCEPTIONLESS"); + assertThat(appender).isNotNull(); + assertThat(appender.isStarted()).isTrue(); + assertThat(appender.getApiKey()).isEqualTo(TestClients.API_KEY); + assertThat(appender.getServerUrl()).isEqualTo(fake.url()); + assertThat(appender.getMinimumLevel()).isEqualTo(LogLevel.INFO); + assertThat(appender.isIncludeMdc()).isFalse(); + + org.slf4j.Logger logger = context.getLogger("com.example.Xml"); + logger.debug("not sent"); + logger.info("Configured from XML"); + + // Stopping the context stops the appender, which closes its client and sends the queue. + context.stop(); + + List> events = + fake.take("/api/v2/events", Duration.ofSeconds(5)).events(); + assertThat(events).hasSize(1); + assertThat(events.get(0).get("message")).isEqualTo("Configured from XML"); + assertThat(data(events.get(0))) + .containsEntry("@level", "info") + .containsEntry("@submission_method", "logback"); + } + } +} diff --git a/exceptionless-logback/src/test/resources/exceptionless-logback-test.xml b/exceptionless-logback/src/test/resources/exceptionless-logback-test.xml new file mode 100644 index 0000000..646e6fc --- /dev/null +++ b/exceptionless-logback/src/test/resources/exceptionless-logback-test.xml @@ -0,0 +1,12 @@ + + + LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest + ${exceptionlessServerUrl} + info + false + + + + + + diff --git a/exceptionless-servlet/pom.xml b/exceptionless-servlet/pom.xml new file mode 100644 index 0000000..b2e625b --- /dev/null +++ b/exceptionless-servlet/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + + com.exceptionless + exceptionless-parent + ${revision} + ../pom.xml + + + exceptionless-servlet + Exceptionless Servlet + Jakarta Servlet filter that reports unhandled exceptions and 404s with request details to Exceptionless. + + + com.exceptionless.servlet + 12.1.14 + + + + + com.exceptionless + exceptionless-core + + + org.jspecify + jspecify + + + jakarta.servlet + jakarta.servlet-api + ${jakarta.servlet.version} + provided + + + + org.awaitility + awaitility + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.eclipse.jetty.ee10 + jetty-ee10-servlet + ${jetty.version} + test + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + + diff --git a/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ExceptionlessFilter.java b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ExceptionlessFilter.java new file mode 100644 index 0000000..9ae1b4b --- /dev/null +++ b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ExceptionlessFilter.java @@ -0,0 +1,289 @@ +package com.exceptionless.servlet; + +import com.exceptionless.EventBuilder; +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.logging.LogEventSupport; +import com.exceptionless.models.UserInfo; +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpFilter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.jspecify.annotations.Nullable; + +/** + * Reports unhandled exceptions and 404 responses to Exceptionless, with the request details, the + * signed in user ({@link HttpServletRequest#getUserPrincipal()}, when {@code includeUserName} is + * on) and {@code @submission_method} {@value #SUBMISSION_METHOD}. + * + *

While a request runs, the filter also makes it available to {@link ServletRequestInfoPlugin}, + * so errors logged during the request (through an Exceptionless logging appender, or submitted + * directly) get the request details too. An exception that is both logged and thrown out of the + * filter is only reported once. + * + *

Register it first in the chain so it sees the exceptions of every other filter. In {@code + * web.xml}: + * + *

{@code
+ * 
+ *   exceptionless
+ *   com.exceptionless.servlet.ExceptionlessFilter
+ *   true
+ *   
+ *     captureNotFound
+ *     true
+ *   
+ * 
+ * 
+ *   exceptionless
+ *   /*
+ * 
+ * }
+ * + * Or in code, with your own client: + * + *
{@code
+ * servletContext.addFilter("exceptionless", new ExceptionlessFilter(client))
+ *     .addMappingForUrlPatterns(null, false, "/*");
+ * }
+ * + *

Without a client, the filter uses {@link Exceptionless#getDefault() the default client}, + * looked up on every request and only once the application has started it; until then (and after + * {@link Exceptionless#shutdown()}) requests pass through untouched. Exceptions are always rethrown + * unchanged, so the container's error handling still runs. + * + *

Async requests: when the request goes async ({@link HttpServletRequest#startAsync()}), + * the 404 check waits until the async request completes, and errors reported to {@link + * AsyncListener#onError} are submitted as unhandled. Errors logged on application threads during + * async processing don't get request details, because the request is only tracked on the container + * threads that run the filter. + */ +public class ExceptionlessFilter extends HttpFilter { + /** The {@code @submission_method} of the events reported by this filter. */ + public static final String SUBMISSION_METHOD = "servletFilter"; + + /** The init parameter that turns 404 reporting on or off ({@code true} by default). */ + public static final String CAPTURE_NOT_FOUND_PARAM = "captureNotFound"; + + private static final System.Logger LOG = System.getLogger(ExceptionlessFilter.class.getName()); + private static final String HANDLED_ATTRIBUTE = ExceptionlessFilter.class.getName() + ".HANDLED"; + private static final String ASYNC_ATTRIBUTE = ExceptionlessFilter.class.getName() + ".ASYNC"; + + private static final int MAX_UNWRAP_DEPTH = 10; + + private final transient @Nullable ExceptionlessClient client; + + /** The client {@link ServletRequestInfoPlugin} was last registered with. */ + private transient volatile @Nullable ExceptionlessClient pluginClient; + + private volatile boolean captureNotFound = true; + + /** Uses {@link Exceptionless#getDefault() the default client} once it has started. */ + public ExceptionlessFilter() { + this.client = null; + } + + /** Reports to {@code client}. */ + public ExceptionlessFilter(ExceptionlessClient client) { + this.client = client; + } + + /** Reads the {@value #CAPTURE_NOT_FOUND_PARAM} init parameter. */ + @Override + public void init() { + String value = getInitParameter(CAPTURE_NOT_FOUND_PARAM); + if (value != null && !value.isBlank()) { + captureNotFound = Boolean.parseBoolean(value.trim()); + } + } + + /** Whether 404 responses are reported. */ + public boolean isCaptureNotFound() { + return captureNotFound; + } + + /** Turns 404 reporting on (the default) or off. */ + public void setCaptureNotFound(boolean captureNotFound) { + this.captureNotFound = captureNotFound; + } + + /** + * The client events are reported to now: the one passed to the constructor, else the {@link + * Exceptionless#getDefault() default client} once it has started, else {@code null} (requests + * pass through). Never creates the default client. + */ + public @Nullable ExceptionlessClient getClient() { + return client != null ? client : LogEventSupport.startedDefaultClient(); + } + + /** Registers {@link ServletRequestInfoPlugin} once per client. */ + private void registerPlugin(ExceptionlessClient exceptionless) { + if (pluginClient != exceptionless) { + ExceptionlessConfiguration config = exceptionless.getConfiguration(); + if (!config.hasPlugin(ServletRequestInfoPlugin.NAME)) { + config.addPlugin(new ServletRequestInfoPlugin()); + } + pluginClient = exceptionless; + } + } + + @Override + protected void doFilter( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + ExceptionlessClient exceptionless = getClient(); + if (exceptionless == null) { + chain.doFilter(request, response); + return; + } + registerPlugin(exceptionless); + + // Only the first REQUEST dispatch checks for 404s, even if the filter is registered twice. + boolean checkNotFound = + request.getDispatcherType() == DispatcherType.REQUEST + && request.getAttribute(HANDLED_ATTRIBUTE) == null; + if (checkNotFound) { + request.setAttribute(HANDLED_ATTRIBUTE, true); + } + + HttpServletRequest previous = ServletRequestInfoPlugin.setCurrentRequest(request); + try { + chain.doFilter(request, response); + } catch (Throwable error) { + reportUnhandled(exceptionless, error, request); + throw error; + } finally { + ServletRequestInfoPlugin.setCurrentRequest(previous); + } + + if (!checkNotFound) { + return; + } + if (request.isAsyncStarted()) { + listenForAsyncCompletion(exceptionless, request, response); + } else if (response.getStatus() == HttpServletResponse.SC_NOT_FOUND) { + reportNotFound(exceptionless, request); + } + } + + private void listenForAsyncCompletion( + ExceptionlessClient exceptionless, HttpServletRequest request, HttpServletResponse response) { + if (request.getAttribute(ASYNC_ATTRIBUTE) != null) { + return; + } + request.setAttribute(ASYNC_ATTRIBUTE, true); + try { + request + .getAsyncContext() + .addListener(new CompletionListener(exceptionless), request, response); + } catch (IllegalStateException e) { + LOG.log(System.Logger.Level.DEBUG, "Unable to listen for async completion", e); + } + } + + /** Reports an exception that escaped the filter chain. Never throws. */ + protected void reportUnhandled( + ExceptionlessClient exceptionless, Throwable error, HttpServletRequest request) { + try { + EventBuilder builder = + exceptionless + .createUnhandledException(unwrap(error), SUBMISSION_METHOD) + .setRequestInfo(ServletRequestInfoFactory.create(request)); + setUser(builder, request, exceptionless.getConfiguration()); + builder.submit(); + } catch (RuntimeException | LinkageError e) { + LOG.log(System.Logger.Level.WARNING, "Unable to report unhandled exception", e); + } + } + + /** Reports a 404 response. Never throws. */ + protected void reportNotFound(ExceptionlessClient exceptionless, HttpServletRequest request) { + if (!captureNotFound) { + return; + } + try { + EventBuilder builder = + exceptionless + .createNotFound(request.getRequestURI()) + .setSubmissionMethod(SUBMISSION_METHOD) + .setRequestInfo(ServletRequestInfoFactory.create(request)); + setUser(builder, request, exceptionless.getConfiguration()); + builder.submit(); + } catch (RuntimeException | LinkageError e) { + LOG.log(System.Logger.Level.WARNING, "Unable to report 404", e); + } + } + + private static void setUser( + EventBuilder builder, HttpServletRequest request, ExceptionlessConfiguration config) { + UserInfo user = ServletRequestInfoPlugin.userInfo(request, config); + if (user != null) { + builder.setUserIdentity(user); + } + } + + /** + * The exception to report: {@link ServletException}s that only wrap another exception (as + * frameworks such as Spring MVC do) are unwrapped to their root cause. + */ + static Throwable unwrap(Throwable error) { + Throwable current = error; + for (int depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) { + Throwable cause = current instanceof ServletException e ? e.getRootCause() : null; + if (cause == null) { + break; + } + current = cause; + } + return current; + } + + private final class CompletionListener implements AsyncListener { + private final ExceptionlessClient exceptionless; + private volatile boolean failed; + + CompletionListener(ExceptionlessClient exceptionless) { + this.exceptionless = exceptionless; + } + + @Override + public void onComplete(AsyncEvent event) { + if (failed + || !(event.getSuppliedRequest() instanceof HttpServletRequest request) + || !(event.getSuppliedResponse() instanceof HttpServletResponse response)) { + return; + } + if (response.getStatus() == HttpServletResponse.SC_NOT_FOUND) { + reportNotFound(exceptionless, request); + } + } + + @Override + public void onError(AsyncEvent event) { + failed = true; + Throwable error = event.getThrowable(); + if (error != null && event.getSuppliedRequest() instanceof HttpServletRequest request) { + reportUnhandled(exceptionless, error, request); + } + } + + @Override + public void onTimeout(AsyncEvent event) {} + + @Override + public void onStartAsync(AsyncEvent event) { + // The request went async again: keep listening for its completion. + if (event.getAsyncContext() != null) { + event + .getAsyncContext() + .addListener(this, event.getSuppliedRequest(), event.getSuppliedResponse()); + } + } + } +} diff --git a/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoFactory.java b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoFactory.java new file mode 100644 index 0000000..e73a2c9 --- /dev/null +++ b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoFactory.java @@ -0,0 +1,174 @@ +package com.exceptionless.servlet; + +import com.exceptionless.models.RequestInfo; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * Captures an {@link HttpServletRequest} as a {@link RequestInfo} ({@code @request}). + * + *

The request is captured as is: the client's privacy settings ({@code includeHeaders}, {@code + * includeCookies}, ...), the ignored headers ({@code Authorization}, {@code Cookie}, ...) and the + * data exclusions are applied later by the event pipeline, so this is safe to call for any request. + * + *

{@code
+ * client.createException(e)
+ *     .setRequestInfo(ServletRequestInfoFactory.create(request))
+ *     .submit();
+ * }
+ * + *

The request body is never read directly. Form fields ({@code post_data}) are only captured + * when {@code includeFormData} is true, because reading the parameters of a form post makes the + * container consume the body, which would break an application that reads it later. + */ +public final class ServletRequestInfoFactory { + private ServletRequestInfoFactory() {} + + /** Captures the request, including form fields. Use once the request has been handled. */ + public static RequestInfo create(HttpServletRequest request) { + return create(request, true); + } + + /** + * Captures the request. + * + * @param includeFormData whether to capture the fields of {@code + * application/x-www-form-urlencoded} and {@code multipart/form-data} posts. Only pass {@code + * true} once the application is done with the request body. + */ + public static RequestInfo create(HttpServletRequest request, boolean includeFormData) { + RequestInfo info = new RequestInfo(); + info.setHttpMethod(request.getMethod()); + info.setSecure(request.isSecure()); + info.setHost(request.getServerName()); + info.setPort(request.getServerPort()); + info.setPath(request.getRequestURI()); + info.setReferrer(request.getHeader("Referer")); + info.setUserAgent(request.getHeader("User-Agent")); + info.setClientIpAddress(request.getRemoteAddr()); + + Map> headers = headers(request); + if (!headers.isEmpty()) { + info.setHeaders(headers); + } + Map cookies = cookies(request); + if (!cookies.isEmpty()) { + info.setCookies(cookies); + } + Map query = parseQueryString(request.getQueryString()); + if (!query.isEmpty()) { + info.setQueryString(query); + } + if (includeFormData) { + Map form = formData(request, query); + if (!form.isEmpty()) { + info.setPostData(form); + } + } + return info; + } + + private static Map> headers(HttpServletRequest request) { + Map> headers = new LinkedHashMap<>(); + Enumeration names = request.getHeaderNames(); + if (names == null) { + return headers; + } + for (String name : Collections.list(names)) { + Enumeration values = request.getHeaders(name); + List list = values == null ? new ArrayList<>() : Collections.list(values); + if (!list.isEmpty()) { + headers.computeIfAbsent(name, k -> new ArrayList<>()).addAll(list); + } + } + return headers; + } + + private static Map cookies(HttpServletRequest request) { + Map cookies = new LinkedHashMap<>(); + Cookie[] array; + try { + array = request.getCookies(); + } catch (RuntimeException e) { + return cookies; // Malformed cookie header. + } + if (array != null) { + for (Cookie cookie : array) { + if (cookie != null && cookie.getName() != null && cookie.getValue() != null) { + cookies.putIfAbsent(cookie.getName(), cookie.getValue()); + } + } + } + return cookies; + } + + /** + * Parses a raw query string into its URL-decoded names and values. The first value wins when a + * name repeats; names without a value map to an empty string. + */ + static Map parseQueryString(@Nullable String queryString) { + Map values = new LinkedHashMap<>(); + if (queryString == null || queryString.isEmpty()) { + return values; + } + for (String pair : queryString.split("&", -1)) { + if (pair.isEmpty()) { + continue; + } + int equals = pair.indexOf('='); + String name = decode(equals < 0 ? pair : pair.substring(0, equals)); + String value = equals < 0 ? "" : decode(pair.substring(equals + 1)); + if (!name.isEmpty()) { + values.putIfAbsent(name, value); + } + } + return values; + } + + private static String decode(String value) { + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return value; // Invalid escape sequence: keep it raw. + } + } + + private static Map formData( + HttpServletRequest request, Map query) { + Map form = new LinkedHashMap<>(); + String contentType = request.getContentType(); + if (contentType == null) { + return form; + } + String type = contentType.toLowerCase(Locale.ROOT); + if (!type.startsWith("application/x-www-form-urlencoded") + && !type.startsWith("multipart/form-data")) { + return form; + } + Map parameters; + try { + parameters = request.getParameterMap(); + } catch (RuntimeException e) { + return form; // E.g. multipart without a multipart config, or a body that is too large. + } + for (Map.Entry entry : parameters.entrySet()) { + String name = entry.getKey(); + String[] values = entry.getValue(); + if (name == null || query.containsKey(name) || values == null || values.length == 0) { + continue; + } + form.put(name, values.length == 1 ? values[0] : String.join(",", values)); + } + return form; + } +} diff --git a/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoPlugin.java b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoPlugin.java new file mode 100644 index 0000000..dc7af1b --- /dev/null +++ b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoPlugin.java @@ -0,0 +1,100 @@ +package com.exceptionless.servlet; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.models.Event; +import com.exceptionless.models.EventTypes; +import com.exceptionless.models.UserInfo; +import com.exceptionless.plugins.EventPlugin; +import com.exceptionless.plugins.EventPluginContext; +import com.exceptionless.plugins.RequestInfoPlugin; +import jakarta.servlet.http.HttpServletRequest; +import java.security.Principal; +import org.jspecify.annotations.Nullable; + +/** + * Adds the request being handled on the current thread to error and 404 events that don't have + * request info yet, for example an error logged with {@code log.error("Payment failed", e)} from a + * controller. {@link ExceptionlessFilter} registers this plugin and tracks the current request. + * + *

Runs before the core {@link RequestInfoPlugin}, which applies the privacy settings and data + * exclusions to the captured request. Form fields are not captured here: the application may not + * have read the request body yet. + */ +public final class ServletRequestInfoPlugin implements EventPlugin { + /** The plugin name, used to register it only once per client. */ + public static final String NAME = "ServletRequestInfoPlugin"; + + /** Runs before {@link RequestInfoPlugin#PRIORITY} so the request is sanitized. */ + public static final int PRIORITY = RequestInfoPlugin.PRIORITY - 5; + + private static final ThreadLocal<@Nullable HttpServletRequest> CURRENT = new ThreadLocal<>(); + + @Override + public String getName() { + return NAME; + } + + @Override + public int getPriority() { + return PRIORITY; + } + + /** + * The request {@link ExceptionlessFilter} is handling on the current thread, or {@code null} + * outside of a request. + */ + public static @Nullable HttpServletRequest getCurrentRequest() { + return CURRENT.get(); + } + + /** Sets the current request and returns the previous one, to restore afterwards. */ + static @Nullable HttpServletRequest setCurrentRequest(@Nullable HttpServletRequest request) { + HttpServletRequest previous = CURRENT.get(); + if (request == null) { + CURRENT.remove(); + } else { + CURRENT.set(request); + } + return previous; + } + + @Override + public void run(EventPluginContext context) { + HttpServletRequest request = CURRENT.get(); + if (request == null) { + return; + } + Event event = context.getEvent(); + if (!EventTypes.ERROR.equals(event.getType()) + && !EventTypes.NOT_FOUND.equals(event.getType())) { + return; + } + if (event.getRequestInfo() == null && context.getContextData().getRequestInfo() == null) { + context.getContextData().setRequestInfo(ServletRequestInfoFactory.create(request, false)); + } + if (event.getUserInfo() == null) { + UserInfo user = userInfo(request, context.getConfiguration()); + if (user != null) { + event.setUserInfo(user); + } + } + } + + /** The signed in user, when user names may be sent. */ + static @Nullable UserInfo userInfo( + HttpServletRequest request, ExceptionlessConfiguration config) { + if (!config.isIncludeUserName()) { + return null; + } + Principal principal; + try { + principal = request.getUserPrincipal(); + } catch (RuntimeException e) { + return null; + } + if (principal == null || principal.getName() == null || principal.getName().isBlank()) { + return null; + } + return new UserInfo(principal.getName()); + } +} diff --git a/exceptionless-servlet/src/main/java/com/exceptionless/servlet/package-info.java b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/package-info.java new file mode 100644 index 0000000..bd9080f --- /dev/null +++ b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/package-info.java @@ -0,0 +1,9 @@ +/** + * Jakarta Servlet integration: {@link com.exceptionless.servlet.ExceptionlessFilter} reports + * unhandled exceptions and 404s with the request details, and adds the current request to errors + * logged while a request runs. + */ +@NullMarked +package com.exceptionless.servlet; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-servlet/src/test/java/com/exceptionless/servlet/ExceptionlessFilterTest.java b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/ExceptionlessFilterTest.java new file mode 100644 index 0000000..17c1c98 --- /dev/null +++ b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/ExceptionlessFilterTest.java @@ -0,0 +1,511 @@ +package com.exceptionless.servlet; + +import static com.exceptionless.TestClients.data; +import static com.exceptionless.TestClients.error; +import static com.exceptionless.TestClients.request; +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.TestClients.CapturingSubmissionClient; +import jakarta.servlet.AsyncContext; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.Filter; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.http.HttpResponse; +import java.security.Principal; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ExceptionlessFilterTest { + private final CapturingSubmissionClient recorder = new CapturingSubmissionClient(); + private ExceptionlessClient client; + private JettyServer server; + + @AfterEach + void tearDown() throws Exception { + if (server != null) { + server.close(); + } + if (client != null) { + client.close(); + } + } + + private void start(Consumer configure, Consumer filters) + throws Exception { + ExceptionlessConfiguration config = recorder.newConfiguration(); + configure.accept(config); + client = new ExceptionlessClient(config).startup(); + server = new JettyServer(); + filters.accept(server); + server.servlet(new TestServlet(client)).start(); + } + + private void start() throws Exception { + start(c -> {}, s -> s.filter(new ExceptionlessFilter(client))); + } + + @Test + void reportsUnhandledExceptionWithRequestInfo() throws Exception { + start(); + + HttpResponse response = + server.get( + "/fail?id=42&name=John%20Doe&id=43&flag", + "Authorization", + "Bearer secret", + "Cookie", + "session=abc123; theme=dark", + "User-Agent", + "TestAgent/1.0", + "Referer", + "https://example.com/start", + "X-Custom", + "one", + "X-Custom", + "two"); + assertThat(response.statusCode()).isEqualTo(500); + + List> events = recorder.awaitEvents(client, 1); + assertThat(events).hasSize(1); + Map event = events.get(0); + assertThat(event.get("type")).isEqualTo("error"); + assertThat(error(event)) + .containsEntry("type", "java.lang.IllegalStateException") + .containsEntry("message", "boom"); + assertThat(data(event)).containsEntry("@submission_method", "servletFilter"); + + Map request = request(event); + assertThat(request) + .containsEntry("http_method", "GET") + .containsEntry("path", "/fail") + .containsEntry("host", "127.0.0.1") + .containsEntry("is_secure", false) + .containsEntry("user_agent", "TestAgent/1.0") + .containsEntry("referrer", "https://example.com/start") + .containsEntry("client_ip_address", "127.0.0.1") + .containsKey("port"); + assertThat(request.get("query_string")) + .isEqualTo(Map.of("id", "42", "name", "John Doe", "flag", "")); + assertThat(request.get("cookies")).isEqualTo(Map.of("session", "abc123", "theme", "dark")); + @SuppressWarnings("unchecked") + Map headers = (Map) request.get("headers"); + assertThat(headers.keySet()) + .noneMatch(name -> name.equalsIgnoreCase("authorization")) + .noneMatch(name -> name.equalsIgnoreCase("cookie")) + .noneMatch(name -> name.equalsIgnoreCase("user-agent")) + .noneMatch(name -> name.equalsIgnoreCase("host")); + assertThat(headers.entrySet()) + .anySatisfy( + header -> { + assertThat(header.getKey()).isEqualToIgnoringCase("X-Custom"); + assertThat(header.getValue()).isEqualTo(List.of("one", "two")); + }); + assertThat(request).doesNotContainKey("post_data"); + } + + @Test + void unwrapsServletExceptionRootCause() throws Exception { + start(); + + assertThat(server.get("/wrapped").statusCode()).isEqualTo(500); + + Map event = recorder.awaitEvents(client, 1).get(0); + assertThat(error(event)) + .containsEntry("type", "java.lang.IllegalArgumentException") + .containsEntry("message", "bad input"); + } + + @Test + void rethrowsTheOriginalException() throws Exception { + AtomicReference thrown = new AtomicReference<>(); + Filter outer = + (request, response, chain) -> { + try { + chain.doFilter(request, response); + } catch (IOException | ServletException | RuntimeException e) { + thrown.set(e); + throw e; + } + }; + start(c -> {}, s -> s.filter(outer).filter(new ExceptionlessFilter(client))); + + server.get("/wrapped"); + + assertThat(thrown.get()) + .isExactlyInstanceOf(ServletException.class) + .hasMessage("Request processing failed"); + assertThat(recorder.awaitEvents(client, 1)).hasSize(1); + } + + @Test + void reportsNotFound() throws Exception { + start(); + + assertThat(server.get("/missing?page=2", "User-Agent", "Mozilla/5.0").statusCode()) + .isEqualTo(404); + + Map event = recorder.awaitEvents(client, 1).get(0); + assertThat(event.get("type")).isEqualTo("404"); + assertThat(event.get("source")).isEqualTo("/missing"); + assertThat(data(event)).containsEntry("@submission_method", "servletFilter"); + assertThat(request(event)) + .containsEntry("path", "/missing") + .containsEntry("query_string", Map.of("page", "2")); + } + + @Test + void doesNotReportNotFoundWhenDisabled() throws Exception { + start( + c -> {}, + s -> s.filter(new ExceptionlessFilter(client), Map.of("captureNotFound", "false"))); + + assertThat(server.get("/missing").statusCode()).isEqualTo(404); + client.submitLog("marker"); + + List> events = recorder.awaitEvents(client, 1); + assertThat(events).extracting(e -> e.get("message")).containsExactly("marker"); + } + + @Test + void doesNotReportSuccessfulRequests() throws Exception { + start(); + + assertThat(server.get("/ok").statusCode()).isEqualTo(200); + client.submitLog("marker"); + + assertThat(recorder.awaitEvents(client, 1)) + .extracting(e -> e.get("message")) + .containsExactly("marker"); + } + + @Test + void stripsPrivateInformationWhenDisabled() throws Exception { + start(c -> c.includePrivateInformation(false), s -> s.filter(new ExceptionlessFilter(client))); + + server.get("/fail?token=abc", "Cookie", "session=abc123", "X-Custom", "value"); + + Map request = request(recorder.awaitEvents(client, 1).get(0)); + assertThat(request).containsEntry("path", "/fail").containsEntry("http_method", "GET"); + assertThat(request) + .doesNotContainKeys("cookies", "query_string", "headers", "client_ip_address", "post_data"); + } + + @Test + void appliesDataExclusionsToRequest() throws Exception { + start( + c -> c.addDataExclusions("*token*", "session"), + s -> s.filter(new ExceptionlessFilter(client))); + + server.get( + "/fail?access_token=abc&page=1", + "Cookie", + "session=abc123; theme=dark", + "X-Token", + "t", + "X-Other", + "o"); + + Map request = request(recorder.awaitEvents(client, 1).get(0)); + assertThat(request.get("query_string")).isEqualTo(Map.of("page", "1")); + assertThat(request.get("cookies")).isEqualTo(Map.of("theme", "dark")); + @SuppressWarnings("unchecked") + Map headers = (Map) request.get("headers"); + assertThat(headers.keySet()) + .noneMatch(name -> name.equalsIgnoreCase("x-token")) + .anyMatch(name -> name.equalsIgnoreCase("x-other")); + } + + @Test + void capturesFormFieldsButNotQueryStringInPostData() throws Exception { + start(); + + server.postForm("/fail?page=1", "name=Jane&color=red&color=blue"); + + Map request = request(recorder.awaitEvents(client, 1).get(0)); + assertThat(request).containsEntry("http_method", "POST"); + assertThat(request.get("post_data")).isEqualTo(Map.of("name", "Jane", "color", "red,blue")); + assertThat(request.get("query_string")).isEqualTo(Map.of("page", "1")); + } + + @Test + void addsRequestInfoToErrorsSubmittedDuringTheRequest() throws Exception { + start(); + + assertThat(server.get("/logged?q=1", "User-Agent", "TestAgent/1.0").statusCode()) + .isEqualTo(200); + + List> events = recorder.awaitEvents(client, 2); + Map error = + events.stream().filter(e -> "error".equals(e.get("type"))).findFirst().orElseThrow(); + assertThat(request(error)) + .containsEntry("path", "/logged") + .containsEntry("user_agent", "TestAgent/1.0") + .containsEntry("query_string", Map.of("q", "1")); + assertThat(data(error)).doesNotContainKey("@submission_method"); + Map log = + events.stream().filter(e -> "log".equals(e.get("type"))).findFirst().orElseThrow(); + assertThat(data(log)).doesNotContainKey("@request"); + } + + @Test + void doesNotAddRequestInfoOutsideOfRequests() throws Exception { + start(); + server.get("/ok"); + + client.submitException(new IllegalStateException("background")); + + Map event = recorder.awaitEvents(client, 1).get(0); + assertThat(data(event)).doesNotContainKey("@request"); + assertThat(ServletRequestInfoPlugin.getCurrentRequest()).isNull(); + } + + @Test + void reportsExceptionOnceWhenLoggedAndThrown() throws Exception { + start(); + + assertThat(server.get("/logged-and-thrown").statusCode()).isEqualTo(500); + client.submitLog("marker"); + + List> events = recorder.awaitEvents(client, 2); + assertThat(events).extracting(e -> e.get("type")).containsExactlyInAnyOrder("error", "log"); + Map error = + events.stream().filter(e -> "error".equals(e.get("type"))).findFirst().orElseThrow(); + assertThat(error(error)).containsEntry("message", "twice"); + assertThat(request(error)).containsEntry("path", "/logged-and-thrown"); + } + + @Test + void includesSignedInUser() throws Exception { + start(c -> {}, s -> s.filter(principalFilter("alice")).filter(new ExceptionlessFilter(client))); + + server.get("/fail"); + + Map event = recorder.awaitEvents(client, 1).get(0); + assertThat(data(event).get("@user")).isEqualTo(Map.of("identity", "alice")); + } + + @Test + void omitsSignedInUserWhenUserNamesAreExcluded() throws Exception { + start( + c -> c.includeUserName(false), + s -> s.filter(principalFilter("alice")).filter(new ExceptionlessFilter(client))); + + server.get("/fail"); + + Map event = recorder.awaitEvents(client, 1).get(0); + assertThat(data(event)).doesNotContainKey("@user"); + } + + @Test + void reportsAsyncNotFoundWhenTheRequestCompletes() throws Exception { + start(); + + assertThat(server.get("/async-missing").statusCode()).isEqualTo(404); + + Map event = recorder.awaitEvents(client, 1).get(0); + assertThat(event.get("type")).isEqualTo("404"); + assertThat(event.get("source")).isEqualTo("/async-missing"); + } + + @Test + void doesNotReportSuccessfulAsyncRequests() throws Exception { + start(); + + assertThat(server.get("/async-ok").statusCode()).isEqualTo(200); + client.submitLog("marker"); + + assertThat(recorder.awaitEvents(client, 1)) + .extracting(e -> e.get("message")) + .containsExactly("marker"); + } + + @Test + void reportsAsyncDispatchErrorsOnce() throws Exception { + start( + c -> {}, + s -> + s.filter( + new ExceptionlessFilter(client), + Map.of(), + DispatcherType.REQUEST, + DispatcherType.ASYNC)); + + assertThat(server.get("/async-fail").statusCode()).isEqualTo(500); + client.submitLog("marker"); + + List> events = recorder.awaitEvents(client, 2); + assertThat(events).extracting(e -> e.get("type")).containsExactlyInAnyOrder("error", "log"); + Map error = + events.stream().filter(e -> "error".equals(e.get("type"))).findFirst().orElseThrow(); + assertThat(error(error)).containsEntry("message", "boom"); + assertThat(request(error)).containsKey("path"); + } + + @Test + void usesTheDefaultClientWhenNoneIsGiven() throws Exception { + ExceptionlessClient previous = Exceptionless.hasDefault() ? Exceptionless.getDefault() : null; + try { + start( + c -> {}, + s -> { + Exceptionless.setDefault(client); + s.filter(new ExceptionlessFilter()); + }); + + server.get("/fail"); + + assertThat(recorder.awaitEvents(client, 1).get(0).get("type")).isEqualTo("error"); + } finally { + Exceptionless.setDefault(previous); + } + } + + @Test + void passesRequestsThroughUntilTheDefaultClientHasStarted() throws Exception { + ExceptionlessClient previous = Exceptionless.hasDefault() ? Exceptionless.getDefault() : null; + Exceptionless.setDefault(null); + ExceptionlessClient notStarted = new ExceptionlessClient(recorder.newConfiguration()); + try { + start(c -> {}, s -> s.filter(new ExceptionlessFilter())); + + assertThat(server.get("/fail").statusCode()).isEqualTo(500); + assertThat(Exceptionless.hasDefault()).as("the filter never creates it").isFalse(); + + Exceptionless.setDefault(notStarted); + assertThat(server.get("/fail").statusCode()).isEqualTo(500); + assertThat(notStarted.isStarted()).as("the filter never starts it").isFalse(); + + client.submitLog("marker"); + assertThat(recorder.awaitEvents(client, 1)) + .extracting(e -> e.get("message")) + .containsExactly("marker"); + } finally { + notStarted.close(); + Exceptionless.setDefault(previous); + } + } + + @Test + void followsTheDefaultClientWhenItIsReplaced() throws Exception { + ExceptionlessClient previous = Exceptionless.hasDefault() ? Exceptionless.getDefault() : null; + CapturingSubmissionClient otherRecorder = new CapturingSubmissionClient(); + ExceptionlessClient other = otherRecorder.newClient(); + try { + start( + c -> {}, + s -> { + Exceptionless.setDefault(client); + s.filter(new ExceptionlessFilter()); + }); + server.get("/fail"); + assertThat(recorder.awaitEvents(client, 1)).hasSize(1); + + Exceptionless.setDefault(other); + server.get("/missing"); + + Map event = otherRecorder.awaitEvents(other, 1).get(0); + assertThat(event.get("type")).isEqualTo("404"); + assertThat(request(event)).containsEntry("path", "/missing"); + assertThat(other.getConfiguration().hasPlugin(ServletRequestInfoPlugin.NAME)).isTrue(); + assertThat(recorder.flush(client)).hasSize(1); + } finally { + other.close(); + Exceptionless.setDefault(previous); + } + } + + @Test + void registersRequestPluginOnce() throws Exception { + start(); + server.get("/ok"); + server.get("/ok"); + + assertThat(client.getConfiguration().getPlugins()) + .filteredOn(p -> p.getName().equals(ServletRequestInfoPlugin.NAME)) + .hasSize(1); + } + + private static Filter principalFilter(String name) { + return (request, response, chain) -> + chain.doFilter( + new HttpServletRequestWrapper((HttpServletRequest) request) { + @Override + public Principal getUserPrincipal() { + return () -> name; + } + }, + response); + } + + /** Routes requests by path. */ + static final class TestServlet extends HttpServlet { + private final transient ExceptionlessClient client; + + TestServlet(ExceptionlessClient client) { + this.client = client; + } + + @Override + protected void service(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + String path = request.getRequestURI(); + switch (path) { + case "/fail" -> throw new IllegalStateException("boom"); + case "/wrapped" -> + throw new ServletException( + "Request processing failed", new IllegalArgumentException("bad input")); + case "/missing" -> response.sendError(404); + case "/logged" -> { + client.createException(new IllegalStateException("logged")).submit(); + client.submitLog("Something happened"); + response.getWriter().write("ok"); + } + case "/logged-and-thrown" -> { + IllegalStateException error = new IllegalStateException("twice"); + client.submitException(error); + throw error; + } + case "/async-missing" -> { + AsyncContext async = request.startAsync(); + async.start( + () -> { + ((HttpServletResponse) async.getResponse()).setStatus(404); + async.complete(); + }); + } + case "/async-ok" -> { + AsyncContext async = request.startAsync(); + async.start( + () -> { + try { + async.getResponse().getWriter().write("ok"); + } catch (IOException e) { + throw new RuntimeException(e); + } + async.complete(); + }); + } + case "/async-fail" -> { + if (request.getDispatcherType() == DispatcherType.ASYNC) { + throw new IllegalStateException("boom"); + } + AsyncContext async = request.startAsync(); + async.start(async::dispatch); + } + default -> response.getWriter().write("ok"); + } + } + } +} diff --git a/exceptionless-servlet/src/test/java/com/exceptionless/servlet/JettyServer.java b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/JettyServer.java new file mode 100644 index 0000000..7e5f013 --- /dev/null +++ b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/JettyServer.java @@ -0,0 +1,89 @@ +package com.exceptionless.servlet; + +import jakarta.servlet.DispatcherType; +import jakarta.servlet.Filter; +import jakarta.servlet.http.HttpServlet; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.EnumSet; +import java.util.Map; +import org.eclipse.jetty.ee10.servlet.FilterHolder; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; + +/** An embedded Jetty server running a servlet behind filters, for tests. */ +final class JettyServer implements AutoCloseable { + private final Server server; + private final ServletContextHandler context; + private final HttpClient http = + HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(); + + JettyServer() { + server = new Server(0); + context = new ServletContextHandler(); + context.setContextPath("/"); + server.setHandler(context); + } + + JettyServer filter(Filter filter, Map initParameters, DispatcherType... types) { + FilterHolder holder = new FilterHolder(filter); + holder.setAsyncSupported(true); + holder.setInitParameters(initParameters); + EnumSet dispatcherTypes = + types.length == 0 ? EnumSet.of(DispatcherType.REQUEST) : EnumSet.of(types[0], types); + context.addFilter(holder, "/*", dispatcherTypes); + return this; + } + + JettyServer filter(Filter filter) { + return filter(filter, Map.of()); + } + + JettyServer servlet(HttpServlet servlet) { + ServletHolder holder = new ServletHolder(servlet); + holder.setAsyncSupported(true); + context.addServlet(holder, "/*"); + return this; + } + + JettyServer start() throws Exception { + server.start(); + return this; + } + + String url(String path) { + return "http://127.0.0.1:" + + ((ServerConnector) server.getConnectors()[0]).getLocalPort() + + path; + } + + HttpResponse get(String path, String... headers) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url(path))).GET(); + if (headers.length > 0) { + builder.headers(headers); + } + return http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + HttpResponse postForm(String path, String body) throws Exception { + HttpRequest request = + HttpRequest.newBuilder(URI.create(url(path))) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + return http.send(request, HttpResponse.BodyHandlers.ofString()); + } + + @Override + public void close() { + try { + server.stop(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/exceptionless-servlet/src/test/java/com/exceptionless/servlet/ServletRequestInfoFactoryTest.java b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/ServletRequestInfoFactoryTest.java new file mode 100644 index 0000000..086482a --- /dev/null +++ b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/ServletRequestInfoFactoryTest.java @@ -0,0 +1,40 @@ +package com.exceptionless.servlet; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.servlet.ServletException; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ServletRequestInfoFactoryTest { + @Test + void parsesQueryStrings() { + assertThat(ServletRequestInfoFactory.parseQueryString(null)).isEmpty(); + assertThat(ServletRequestInfoFactory.parseQueryString("")).isEmpty(); + assertThat(ServletRequestInfoFactory.parseQueryString("a=1&b=two+words&a=2&&c&=x&d=%E2%9C%93")) + .containsExactly( + Map.entry("a", "1"), + Map.entry("b", "two words"), + Map.entry("c", ""), + Map.entry("d", "✓")); + } + + @Test + void keepsInvalidEscapesRaw() { + assertThat(ServletRequestInfoFactory.parseQueryString("bad=%zz&ok=%41")) + .containsExactly(Map.entry("bad", "%zz"), Map.entry("ok", "A")); + } + + @Test + void unwrapsNestedServletExceptions() { + IllegalStateException root = new IllegalStateException("root"); + ServletException wrapped = new ServletException("outer", new ServletException("inner", root)); + assertThat(ExceptionlessFilter.unwrap(wrapped)).isSameAs(root); + + ServletException withoutCause = new ServletException("alone"); + assertThat(ExceptionlessFilter.unwrap(withoutCause)).isSameAs(withoutCause); + + RuntimeException other = new RuntimeException("x", root); + assertThat(ExceptionlessFilter.unwrap(other)).isSameAs(other); + } +} diff --git a/exceptionless-spring-boot-starter/pom.xml b/exceptionless-spring-boot-starter/pom.xml new file mode 100644 index 0000000..6b7cf46 --- /dev/null +++ b/exceptionless-spring-boot-starter/pom.xml @@ -0,0 +1,159 @@ + + + 4.0.0 + + + com.exceptionless + exceptionless-parent + ${revision} + ../pom.xml + + + exceptionless-spring-boot-starter + Exceptionless Spring Boot Starter + Spring Boot auto-configuration for Exceptionless: client bean, unhandled exceptions and 404s for Spring MVC and WebFlux, and Logback integration. + + + + + com.exceptionless.spring.boot + spring-boot-starter-web + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + com.exceptionless + exceptionless-core + + + + com.exceptionless + exceptionless-servlet + + + com.exceptionless + exceptionless-logback + + + org.jspecify + jspecify + + + + org.springframework.boot + spring-boot-autoconfigure + provided + + + org.springframework + spring-web + provided + true + + + org.springframework + spring-webflux + provided + true + + + jakarta.servlet + jakarta.servlet-api + provided + true + + + ch.qos.logback + logback-classic + provided + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.awaitility + awaitility + test + + + io.projectreactor + reactor-test + test + + + + org.springframework.boot + ${spring-boot-starter-webmvc.artifactId} + test + + + org.springframework.boot + spring-boot-starter-webflux + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + @{argLine} -XX:+EnableDynamicAgentLoading + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.springframework.boot + spring-boot-configuration-processor + ${spring-boot.version} + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + + + + + + spring-boot-4 + + com.exceptionless.spring.boot + 4.1.1 + 6.0.3 + + spring-boot-starter-webmvc + + + + diff --git a/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessAutoConfiguration.java b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessAutoConfiguration.java new file mode 100644 index 0000000..8ce4274 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessAutoConfiguration.java @@ -0,0 +1,176 @@ +package com.exceptionless.spring.boot; + +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.servlet.ExceptionlessFilter; +import java.nio.file.Path; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.info.BuildProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +/** + * Auto-configures Exceptionless. Add the starter and set the API key: + * + *

{@code
+ * exceptionless.api-key=YOUR_API_KEY
+ * }
+ * + *

This registers: + * + *

    + *
  • an {@link ExceptionlessClient} bean, configured from the {@link ExceptionlessProperties + * exceptionless.*} properties and any {@link ExceptionlessConfigurationCustomizer} beans. It + * becomes the {@link Exceptionless#getDefault() default client} (used by logging integrations + * without their own API key) and is closed, sending queued events, when the application + * context closes; + *
  • in servlet web applications, an {@link ExceptionlessFilter} that reports unhandled + * exceptions and 404s with the request details, and adds the request to errors logged during + * it; + *
  • in reactive web applications, an {@link ExceptionlessWebFilter} that does the same for + * WebFlux; + *
  • when Logback is the logging system, an appender on the root logger that sends log messages + * at or above {@code exceptionless.logging.minimum-level} (default {@code warn}) and logged + * exceptions. Log4j 2 users add the {@code exceptionless-log4j2} appender to their + * configuration themselves; without an API key it uses this client. + *
+ * + *

Set {@code exceptionless.enabled=false} to turn all of it off. + */ +@AutoConfiguration +@ConditionalOnBooleanProperty(name = "exceptionless.enabled", matchIfMissing = true) +@EnableConfigurationProperties(ExceptionlessProperties.class) +public class ExceptionlessAutoConfiguration { + + /** Creates and starts the client. */ + @Bean(destroyMethod = "close") + @ConditionalOnMissingBean + public ExceptionlessClient exceptionlessClient( + ExceptionlessProperties properties, + ObjectProvider customizers, + ObjectProvider buildProperties) { + ExceptionlessConfiguration config = ExceptionlessConfiguration.fromEnvironment(); + apply(properties, config); + if (config.getVersion() == null) { + BuildProperties build = buildProperties.getIfAvailable(); + if (build != null) { + config.version(build.getVersion()); + } + } + // The application context closes the client. + config.registerShutdownHook(false); + customizers.orderedStream().forEach(customizer -> customizer.customize(config)); + + return new ExceptionlessClient(config).startup(); + } + + static void apply(ExceptionlessProperties properties, ExceptionlessConfiguration config) { + if (StringUtils.hasText(properties.getApiKey())) { + config.apiKey(properties.getApiKey()); + } + if (StringUtils.hasText(properties.getServerUrl())) { + config.serverUrl(properties.getServerUrl()); + } + if (StringUtils.hasText(properties.getVersion())) { + config.version(properties.getVersion()); + } + config.addDefaultTags(properties.getDefaultTags().toArray(String[]::new)); + properties.getDefaultData().forEach(config::addDefaultData); + properties.getSettings().forEach(config::setting); + Boolean includePrivateInformation = properties.getIncludePrivateInformation(); + if (includePrivateInformation != null) { + config.includePrivateInformation(includePrivateInformation); + } + Path storagePath = properties.getStoragePath(); + if (storagePath != null) { + config.useFileStorage(storagePath); + } + if (properties.isUseSessions()) { + config.useSessions(); + } + config.captureUncaughtExceptions(properties.isCaptureUncaughtExceptions()); + } + + /** + * Makes the client the {@link Exceptionless#getDefault() default client}, including a client bean + * defined by the application, and clears it again when the context closes. + */ + @Bean + DefaultClientRegistration exceptionlessDefaultClientRegistration(ExceptionlessClient client) { + return new DefaultClientRegistration(client); + } + + static final class DefaultClientRegistration implements DisposableBean { + private final ExceptionlessClient client; + + DefaultClientRegistration(ExceptionlessClient client) { + this.client = client; + Exceptionless.setDefault(client); + } + + @Override + public void destroy() { + if (Exceptionless.hasDefault() && Exceptionless.getDefault() == client) { + Exceptionless.setDefault(null); + } + } + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + @ConditionalOnClass( + name = {"jakarta.servlet.Filter", "com.exceptionless.servlet.ExceptionlessFilter"}) + static class ServletConfiguration { + /** Reports unhandled exceptions and 404s. Spring Boot registers Filter beans automatically. */ + @Bean + @ConditionalOnMissingBean(ExceptionlessFilter.class) + OrderedExceptionlessFilter exceptionlessFilter( + ExceptionlessClient client, ExceptionlessProperties properties) { + OrderedExceptionlessFilter filter = + new OrderedExceptionlessFilter(client, properties.getFilterOrder()); + filter.setCaptureNotFound(properties.isCaptureNotFound()); + return filter; + } + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + @ConditionalOnClass( + name = {"org.springframework.web.server.WebFilter", "reactor.core.publisher.Mono"}) + static class ReactiveConfiguration { + /** Reports unhandled exceptions and 404s. */ + @Bean + @ConditionalOnMissingBean + ExceptionlessWebFilter exceptionlessWebFilter( + ExceptionlessClient client, ExceptionlessProperties properties) { + return new ExceptionlessWebFilter( + client, properties.isCaptureNotFound(), properties.getFilterOrder()); + } + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass( + name = { + "ch.qos.logback.classic.LoggerContext", + "com.exceptionless.logback.ExceptionlessAppender" + }) + @ConditionalOnBooleanProperty(name = "exceptionless.logging.enabled", matchIfMissing = true) + static class LogbackConfiguration { + /** Adds the Exceptionless appender to the Logback root logger. */ + @Bean + @ConditionalOnMissingBean + LogbackAppenderRegistration exceptionlessLogbackAppenderRegistration( + ExceptionlessClient client, ExceptionlessProperties properties) { + return new LogbackAppenderRegistration(client, properties.getLogging().getMinimumLevel()); + } + } +} diff --git a/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessConfigurationCustomizer.java b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessConfigurationCustomizer.java new file mode 100644 index 0000000..aa67e43 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessConfigurationCustomizer.java @@ -0,0 +1,22 @@ +package com.exceptionless.spring.boot; + +import com.exceptionless.ExceptionlessConfiguration; + +/** + * Customizes the auto-configured client's {@link ExceptionlessConfiguration} after the {@code + * exceptionless.*} properties are applied and before the client starts. Declare one or more as + * beans; they run in {@link org.springframework.core.annotation.Order @Order}. + * + *

{@code
+ * @Bean
+ * ExceptionlessConfigurationCustomizer exceptionlessCustomizer() {
+ *   return config -> config
+ *       .addDataExclusions("*password*")
+ *       .addEventExclusion(event -> "health".equals(event.getSource()));
+ * }
+ * }
+ */ +@FunctionalInterface +public interface ExceptionlessConfigurationCustomizer { + void customize(ExceptionlessConfiguration configuration); +} diff --git a/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessProperties.java b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessProperties.java new file mode 100644 index 0000000..be66037 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessProperties.java @@ -0,0 +1,213 @@ +package com.exceptionless.spring.boot; + +import com.exceptionless.LogLevel; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Exceptionless settings ({@code exceptionless.*}). Values that are not set fall back to {@code + * exceptionless.properties}, {@code EXCEPTIONLESS_*} environment variables and {@code + * exceptionless.*} system properties. + * + *
{@code
+ * exceptionless:
+ *   api-key: YOUR_API_KEY
+ *   default-tags: [backend]
+ *   logging:
+ *     minimum-level: info
+ * }
+ */ +@ConfigurationProperties("exceptionless") +public class ExceptionlessProperties { + /** Whether Exceptionless is enabled. When false, nothing is auto-configured. */ + private boolean enabled = true; + + /** The project API key. */ + private @Nullable String apiKey; + + /** The server URL, for self hosted installs. Also used for settings and heartbeats. */ + private @Nullable String serverUrl; + + /** The application version. Defaults to the version from build-info (BuildProperties). */ + private @Nullable String version; + + /** Tags added to every event. */ + private List defaultTags = new ArrayList<>(); + + /** Data added to every event. */ + private Map defaultData = new LinkedHashMap<>(); + + /** + * Local client settings. The project's settings take precedence. Keys with special characters + * need brackets, e.g. {@code exceptionless.settings.[@@log\:com.example.*]=info} in a properties + * file or {@code "[@@log:com.example.*]": info} in YAML. + */ + private Map settings = new LinkedHashMap<>(); + + /** + * Whether private information (user names, IP addresses, headers, cookies, query strings and form + * data) is sent. Unset keeps the client default (true). + */ + private @Nullable Boolean includePrivateInformation; + + /** A directory to persist queued events and settings in, so events survive restarts. */ + private @Nullable Path storagePath; + + /** Whether to track sessions, with heartbeats. */ + private boolean useSessions; + + /** Whether exceptions that reach the default uncaught exception handler are reported. */ + private boolean captureUncaughtExceptions = true; + + /** Whether 404 responses are reported (Spring MVC and WebFlux). */ + private boolean captureNotFound = true; + + /** + * The order of the web filter that reports unhandled exceptions and 404s. The default runs just + * after Spring Security's filter chain (-100), so the signed in user is known. + */ + private int filterOrder = -99; + + private final Logging logging = new Logging(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public @Nullable String getApiKey() { + return apiKey; + } + + public void setApiKey(@Nullable String apiKey) { + this.apiKey = apiKey; + } + + public @Nullable String getServerUrl() { + return serverUrl; + } + + public void setServerUrl(@Nullable String serverUrl) { + this.serverUrl = serverUrl; + } + + public @Nullable String getVersion() { + return version; + } + + public void setVersion(@Nullable String version) { + this.version = version; + } + + public List getDefaultTags() { + return defaultTags; + } + + public void setDefaultTags(List defaultTags) { + this.defaultTags = defaultTags; + } + + public Map getDefaultData() { + return defaultData; + } + + public void setDefaultData(Map defaultData) { + this.defaultData = defaultData; + } + + public Map getSettings() { + return settings; + } + + public void setSettings(Map settings) { + this.settings = settings; + } + + public @Nullable Boolean getIncludePrivateInformation() { + return includePrivateInformation; + } + + public void setIncludePrivateInformation(@Nullable Boolean includePrivateInformation) { + this.includePrivateInformation = includePrivateInformation; + } + + public @Nullable Path getStoragePath() { + return storagePath; + } + + public void setStoragePath(@Nullable Path storagePath) { + this.storagePath = storagePath; + } + + public boolean isUseSessions() { + return useSessions; + } + + public void setUseSessions(boolean useSessions) { + this.useSessions = useSessions; + } + + public boolean isCaptureUncaughtExceptions() { + return captureUncaughtExceptions; + } + + public void setCaptureUncaughtExceptions(boolean captureUncaughtExceptions) { + this.captureUncaughtExceptions = captureUncaughtExceptions; + } + + public boolean isCaptureNotFound() { + return captureNotFound; + } + + public void setCaptureNotFound(boolean captureNotFound) { + this.captureNotFound = captureNotFound; + } + + public int getFilterOrder() { + return filterOrder; + } + + public void setFilterOrder(int filterOrder) { + this.filterOrder = filterOrder; + } + + public Logging getLogging() { + return logging; + } + + /** Logging integration settings ({@code exceptionless.logging.*}). */ + public static class Logging { + /** Whether log events are sent through an appender added to the Logback root logger. */ + private boolean enabled = true; + + /** + * The minimum level sent when the project has no matching @@log setting: trace, debug, info, + * warn, error, fatal or off. + */ + private LogLevel minimumLevel = LogLevel.WARN; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public LogLevel getMinimumLevel() { + return minimumLevel; + } + + public void setMinimumLevel(LogLevel minimumLevel) { + this.minimumLevel = minimumLevel; + } + } +} diff --git a/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessWebFilter.java b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessWebFilter.java new file mode 100644 index 0000000..35e1f6a --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessWebFilter.java @@ -0,0 +1,124 @@ +package com.exceptionless.spring.boot; + +import com.exceptionless.EventBuilder; +import com.exceptionless.ExceptionlessClient; +import java.security.Principal; +import java.util.function.Function; +import org.jspecify.annotations.Nullable; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.ErrorResponse; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +/** + * Reports unhandled exceptions and 404s in Spring WebFlux applications, with the request details + * and the signed in user ({@code includeUserName}). Registered automatically by the + * auto-configuration in reactive web applications. + * + *

Errors that carry a 4xx status (such as {@code ResponseStatusException}) are not reported as + * errors; a 404 is reported as a not found event. Errors are always passed on unchanged, so + * WebFlux's error handling still runs. + * + *

Unlike the servlet filter, errors logged while a reactive request is processed don't get the + * request details, because the request isn't bound to a thread. + */ +public class ExceptionlessWebFilter implements WebFilter, Ordered { + /** The {@code @submission_method} of the events reported by this filter. */ + public static final String SUBMISSION_METHOD = "webFilter"; + + private static final System.Logger LOG = System.getLogger(ExceptionlessWebFilter.class.getName()); + + private final ExceptionlessClient client; + private final boolean captureNotFound; + private final int order; + + public ExceptionlessWebFilter(ExceptionlessClient client, boolean captureNotFound, int order) { + this.client = client; + this.captureNotFound = captureNotFound; + this.order = order; + } + + @Override + public int getOrder() { + return order; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return chain + .filter(exchange) + .onErrorResume(error -> reportError(exchange, error).then(Mono.error(error))) + .then( + Mono.defer( + () -> + isNotFound(exchange.getResponse().getStatusCode()) + ? reportNotFound(exchange) + : Mono.empty())); + } + + private Mono reportError(ServerWebExchange exchange, Throwable error) { + if (error instanceof ErrorResponse response) { + HttpStatusCode status = response.getStatusCode(); + if (isNotFound(status)) { + return reportNotFound(exchange); + } + if (status.value() < 500) { + return Mono.empty(); + } + } + return withUser( + exchange, + user -> + submit( + client.createUnhandledException(error, SUBMISSION_METHOD), + exchange, + user, + "Unable to report unhandled exception")); + } + + private Mono reportNotFound(ServerWebExchange exchange) { + if (!captureNotFound) { + return Mono.empty(); + } + return withUser( + exchange, + user -> + submit( + client + .createNotFound(exchange.getRequest().getURI().getRawPath()) + .setSubmissionMethod(SUBMISSION_METHOD), + exchange, + user, + "Unable to report 404")); + } + + private static boolean isNotFound(@Nullable HttpStatusCode status) { + return status != null && status.value() == 404; + } + + /** Runs {@code action} with the signed in user's name, or an empty string. */ + private Mono withUser(ServerWebExchange exchange, Function> action) { + Mono user = + client.getConfiguration().isIncludeUserName() + ? exchange.getPrincipal().map(Principal::getName).defaultIfEmpty("").onErrorReturn("") + : Mono.just(""); + return user.flatMap(action); + } + + private static Mono submit( + EventBuilder builder, ServerWebExchange exchange, String user, String failure) { + try { + builder.setRequestInfo(ServerHttpRequestInfoFactory.create(exchange.getRequest())); + if (!user.isBlank()) { + builder.setUserIdentity(user); + } + builder.submit(); + } catch (RuntimeException | LinkageError e) { + LOG.log(System.Logger.Level.WARNING, failure, e); + } + return Mono.empty(); + } +} diff --git a/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/LogbackAppenderRegistration.java b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/LogbackAppenderRegistration.java new file mode 100644 index 0000000..3b5d422 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/LogbackAppenderRegistration.java @@ -0,0 +1,154 @@ +package com.exceptionless.spring.boot; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.LoggerContextListener; +import ch.qos.logback.core.Appender; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.LogLevel; +import com.exceptionless.logback.ExceptionlessAppender; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.Set; +import org.jspecify.annotations.Nullable; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.SmartInitializingSingleton; + +/** + * Adds an {@link ExceptionlessAppender} for the auto-configured client to the Logback root logger + * once the application context's singletons exist, and removes it when the context closes. Does + * nothing when Logback is not the SLF4J backend, or when the root logger already has an {@link + * ExceptionlessAppender} (e.g. from {@code logback-spring.xml}). + * + *

Resetting the Logback context (for example when Logback reloads a changed configuration file + * with {@code scan="true"}) removes all appenders, so the appender is added again on every reset. + */ +final class LogbackAppenderRegistration + implements SmartInitializingSingleton, DisposableBean, LoggerContextListener { + /** The name of the appender added to the root logger. */ + static final String APPENDER_NAME = "EXCEPTIONLESS"; + + /** Appenders added by registrations, to tell them apart from configured ones. */ + private static final Set> REGISTERED = + Collections.synchronizedSet(Collections.newSetFromMap(new IdentityHashMap<>())); + + private final ExceptionlessClient client; + private final LogLevel minimumLevel; + private final Object lock = new Object(); + private @Nullable LoggerContext context; + private @Nullable ExceptionlessAppender appender; + + LogbackAppenderRegistration(ExceptionlessClient client, LogLevel minimumLevel) { + this.client = client; + this.minimumLevel = minimumLevel; + } + + /** The appender currently on the root logger, or {@code null} when none was added. */ + @Nullable ExceptionlessAppender getAppender() { + synchronized (lock) { + return appender; + } + } + + @Override + public void afterSingletonsInstantiated() { + if (!(LoggerFactory.getILoggerFactory() instanceof LoggerContext loggerContext)) { + return; + } + synchronized (lock) { + if (context != null || hasConfiguredAppender(root(loggerContext))) { + return; + } + context = loggerContext; + attach(loggerContext); + loggerContext.addListener(this); + } + } + + @Override + public void destroy() { + synchronized (lock) { + LoggerContext loggerContext = context; + if (loggerContext != null) { + loggerContext.removeListener(this); + detach(loggerContext); + context = null; + } + } + } + + /** Logback removed and stopped every appender: add a new one. */ + @Override + @SuppressWarnings("ReferenceEquality") // Only events from the context we attached to. + public void onReset(LoggerContext loggerContext) { + synchronized (lock) { + if (loggerContext == context && loggerContext.isStarted()) { + detach(loggerContext); + attach(loggerContext); + } + } + } + + /** The Logback context stopped (the JVM is shutting down): nothing more to send. */ + @Override + @SuppressWarnings("ReferenceEquality") // Only events from the context we attached to. + public void onStop(LoggerContext loggerContext) { + synchronized (lock) { + if (loggerContext == context) { + detach(loggerContext); + context = null; + } + } + } + + @Override + public boolean isResetResistant() { + return true; + } + + @Override + public void onStart(LoggerContext loggerContext) {} + + @Override + public void onLevelChange(Logger logger, Level level) {} + + private void attach(LoggerContext loggerContext) { + ExceptionlessAppender added = new ExceptionlessAppender(); + added.setContext(loggerContext); + added.setName(APPENDER_NAME); + added.setClient(client); + added.setMinimumLevel(minimumLevel.value()); + added.start(); + REGISTERED.add(added); + root(loggerContext).addAppender(added); + appender = added; + } + + private void detach(LoggerContext loggerContext) { + ExceptionlessAppender added = appender; + if (added != null) { + root(loggerContext).detachAppender(added); + added.stop(); + REGISTERED.remove(added); + appender = null; + } + } + + private static boolean hasConfiguredAppender(Logger root) { + for (Iterator> it = root.iteratorForAppenders(); it.hasNext(); ) { + Appender existing = it.next(); + if (existing instanceof ExceptionlessAppender && !REGISTERED.contains(existing)) { + return true; + } + } + return false; + } + + private static Logger root(LoggerContext loggerContext) { + return loggerContext.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + } +} diff --git a/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/OrderedExceptionlessFilter.java b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/OrderedExceptionlessFilter.java new file mode 100644 index 0000000..afc821b --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/OrderedExceptionlessFilter.java @@ -0,0 +1,23 @@ +package com.exceptionless.spring.boot; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.servlet.ExceptionlessFilter; +import org.springframework.core.Ordered; + +/** + * The {@link ExceptionlessFilter} registered by the auto-configuration in servlet web applications. + * Spring Boot registers it for all requests, in {@link #getOrder() order}. + */ +public class OrderedExceptionlessFilter extends ExceptionlessFilter implements Ordered { + private final int order; + + public OrderedExceptionlessFilter(ExceptionlessClient client, int order) { + super(client); + this.order = order; + } + + @Override + public int getOrder() { + return order; + } +} diff --git a/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ServerHttpRequestInfoFactory.java b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ServerHttpRequestInfoFactory.java new file mode 100644 index 0000000..a7c8a3a --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ServerHttpRequestInfoFactory.java @@ -0,0 +1,91 @@ +package com.exceptionless.spring.boot; + +import com.exceptionless.models.RequestInfo; +import java.net.InetSocketAddress; +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpRequest; + +/** + * Captures a WebFlux {@link ServerHttpRequest} as a {@link RequestInfo} ({@code @request}). The + * request body is never read. Privacy settings and data exclusions are applied later by the event + * pipeline. + * + *

{@code
+ * client.createException(e)
+ *     .setRequestInfo(ServerHttpRequestInfoFactory.create(exchange.getRequest()))
+ *     .submit();
+ * }
+ */ +public final class ServerHttpRequestInfoFactory { + private ServerHttpRequestInfoFactory() {} + + public static RequestInfo create(ServerHttpRequest request) { + URI uri = request.getURI(); + HttpHeaders headers = request.getHeaders(); + boolean secure = request.getSslInfo() != null || "https".equalsIgnoreCase(uri.getScheme()); + int port = uri.getPort(); + if (port < 0) { + port = secure ? 443 : 80; + } + RequestInfo info = new RequestInfo(); + info.setHttpMethod(request.getMethod().name()); + info.setSecure(secure); + info.setHost(uri.getHost()); + info.setPort(port); + info.setPath(uri.getRawPath()); + info.setReferrer(headers.getFirst("Referer")); + info.setUserAgent(headers.getFirst("User-Agent")); + + InetSocketAddress remote = request.getRemoteAddress(); + if (remote != null) { + info.setClientIpAddress( + remote.getAddress() != null + ? remote.getAddress().getHostAddress() + : remote.getHostString()); + } + + Map> headerMap = new LinkedHashMap<>(); + headers.forEach( + (name, values) -> { + if (values != null && !values.isEmpty()) { + headerMap.put(name, List.copyOf(values)); + } + }); + if (!headerMap.isEmpty()) { + info.setHeaders(headerMap); + } + + Map cookies = new LinkedHashMap<>(); + request + .getCookies() + .forEach( + (name, values) -> { + if (values != null && !values.isEmpty()) { + HttpCookie cookie = values.get(0); + if (cookie.getValue() != null) { + cookies.put(name, cookie.getValue()); + } + } + }); + if (!cookies.isEmpty()) { + info.setCookies(cookies); + } + + // Decoded by WebFlux; the first value wins when a name repeats, and a name without a value + // (?flag) maps to an empty string. + Map query = new LinkedHashMap<>(); + request + .getQueryParams() + .toSingleValueMap() + .forEach((name, value) -> query.put(name, value != null ? value : "")); + if (!query.isEmpty()) { + info.setQueryString(query); + } + return info; + } +} diff --git a/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/package-info.java b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/package-info.java new file mode 100644 index 0000000..e734807 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/package-info.java @@ -0,0 +1,8 @@ +/** + * Spring Boot auto-configuration for Exceptionless. Add the starter and set {@code + * exceptionless.api-key}; see {@link com.exceptionless.spring.boot.ExceptionlessAutoConfiguration}. + */ +@NullMarked +package com.exceptionless.spring.boot; + +import org.jspecify.annotations.NullMarked; diff --git a/exceptionless-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/exceptionless-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..d747d2d --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.exceptionless.spring.boot.ExceptionlessAutoConfiguration diff --git a/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/ExceptionlessAutoConfigurationTest.java b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/ExceptionlessAutoConfigurationTest.java new file mode 100644 index 0000000..b7d6b95 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/ExceptionlessAutoConfigurationTest.java @@ -0,0 +1,423 @@ +package com.exceptionless.spring.boot; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.util.ContextInitializer; +import ch.qos.logback.core.Appender; +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.LogLevel; +import com.exceptionless.TestClients; +import com.exceptionless.TestClients.CapturingSubmissionClient; +import com.exceptionless.logback.ExceptionlessAppender; +import com.exceptionless.servlet.ExceptionlessFilter; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.info.BuildProperties; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.core.annotation.Order; +import org.springframework.core.env.MapPropertySource; +import reactor.core.publisher.Mono; + +class ExceptionlessAutoConfigurationTest { + private static final AutoConfigurations AUTO_CONFIGURATION = + AutoConfigurations.of(ExceptionlessAutoConfiguration.class); + + private final CapturingSubmissionClient recorder = new CapturingSubmissionClient(); + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AUTO_CONFIGURATION) + .withPropertyValues( + "exceptionless.api-key=" + TestClients.API_KEY, + "exceptionless.capture-uncaught-exceptions=false") + .withBean( + "recordingCustomizer", + ExceptionlessConfigurationCustomizer.class, + () -> config -> config.submissionClient(recorder)); + + @AfterEach + void resetDefault() { + Exceptionless.setDefault(null); + } + + @Test + void createsStartedClientFromProperties(@TempDir Path storage) { + runner + .withPropertyValues( + "exceptionless.storage-path=" + storage, + "exceptionless.server-url=http://localhost:5200/", + "exceptionless.version=1.2.3", + "exceptionless.default-tags=web,api", + "exceptionless.default-data.team=core", + "exceptionless.include-private-information=false", + "exceptionless.use-sessions=true") + // ':' separates keys from values in withPropertyValues, so add the bracketed map key + // directly. + .withInitializer( + context -> + context + .getEnvironment() + .getPropertySources() + .addFirst( + new MapPropertySource( + "exceptionlessSettings", + Map.of("exceptionless.settings.[@@log:com.example.*]", "info")))) + .run( + context -> { + assertThat(context).hasSingleBean(ExceptionlessClient.class); + ExceptionlessClient client = context.getBean(ExceptionlessClient.class); + assertThat(client.isStarted()).isTrue(); + ExceptionlessConfiguration config = client.getConfiguration(); + assertThat(config.getApiKey()).isEqualTo(TestClients.API_KEY); + assertThat(context.getBean(ExceptionlessProperties.class).getStoragePath()) + .isEqualTo(storage); + assertThat(config.getStoragePath()).isEqualTo(storage); + assertThat(config.getServerUrl()).isEqualTo("http://localhost:5200"); + assertThat(config.getVersion()).isEqualTo("1.2.3"); + assertThat(config.getDefaultTags()).containsExactlyInAnyOrder("web", "api"); + assertThat(config.getDefaultData()).containsEntry("team", "core"); + assertThat(config.getSettings().asMap()).containsEntry("@@log:com.example.*", "info"); + assertThat(config.isIncludePrivateInformation()).isFalse(); + assertThat(config.isIncludeCookies()).isFalse(); + assertThat(config.isSessionsEnabled()).isTrue(); + assertThat(config.isCaptureUncaughtExceptions()).isFalse(); + assertThat(config.isRegisterShutdownHook()).isFalse(); + }); + } + + @Test + void leavesPrivateInformationDefaultWhenNotSet() { + runner.run( + context -> + assertThat( + context + .getBean(ExceptionlessClient.class) + .getConfiguration() + .isIncludePrivateInformation()) + .isTrue()); + } + + @Test + void usesBuildVersionWhenNoVersionIsSet() { + runner + .withBean(BuildProperties.class, () -> buildProperties("4.5.6")) + .run( + context -> + assertThat( + context.getBean(ExceptionlessClient.class).getConfiguration().getVersion()) + .isEqualTo("4.5.6")); + + runner + .withBean(BuildProperties.class, () -> buildProperties("4.5.6")) + .withPropertyValues("exceptionless.version=1.0.0") + .run( + context -> + assertThat( + context.getBean(ExceptionlessClient.class).getConfiguration().getVersion()) + .isEqualTo("1.0.0")); + } + + @Test + void appliesCustomizersInOrder() { + runner + .withUserConfiguration(CustomizerConfiguration.class) + .run( + context -> { + ExceptionlessConfiguration config = + context.getBean(ExceptionlessClient.class).getConfiguration(); + assertThat(config.getDefaultTags()).contains("first", "second"); + assertThat(config.getVersion()).isEqualTo("second"); + }); + } + + @Test + void becomesTheDefaultClientAndClosesWithTheContext() { + AtomicReference client = new AtomicReference<>(); + runner.run( + context -> { + client.set(context.getBean(ExceptionlessClient.class)); + assertThat(Exceptionless.hasDefault()).isTrue(); + assertThat(Exceptionless.getDefault()).isSameAs(client.get()); + }); + assertThat(client.get().isStarted()).isFalse(); + assertThat(Exceptionless.hasDefault()).isFalse(); + } + + @Test + void backsOffWhenTheApplicationDefinesAClient() { + ExceptionlessClient own = + new ExceptionlessClient(new ExceptionlessConfiguration().submissionClient(recorder)); + runner + .withBean("myClient", ExceptionlessClient.class, () -> own) + .run( + context -> { + assertThat(context).getBean(ExceptionlessClient.class).isSameAs(own); + assertThat(Exceptionless.getDefault()).isSameAs(own); + }); + } + + @Test + void disabledTurnsEverythingOff() { + new WebApplicationContextRunner() + .withConfiguration(AUTO_CONFIGURATION) + .withPropertyValues( + "exceptionless.enabled=false", "exceptionless.api-key=" + TestClients.API_KEY) + .run( + context -> { + assertThat(context).doesNotHaveBean(ExceptionlessClient.class); + assertThat(context).doesNotHaveBean(ExceptionlessFilter.class); + assertThat(context).doesNotHaveBean(LogbackAppenderRegistration.class); + assertThat(Exceptionless.hasDefault()).isFalse(); + }); + } + + @Test + void registersServletFilterInServletApplications() { + new WebApplicationContextRunner() + .withConfiguration(AUTO_CONFIGURATION) + .withPropertyValues( + "exceptionless.api-key=" + TestClients.API_KEY, + "exceptionless.capture-uncaught-exceptions=false", + "exceptionless.capture-not-found=false", + "exceptionless.filter-order=42") + .run( + context -> { + assertThat(context).hasSingleBean(OrderedExceptionlessFilter.class); + assertThat(context).doesNotHaveBean(ExceptionlessWebFilter.class); + OrderedExceptionlessFilter filter = context.getBean(OrderedExceptionlessFilter.class); + assertThat(filter.getOrder()).isEqualTo(42); + assertThat(filter.isCaptureNotFound()).isFalse(); + assertThat(filter.getClient()).isSameAs(context.getBean(ExceptionlessClient.class)); + }); + } + + @Test + void servletFilterDefaultsRunAfterSpringSecurity() { + new WebApplicationContextRunner() + .withConfiguration(AUTO_CONFIGURATION) + .withPropertyValues( + "exceptionless.api-key=" + TestClients.API_KEY, + "exceptionless.capture-uncaught-exceptions=false") + .run( + context -> { + OrderedExceptionlessFilter filter = context.getBean(OrderedExceptionlessFilter.class); + assertThat(filter.getOrder()).isEqualTo(-99); + assertThat(filter.isCaptureNotFound()).isTrue(); + }); + } + + @Test + void registersWebFilterInReactiveApplications() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AUTO_CONFIGURATION) + .withPropertyValues( + "exceptionless.api-key=" + TestClients.API_KEY, + "exceptionless.capture-uncaught-exceptions=false") + .run( + context -> { + assertThat(context).hasSingleBean(ExceptionlessWebFilter.class); + assertThat(context).doesNotHaveBean(ExceptionlessFilter.class); + assertThat(context.getBean(ExceptionlessWebFilter.class).getOrder()).isEqualTo(-99); + }); + } + + @Test + void backsOffFromTheServletFilterWithoutExceptionlessServlet() { + new WebApplicationContextRunner() + .withConfiguration(AUTO_CONFIGURATION) + .withClassLoader(new FilteredClassLoader(ExceptionlessFilter.class)) + .withPropertyValues( + "exceptionless.api-key=" + TestClients.API_KEY, + "exceptionless.capture-uncaught-exceptions=false") + .run( + context -> { + assertThat(context).hasSingleBean(ExceptionlessClient.class); + assertThat(context).doesNotHaveBean(OrderedExceptionlessFilter.class); + }); + } + + @Test + void backsOffFromTheWebFilterWithoutReactor() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AUTO_CONFIGURATION) + .withClassLoader(new FilteredClassLoader(Mono.class)) + .withPropertyValues( + "exceptionless.api-key=" + TestClients.API_KEY, + "exceptionless.capture-uncaught-exceptions=false") + .run( + context -> { + assertThat(context).hasSingleBean(ExceptionlessClient.class); + assertThat(context).doesNotHaveBean(ExceptionlessWebFilter.class); + }); + } + + @Test + void backsOffFromLogbackWithoutLogback() { + runner + .withClassLoader(new FilteredClassLoader(LoggerContext.class)) + .run( + context -> { + assertThat(context).hasSingleBean(ExceptionlessClient.class); + assertThat(context).doesNotHaveBean(LogbackAppenderRegistration.class); + }); + } + + @Test + void bindsTheMinimumLevelCaseInsensitively() { + runner + .withPropertyValues("exceptionless.logging.minimum-level=ERROR") + .run( + context -> { + assertThat( + context.getBean(ExceptionlessProperties.class).getLogging().getMinimumLevel()) + .isEqualTo(LogLevel.ERROR); + assertThat( + context + .getBean(LogbackAppenderRegistration.class) + .getAppender() + .getMinimumLevel()) + .isEqualTo(LogLevel.ERROR); + }); + } + + @Test + void registersNoWebFiltersInNonWebApplications() { + runner.run( + context -> { + assertThat(context).doesNotHaveBean(ExceptionlessFilter.class); + assertThat(context).doesNotHaveBean(ExceptionlessWebFilter.class); + }); + } + + @Test + void addsLogbackAppenderWhileTheContextRuns() { + AtomicReference appender = new AtomicReference<>(); + runner + .withPropertyValues("exceptionless.logging.minimum-level=info") + .run( + context -> { + ExceptionlessClient client = context.getBean(ExceptionlessClient.class); + appender.set(context.getBean(LogbackAppenderRegistration.class).getAppender()); + assertThat(appender.get()).isNotNull(); + assertThat(appender.get().getClient()).isSameAs(client); + assertThat(appender.get().getMinimumLevel()).isEqualTo(LogLevel.INFO); + assertThat(rootAppenders()).contains(appender.get()); + + LoggerFactory.getLogger("com.example.Checkout") + .error("Payment failed", new IllegalStateException("declined")); + LoggerFactory.getLogger("com.example.Checkout").info("Order placed"); + LoggerFactory.getLogger("com.example.Checkout").debug("Not sent"); + + recorder.awaitEvents(client, 2); + List> events = recorder.flush(client); + assertThat(events) + .anySatisfy( + e -> assertThat(TestClients.error(e)).containsEntry("message", "declined")) + .anySatisfy( + e -> + assertThat(e) + .containsEntry("type", "log") + .containsEntry("message", "Order placed")) + .noneSatisfy(e -> assertThat(e).containsEntry("message", "Not sent")); + }); + assertThat(rootAppenders()).doesNotContain(appender.get()); + assertThat(appender.get().isStarted()).isFalse(); + } + + @Test + void addsTheAppenderAgainWhenLogbackIsReset() throws Exception { + LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + AtomicReference registration = new AtomicReference<>(); + List added = new ArrayList<>(); + try { + runner.run( + context -> { + ExceptionlessClient client = context.getBean(ExceptionlessClient.class); + registration.set(context.getBean(LogbackAppenderRegistration.class)); + ExceptionlessAppender original = registration.get().getAppender(); + assertThat(original).isNotNull(); + added.add(original); + assertThat(rootAppenders()).contains(original); + + // What Logback does when it reloads a changed logback.xml (scan="true"). + loggerContext.reset(); + + ExceptionlessAppender replacement = registration.get().getAppender(); + assertThat(replacement).isNotNull().isNotSameAs(original); + added.add(replacement); + assertThat(replacement.isStarted()).isTrue(); + assertThat(original.isStarted()).isFalse(); + assertThat(rootAppenders()).contains(replacement).doesNotContain(original); + + LoggerFactory.getLogger("com.example.Reset").error("After reset"); + assertThat(recorder.awaitEvents(client, 1)) + .extracting(e -> e.get("message")) + .contains("After reset"); + }); + // Only this context's appenders: a cached context from another test class can have its own. + assertThat(rootAppenders()).doesNotContainAnyElementsOf(added); + assertThat(loggerContext.getCopyOfListenerList()).doesNotContain(registration.get()); + } finally { + // Restore the default configuration (logback-test.xml or Logback's defaults). + loggerContext.reset(); + new ContextInitializer(loggerContext).autoConfig(); + } + } + + @Test + void loggingCanBeDisabled() { + runner + .withPropertyValues("exceptionless.logging.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(LogbackAppenderRegistration.class)); + } + + private static List> rootAppenders() { + Logger root = + ((LoggerContext) LoggerFactory.getILoggerFactory()) + .getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + List> appenders = new ArrayList<>(); + for (Iterator> it = root.iteratorForAppenders(); it.hasNext(); ) { + appenders.add(it.next()); + } + return appenders; + } + + private static BuildProperties buildProperties(String version) { + Properties properties = new Properties(); + properties.setProperty("version", version); + return new BuildProperties(properties); + } + + @org.springframework.context.annotation.Configuration(proxyBeanMethods = false) + static class CustomizerConfiguration { + @org.springframework.context.annotation.Bean + @Order(2) + ExceptionlessConfigurationCustomizer second() { + return config -> config.addDefaultTags("second").version("second"); + } + + @org.springframework.context.annotation.Bean + @Order(1) + ExceptionlessConfigurationCustomizer first() { + return config -> config.addDefaultTags("first").version("first"); + } + } +} diff --git a/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/ExceptionlessWebFilterTest.java b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/ExceptionlessWebFilterTest.java new file mode 100644 index 0000000..e271651 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/ExceptionlessWebFilterTest.java @@ -0,0 +1,183 @@ +package com.exceptionless.spring.boot; + +import static com.exceptionless.TestClients.data; +import static com.exceptionless.TestClients.error; +import static com.exceptionless.TestClients.request; +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.TestClients.CapturingSubmissionClient; +import java.net.URI; +import java.security.Principal; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +class ExceptionlessWebFilterTest { + private final CapturingSubmissionClient recorder = new CapturingSubmissionClient(); + private ExceptionlessClient client; + + @AfterEach + void close() { + if (client != null) { + client.close(); + } + } + + private ExceptionlessWebFilter filter( + boolean captureNotFound, Consumer configure) { + ExceptionlessConfiguration config = recorder.newConfiguration(); + configure.accept(config); + client = new ExceptionlessClient(config).startup(); + return new ExceptionlessWebFilter(client, captureNotFound, 0); + } + + private static MockServerWebExchange exchange(String uri) { + return MockServerWebExchange.from( + MockServerHttpRequest.method(HttpMethod.GET, URI.create(uri)) + .header("User-Agent", "TestAgent/1.0") + .header("Authorization", "Bearer secret") + .header("X-Custom", "one", "two") + .cookie(new HttpCookie("session", "abc123"))); + } + + @Test + void reportsErrorsWithRequestInfoAndPassesThemOn() { + ExceptionlessWebFilter filter = filter(true, c -> {}); + IllegalStateException boom = new IllegalStateException("boom"); + + StepVerifier.create( + filter.filter( + exchange("http://example.com:8080/orders?id=42&name=a%20b&flag&id=43"), + failing(boom))) + .expectErrorSatisfies(error -> assertThat(error).isSameAs(boom)) + .verify(); + + Map event = recorder.awaitEvents(client, 1).get(0); + assertThat(event).containsEntry("type", "error"); + assertThat(error(event)).containsEntry("message", "boom"); + assertThat(data(event)).containsEntry("@submission_method", "webFilter"); + Map request = request(event); + assertThat(request) + .containsEntry("http_method", "GET") + .containsEntry("path", "/orders") + .containsEntry("host", "example.com") + .containsEntry("port", 8080L) + .containsEntry("is_secure", false) + .containsEntry("user_agent", "TestAgent/1.0") + .containsEntry("query_string", Map.of("id", "42", "name", "a b", "flag", "")) + .containsEntry("cookies", Map.of("session", "abc123")); + @SuppressWarnings("unchecked") + Map headers = (Map) request.get("headers"); + assertThat(headers).containsEntry("X-Custom", List.of("one", "two")); + assertThat(headers.keySet()).noneMatch(name -> name.equalsIgnoreCase("authorization")); + } + + @Test + void reportsNotFoundErrorsAsNotFound() { + ExceptionlessWebFilter filter = filter(true, c -> {}); + + StepVerifier.create( + filter.filter( + exchange("/missing"), failing(new ResponseStatusException(HttpStatus.NOT_FOUND)))) + .verifyError(ResponseStatusException.class); + + Map event = recorder.awaitEvents(client, 1).get(0); + assertThat(event).containsEntry("type", "404").containsEntry("source", "/missing"); + assertThat(request(event)).containsEntry("path", "/missing"); + } + + @Test + void reportsNotFoundResponses() { + ExceptionlessWebFilter filter = filter(true, c -> {}); + + StepVerifier.create(filter.filter(exchange("/gone"), respondWith(HttpStatus.NOT_FOUND))) + .verifyComplete(); + + assertThat(recorder.awaitEvents(client, 1).get(0)) + .containsEntry("type", "404") + .containsEntry("source", "/gone"); + } + + @Test + void ignoresClientErrorsSuccessAndDisabledNotFound() { + ExceptionlessWebFilter filter = filter(false, c -> {}); + + StepVerifier.create( + filter.filter( + exchange("/bad"), failing(new ResponseStatusException(HttpStatus.BAD_REQUEST)))) + .verifyError(ResponseStatusException.class); + StepVerifier.create( + filter.filter( + exchange("/missing"), failing(new ResponseStatusException(HttpStatus.NOT_FOUND)))) + .verifyError(ResponseStatusException.class); + StepVerifier.create(filter.filter(exchange("/gone"), respondWith(HttpStatus.NOT_FOUND))) + .verifyComplete(); + StepVerifier.create(filter.filter(exchange("/ok"), respondWith(HttpStatus.OK))) + .verifyComplete(); + client.submitLog("marker"); + + assertThat(recorder.awaitEvents(client, 1)) + .extracting(e -> e.get("message")) + .containsExactly("marker"); + } + + @Test + void reportsServerErrorStatusExceptions() { + ExceptionlessWebFilter filter = filter(true, c -> {}); + + StepVerifier.create( + filter.filter( + exchange("/x"), + failing(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "down")))) + .verifyError(ResponseStatusException.class); + + assertThat(recorder.awaitEvents(client, 1).get(0)).containsEntry("type", "error"); + } + + @Test + void includesSignedInUserUnlessUserNamesAreExcluded() { + ExceptionlessWebFilter filter = filter(true, c -> {}); + Principal alice = () -> "alice"; + ServerWebExchange exchange = exchange("/x").mutate().principal(Mono.just(alice)).build(); + + StepVerifier.create(filter.filter(exchange, failing(new IllegalStateException("a")))) + .verifyError(IllegalStateException.class); + + assertThat(data(recorder.awaitEvents(client, 1).get(0)).get("@user")) + .isEqualTo(Map.of("identity", "alice")); + } + + @Test + void omitsSignedInUserWhenExcluded() { + ExceptionlessWebFilter filter = filter(true, c -> c.includeUserName(false)); + Principal alice = () -> "alice"; + ServerWebExchange exchange = exchange("/x").mutate().principal(Mono.just(alice)).build(); + + StepVerifier.create(filter.filter(exchange, failing(new IllegalStateException("a")))) + .verifyError(IllegalStateException.class); + + assertThat(data(recorder.awaitEvents(client, 1).get(0))).doesNotContainKey("@user"); + } + + private static WebFilterChain failing(Throwable error) { + return exchange -> Mono.error(error); + } + + private static WebFilterChain respondWith(HttpStatus status) { + return exchange -> Mono.fromRunnable(() -> exchange.getResponse().setStatusCode(status)).then(); + } +} diff --git a/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/mvc/MvcIntegrationTest.java b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/mvc/MvcIntegrationTest.java new file mode 100644 index 0000000..f29029f --- /dev/null +++ b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/mvc/MvcIntegrationTest.java @@ -0,0 +1,151 @@ +package com.exceptionless.spring.boot.mvc; + +import static com.exceptionless.TestClients.data; +import static com.exceptionless.TestClients.error; +import static com.exceptionless.TestClients.request; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.FakeServer; +import com.exceptionless.TestClients; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Predicate; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.env.Environment; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +/** Runs a real Spring MVC application on Tomcat and checks the events that reach the server. */ +@SpringBootTest( + classes = MvcTestApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MvcIntegrationTest { + private static final FakeServer SERVER = startServer(); + private final HttpClient http = + HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(); + + @Autowired private ExceptionlessClient client; + @Autowired private Environment environment; + + @DynamicPropertySource + static void exceptionlessProperties(DynamicPropertyRegistry registry) { + registry.add("exceptionless.api-key", () -> TestClients.API_KEY); + registry.add("exceptionless.server-url", SERVER::url); + registry.add("exceptionless.capture-uncaught-exceptions", () -> "false"); + registry.add("exceptionless.default-tags", () -> "mvc-test"); + } + + @AfterAll + static void stopServer() { + SERVER.close(); + } + + @Test + void reportsControllerExceptionWithRequestInfoOnce() throws Exception { + HttpResponse response = + get("/boom?order=42", "Authorization", "Bearer secret", "X-Trace", "abc"); + assertThat(response.statusCode()).isEqualTo(500); + + Map event = awaitEvent(e -> "controller boom".equals(error(e).get("message"))); + assertThat(event).containsEntry("type", "error"); + assertThat(event).containsEntry("tags", List.of("mvc-test")); + assertThat(error(event)).containsEntry("type", "java.lang.IllegalStateException"); + assertThat(data(event)).containsEntry("@submission_method", "servletFilter"); + Map request = request(event); + assertThat(request) + .containsEntry("http_method", "GET") + .containsEntry("path", "/boom") + .containsEntry("query_string", Map.of("order", "42")); + @SuppressWarnings("unchecked") + Map headers = (Map) request.get("headers"); + assertThat(headers.keySet()) + .noneMatch(name -> name.equalsIgnoreCase("authorization")) + .anyMatch(name -> name.equalsIgnoreCase("x-trace")); + + // Tomcat logs the same exception through Logback; it must not be reported again. + client.flush(Duration.ofSeconds(5)); + assertThat(events()) + .filteredOn(e -> "controller boom".equals(error(e).get("message"))) + .hasSize(1); + } + + @Test + void reportsNotFound() throws Exception { + assertThat(get("/does-not-exist").statusCode()).isEqualTo(404); + + Map event = + awaitEvent(e -> "404".equals(e.get("type")) && "/does-not-exist".equals(e.get("source"))); + assertThat(data(event)).containsEntry("@submission_method", "servletFilter"); + assertThat(request(event)).containsEntry("path", "/does-not-exist"); + } + + @Test + void addsRequestInfoToErrorsLoggedDuringTheRequest() throws Exception { + assertThat(get("/logged", "User-Agent", "IntegrationTest/1.0").statusCode()).isEqualTo(200); + + Map event = awaitEvent(e -> "logged boom".equals(error(e).get("message"))); + assertThat(event).containsEntry("type", "error"); + assertThat(request(event)) + .containsEntry("path", "/logged") + .containsEntry("user_agent", "IntegrationTest/1.0"); + } + + @Test + void doesNotReportSuccessfulRequests() throws Exception { + assertThat(get("/ok").statusCode()).isEqualTo(200); + client.flush(Duration.ofSeconds(5)); + assertThat(events()).noneMatch(e -> "/ok".equals(String.valueOf(request(e).get("path")))); + } + + private HttpResponse get(String path, String... headers) throws Exception { + HttpRequest.Builder builder = + HttpRequest.newBuilder( + URI.create("http://127.0.0.1:" + environment.getProperty("local.server.port") + path)); + if (headers.length > 0) { + builder.headers(headers); + } + return http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + /** Sends the client's queue until an event matching {@code match} reaches the server. */ + private Map awaitEvent(Predicate> match) { + return await() + .atMost(Duration.ofSeconds(15)) + .pollInterval(Duration.ofMillis(50)) + .until( + () -> { + client.flush(Duration.ofSeconds(2)); + return events().stream().filter(match).findFirst(); + }, + Optional::isPresent) + .orElseThrow(); + } + + private static List> events() { + return SERVER.requests().stream() + .filter(r -> r.path().equals("/api/v2/events")) + .flatMap(r -> r.events().stream()) + .toList(); + } + + private static FakeServer startServer() { + try { + return new FakeServer(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/mvc/MvcTestApplication.java b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/mvc/MvcTestApplication.java new file mode 100644 index 0000000..2f1e201 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/mvc/MvcTestApplication.java @@ -0,0 +1,32 @@ +package com.exceptionless.spring.boot.mvc; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@SpringBootApplication +public class MvcTestApplication { + @RestController + static class TestController { + // Not under com.exceptionless: the appender ignores the client's own loggers. + private static final Logger LOG = LoggerFactory.getLogger("com.example.Orders"); + + @GetMapping("/boom") + String boom() { + throw new IllegalStateException("controller boom"); + } + + @GetMapping("/logged") + String logged() { + LOG.error("Logged failure", new IllegalArgumentException("logged boom")); + return "ok"; + } + + @GetMapping("/ok") + String ok() { + return "ok"; + } + } +} diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml index 9e4f862..b628817 100644 --- a/pom.xml +++ b/pom.xml @@ -1,259 +1,442 @@ - + 4.0.0 com.exceptionless - exceptionless-client - 0.1.1-SNAPSHOT + exceptionless-parent + ${revision} + pom - Exceptionless Java Client - Java client library to send events to an Exceptionless server + Exceptionless Java Parent + Exceptionless client libraries for Java: error, log, feature usage and session reporting. https://github.com/exceptionless/Exceptionless.Java + 2020 + + + Exceptionless + https://exceptionless.com + The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt + https://www.apache.org/licenses/LICENSE-2.0.txt - Prashant Chaubey - prashantchaubey9795@gmail.com + exceptionless + Exceptionless Team Exceptionless https://github.com/exceptionless - UTC+05:30 - - - org.projectlombok - lombok - 1.18.14 - - - ch.qos.logback - logback-classic - 1.2.13 - - - com.fasterxml.jackson.core - jackson-databind - 2.12.7.1 - - - ch.qos.logback - logback-core - 1.5.25 - - - org.slf4j - slf4j-api - 1.7.30 - - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - 2.10.0 - - - com.squareup.okhttp3 - okhttp - 4.9.2 - - - - org.junit.jupiter - junit-jupiter-engine - 5.3.1 - test - - - org.mockito - mockito-core - 2.25.1 - test - - - org.mockito - mockito-junit-jupiter - 2.23.0 - test - - - org.assertj - assertj-core - 3.27.7 - test - - + + scm:git:https://github.com/exceptionless/Exceptionless.Java.git + scm:git:git@github.com:exceptionless/Exceptionless.Java.git + https://github.com/exceptionless/Exceptionless.Java + HEAD + + + + GitHub + https://github.com/exceptionless/Exceptionless.Java/issues + + + + exceptionless-bom + exceptionless-core + exceptionless-jul + exceptionless-logback + exceptionless-log4j2 + exceptionless-servlet + exceptionless-spring-boot-starter + exceptionless-e2e-tests + samples/console + samples/spring-boot + + + + 0.9.0-SNAPSHOT + 17 + UTF-8 + UTF-8 + + 2026-01-01T00:00:00Z + + + 1.0.0 + 2.0.17 + 1.5.18 + 2.24.3 + 6.0.0 + 3.5.16 + + + 5.13.4 + 3.27.7 + 4.3.0 + 2.50.0 + 0.14.2 + + + 3.5.0 + 3.21.0 + 3.6.4 + 3.14.1 + 3.5.4 + 3.4.2 + 3.3.1 + 3.11.3 + 3.3.1 + 3.1.4 + 3.1.4 + 3.6.1 + 3.2.8 + 1.7.3 + 0.8.15 + 3.10.4 + 1.35.0 + 0.9.0 + 3.6.2 + + + + + + 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.jspecify + jspecify + ${jspecify.version} + + + org.junit + junit-bom + ${junit.version} + pom + import + + + org.assertj + assertj-core + ${assertj.version} + + + org.awaitility + awaitility + ${awaitility.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + -Xlint:all,-processing,-serial,-options,-missing-explicit-ctor + -parameters + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + false + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + + + ${automatic.module.name} + + + + + + org.apache.maven.plugins + maven-clean-plugin + ${maven-clean-plugin.version} + + + org.apache.maven.plugins + maven-site-plugin + ${maven-site-plugin.version} + + + org.codehaus.mojo + exec-maven-plugin + ${exec-maven-plugin.version} + + + org.apache.maven.plugins + maven-resources-plugin + ${maven-resources-plugin.version} + + + org.apache.maven.plugins + maven-install-plugin + ${maven-install-plugin.version} + + + org.apache.maven.plugins + maven-deploy-plugin + ${maven-deploy-plugin.version} + + + org.apache.maven.plugins + maven-source-plugin + ${maven-source-plugin.version} + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + all,-missing + true + + + + org.apache.maven.plugins + maven-gpg-plugin + ${maven-gpg-plugin.version} + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + + org.codehaus.mojo + build-helper-maven-plugin + ${build-helper-maven-plugin.version} + + + add-test-fixtures + generate-test-sources + + add-test-source + + + + ${project.basedir}/../exceptionless-core/src/testFixtures/java + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + org.apache.maven.plugins - maven-compiler-plugin - 3.8.1 - - 11 - 11 - - - org.projectlombok - lombok - 1.18.14 - - - - - - - org.apache.maven.plugins - maven-surefire-plugin - 2.22.2 - - - --illegal-access=permit - - - - - maven-deploy-plugin - 2.8.2 + maven-enforcer-plugin + ${maven-enforcer-plugin.version} - default-deploy - deploy + enforce - deploy + enforce + + + + [3.9,) + + + [17,) + + + + + - org.apache.maven.plugins - maven-release-plugin - 2.5.3 + org.codehaus.mojo + flatten-maven-plugin + ${flatten-maven-plugin.version} - true - false - forked-path - -Dgpg.passphrase=${gpg.passphrase} + true + ossrh - - - org.apache.maven.scm - maven-scm-provider-gitexe - 1.11.2 - - - org.apache.maven.scm - maven-scm-api - 1.11.2 - - - - - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.8 - true - - ossrh - https://s01.oss.sonatype.org/ - true - - - - org.apache.maven.plugins - maven-source-plugin - 3.2.0 - attach-sources + flatten + process-resources - jar + flatten - attach-javadoc + flatten-clean + clean - jar + clean - - org.projectlombok - lombok-maven-plugin - 1.18.20.0 - - ${project.basedir}/src/main/java - UTF-8 - false - + org.jacoco + jacoco-maven-plugin - generate-sources + prepare-agent + + prepare-agent + + + + report + verify - delombok + report - - org.apache.maven.plugins - maven-javadoc-plugin - 3.3.0 - - ${project.build.directory}/generated-sources/delombok - - - - org.apache.maven.plugins - maven-help-plugin - 3.2.0 - - - - ossrh - https://s01.oss.sonatype.org/content/repositories/snapshots - - - ossrh - https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/ - - - - - - scm:git:git://github.com/exceptionless/Exceptionless.Java.git - scm:git:git@github.com:exceptionless/Exceptionless.Java.git - https://github.com/exceptionless/Exceptionless.Java - HEAD - - + + + format + + [21,) + + + + + com.diffplug.spotless + spotless-maven-plugin + ${spotless-maven-plugin.version} + + + + src/*/java/**/*.java + + + ${google-java-format.version} + + + + + + + check-formatting + verify + + check + + + + + + + + + - release-sign-artifacts + library - - performRelease - true - + + ${basedir}/src/main/java + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + attach-javadocs + + jar + + + + + + + + + + + release org.apache.maven.plugins maven-gpg-plugin - 3.0.1 sign-artifacts @@ -262,8 +445,67 @@ sign - ${gpg.keyname} - ${gpg.keyname} + + --pinentry-mode + loopback + + + + + + + org.sonatype.central + central-publishing-maven-plugin + ${central-publishing-maven-plugin.version} + true + + central + true + published + + exceptionless-e2e-tests + exceptionless-sample-console + exceptionless-sample-spring-boot + + + + + + + + errorprone + + [21,) + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + + false + + -XDcompilePolicy=simple + -XDaddTypeAnnotationsToSymbol=true + --should-stop=ifError=FLOW + -Werror + -Xplugin:ErrorProne -XepDisableWarningsInGeneratedCode -Xep:NullAway:ERROR -XepOpt:NullAway:OnlyNullMarked=true -XepOpt:NullAway:JSpecifyMode=true + + + + com.google.errorprone + error_prone_core + ${errorprone.version} + + + com.uber.nullaway + nullaway + ${nullaway.version} + + @@ -272,4 +514,4 @@ - \ No newline at end of file + diff --git a/samples/console/pom.xml b/samples/console/pom.xml new file mode 100644 index 0000000..0c693c5 --- /dev/null +++ b/samples/console/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + + com.exceptionless + exceptionless-parent + ${revision} + ../../pom.xml + + + exceptionless-sample-console + Exceptionless Sample: Console + A plain Java app that reports errors, logs, feature usage and sessions. + + + com.example.console + true + true + true + + + + + + com.exceptionless + exceptionless-core + + + com.exceptionless + exceptionless-jul + + + + + + + + org.codehaus.mojo + exec-maven-plugin + + java + + -classpath + + com.example.console.ConsoleApp + ${sample.args} + + + + + + diff --git a/samples/console/src/main/java/com/example/console/ConsoleApp.java b/samples/console/src/main/java/com/example/console/ConsoleApp.java new file mode 100644 index 0000000..22c909e --- /dev/null +++ b/samples/console/src/main/java/com/example/console/ConsoleApp.java @@ -0,0 +1,107 @@ +package com.example.console; + +import com.exceptionless.Exceptionless; +import com.exceptionless.LogLevel; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; + +/** + * Shows the main features of the Exceptionless Java client. + * + *

Run it with your API key (and server URL when self hosting): + * + *

+ * ./mvnw -q install -DskipTests
+ * EXCEPTIONLESS_API_KEY=your-key ./mvnw -q -pl samples/console exec:exec
+ * EXCEPTIONLESS_API_KEY=your-key EXCEPTIONLESS_SERVER_URL=http://localhost:5200 ./mvnw ...
+ * EXCEPTIONLESS_API_KEY=your-key ./mvnw -q -pl samples/console exec:exec -Dsample.args=crash
+ * 
+ */ +public final class ConsoleApp { + private static final Logger LOG = Logger.getLogger(ConsoleApp.class.getName()); + + private ConsoleApp() {} + + public static void main(String[] args) throws Exception { + // Route java.util.logging WARNING+ records to Exceptionless (see logging.properties). + try (var config = + Objects.requireNonNull( + ConsoleApp.class.getResourceAsStream("/logging.properties"), "logging.properties")) { + LogManager.getLogManager().readConfiguration(config); + } + + // Reads EXCEPTIONLESS_API_KEY / EXCEPTIONLESS_SERVER_URL (or exceptionless.properties). + // Uncaught exceptions are reported and queued events are sent when the JVM exits. + Exceptionless.startup( + config -> + config + .version("1.0.0") + .addDefaultTags("sample", "console") + .addDefaultData("environment", "development") + .userIdentity("sample-user", "Sample User") + .addDataExclusions("*password*") + .useSessions()); + + if (!Exceptionless.getConfiguration().isEnabled()) { + System.err.println("Set EXCEPTIONLESS_API_KEY to send events to Exceptionless."); + return; + } + + // Logs. The source is usually the class or logger name. + Exceptionless.submitLog(ConsoleApp.class.getName(), "Console sample started", LogLevel.INFO); + + // Feature usage. + Exceptionless.createFeatureUsage("Report Export") + .setProperty("format", "csv") + .setValue(42) + .submit(); + + // A handled exception with extra context. + try { + placeOrder(Map.of("id", "order-17", "total", 99.95, "password", "never sent")); + } catch (IllegalStateException e) { + Exceptionless.createException(e) + .addTags("orders") + .setProperty("order", Map.of("id", "order-17", "items", List.of("book", "pen"))) + .markAsCritical() + .submit(); + String referenceId = Exceptionless.getLastReferenceId(); + System.out.println("Reported the error; tell support it was " + referenceId); + Exceptionless.getDefault() + .updateUserEmailAndDescription(referenceId, "user@example.com", "I clicked Order twice") + .join(); + } + + // JUL records reach Exceptionless through ExceptionlessHandler (see logging.properties). + LOG.log(Level.WARNING, "Inventory is low for {0}", "pen"); + + // 404s. + Exceptionless.submitNotFound("/products/discontinued"); + + // Send everything now (also happens automatically on exit). + boolean sent = Exceptionless.flush(Duration.ofSeconds(10)); + System.out.println(sent ? "All events sent." : "Some events are still queued."); + + // Uncaught exceptions are reported automatically before the JVM exits. + if (args.length > 0 && args[0].equals("crash")) { + throw new IllegalStateException("The console sample crashed on purpose"); + } + } + + private static void placeOrder(Map order) { + try { + chargeCard(order); + } catch (RuntimeException e) { + throw new IllegalStateException("Unable to place order " + order.get("id"), e); + } + } + + private static void chargeCard(Map order) { + throw new UnsupportedOperationException("Card declined for order " + order.get("id")); + } +} diff --git a/samples/console/src/main/resources/logging.properties b/samples/console/src/main/resources/logging.properties new file mode 100644 index 0000000..dfcde85 --- /dev/null +++ b/samples/console/src/main/resources/logging.properties @@ -0,0 +1,5 @@ +# Send WARNING and above from java.util.logging to Exceptionless. +# Load with -Djava.util.logging.config.file=... or via LogManager.readConfiguration. +handlers=java.util.logging.ConsoleHandler, com.exceptionless.jul.ExceptionlessHandler +.level=INFO +com.exceptionless.jul.ExceptionlessHandler.level=WARNING diff --git a/samples/example-app/pom.xml b/samples/example-app/pom.xml deleted file mode 100644 index d092da6..0000000 --- a/samples/example-app/pom.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - 4.0.0 - - com.exceptionless - example-app - 1.0 - - - - com.exceptionless - exceptionless-client - 1.0-beta1 - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.5.1 - - 11 - 11 - - - - - \ No newline at end of file diff --git a/samples/example-app/src/main/java/com/exceptionless/example/app/ExampleApp.java b/samples/example-app/src/main/java/com/exceptionless/example/app/ExampleApp.java deleted file mode 100644 index d2f8059..0000000 --- a/samples/example-app/src/main/java/com/exceptionless/example/app/ExampleApp.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.exceptionless.example.app; - -import com.exceptionless.exceptionlessclient.ExceptionlessClient; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; - -public class ExampleApp { - private static final ExceptionlessClient client = - ExceptionlessClient.from( - System.getenv("EXCEPTIONLESS_SAMPLE_APP_API_KEY"), - System.getenv("EXCEPTIONLESS_SAMPLE_APP_SERVER_URL")); - - public static void sampleEventSubmissions() { - client.submitException(new RuntimeException("Test exception")); - client.submitUnhandledException( - new RuntimeException("Test exception"), "Test submission method"); - client.submitFeatureUsage("Test feature"); - client.submitLog("Test log"); - client.submitNotFound("Test resource"); - } - - public static void sampleUseOfSessions() { - client.getConfiguration().useSessions(); - client.submitEvent(client.createSessionStart().userIdentity("test-user").build()); - client.submitSessionEnd("test-user"); - } - - public static void sampleUseOfUpdatingEmailAndDescription() { - client.submitEvent(client.createLog("test-log").referenceId("test-reference-id").build()); - client.updateEmailAndDescription("test-reference-id", "test@email.com", "test-description"); - } - - public static void main(String[] args) { - sampleEventSubmissions(); - sampleUseOfUpdatingEmailAndDescription(); - sampleUseOfSessions(); - } -} diff --git a/samples/example-spring-boot-app/pom.xml b/samples/example-spring-boot-app/pom.xml deleted file mode 100644 index 2452df2..0000000 --- a/samples/example-spring-boot-app/pom.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - 4.0.0 - - com.prashantchaubey - example-spring-boot-app - 1.0-SNAPSHOT - - - org.springframework.boot - spring-boot-starter-parent - 2.3.4.RELEASE - - - - - com.exceptionless - exceptionless-client - 1.0-beta1 - - - org.springframework.boot - spring-boot-starter-web - - - com.squareup.okhttp3 - okhttp - 4.9.1 - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.5.1 - - 11 - 11 - - - - - \ No newline at end of file diff --git a/samples/example-spring-boot-app/src/main/java/com/exceptionless/example/spring/boot/app/ExampleAPI.java b/samples/example-spring-boot-app/src/main/java/com/exceptionless/example/spring/boot/app/ExampleAPI.java deleted file mode 100644 index 461cb27..0000000 --- a/samples/example-spring-boot-app/src/main/java/com/exceptionless/example/spring/boot/app/ExampleAPI.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.exceptionless.example.spring.boot.app; - -import com.exceptionless.exceptionlessclient.ExceptionlessClient; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.Bean; - -@SpringBootApplication -public class ExampleAPI { - public static void main(String[] args) { - SpringApplication.run(ExampleAPI.class, args); - } - - @Bean - public ExceptionlessClient exceptionlessClient() { - return ExceptionlessClient.from( - System.getenv("EXCEPTIONLESS_SAMPLE_APP_API_KEY"), - System.getenv("EXCEPTIONLESS_SAMPLE_APP_SERVER_URL")); - } -} diff --git a/samples/example-spring-boot-app/src/main/java/com/exceptionless/example/spring/boot/app/api/ExampleResource.java b/samples/example-spring-boot-app/src/main/java/com/exceptionless/example/spring/boot/app/api/ExampleResource.java deleted file mode 100644 index 09eaf39..0000000 --- a/samples/example-spring-boot-app/src/main/java/com/exceptionless/example/spring/boot/app/api/ExampleResource.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.exceptionless.example.spring.boot.app.api; - -import com.exceptionless.exceptionlessclient.ExceptionlessClient; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/test") -public class ExampleResource { - private final ExceptionlessClient exceptionlessClient; - - @Autowired - public ExampleResource(ExceptionlessClient exceptionlessClient) { - this.exceptionlessClient = exceptionlessClient; - } - - @PostMapping("/log") - @ResponseStatus(HttpStatus.ACCEPTED) - public void submitLog() { - exceptionlessClient.submitLog("test-log"); - } -} diff --git a/samples/spring-boot/pom.xml b/samples/spring-boot/pom.xml new file mode 100644 index 0000000..98ecfff --- /dev/null +++ b/samples/spring-boot/pom.xml @@ -0,0 +1,73 @@ + + + 4.0.0 + + + com.exceptionless + exceptionless-parent + ${revision} + ../../pom.xml + + + exceptionless-sample-spring-boot + Exceptionless Sample: Spring Boot + A Spring Boot web app that reports errors, 404s, logs and feature usage. + + + com.example.springboot + true + true + true + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + com.exceptionless + exceptionless-spring-boot-starter + + + org.springframework.boot + spring-boot-starter-web + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + build-info + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce + + true + + + + + + + diff --git a/samples/spring-boot/src/main/java/com/example/springboot/OrderController.java b/samples/spring-boot/src/main/java/com/example/springboot/OrderController.java new file mode 100644 index 0000000..738cdf9 --- /dev/null +++ b/samples/spring-boot/src/main/java/com/example/springboot/OrderController.java @@ -0,0 +1,50 @@ +package com.example.springboot; + +import com.exceptionless.ExceptionlessClient; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class OrderController { + private static final Logger LOG = LoggerFactory.getLogger(OrderController.class); + + private final ExceptionlessClient exceptionless; + + OrderController(ExceptionlessClient exceptionless) { + this.exceptionless = exceptionless; + } + + @GetMapping("/orders/{id}") + Map order(@PathVariable long id) { + if (id <= 0) { + // Unhandled: reported with the request details by the starter. + throw new IllegalArgumentException("Order id must be positive: " + id); + } + if (id > 1000) { + // Logged warnings are sent as log events; key-value pairs (and MDC entries) become + // properties. + LOG.atWarn().addKeyValue("orderId", id).log("Order {} is archived", id); + } + exceptionless.createFeatureUsage("View Order").setProperty("orderId", id).submit(); + return Map.of("id", id, "status", "shipped"); + } + + @GetMapping("/orders/{id}/pay") + Map pay(@PathVariable long id) { + try { + throw new IllegalStateException("Card declined"); + } catch (IllegalStateException e) { + // Handled exceptions can be sent with extra context; the reference id can be shown to users. + exceptionless.createException(e).addTags("payments").setProperty("orderId", id).submit(); + return Map.of( + "error", + "Payment failed", + "reference", + String.valueOf(exceptionless.getLastReferenceId())); + } + } +} diff --git a/samples/spring-boot/src/main/java/com/example/springboot/SampleApplication.java b/samples/spring-boot/src/main/java/com/example/springboot/SampleApplication.java new file mode 100644 index 0000000..d2713b8 --- /dev/null +++ b/samples/spring-boot/src/main/java/com/example/springboot/SampleApplication.java @@ -0,0 +1,21 @@ +package com.example.springboot; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * A Spring Boot app wired to Exceptionless by {@code exceptionless-spring-boot-starter}. + * + *
+ * EXCEPTIONLESS_API_KEY=your-key ./mvnw -pl samples/spring-boot -am spring-boot:run
+ * curl localhost:8080/orders/42        # logs + feature usage
+ * curl localhost:8080/orders/0         # unhandled exception, reported with the request
+ * curl localhost:8080/does-not-exist   # 404
+ * 
+ */ +@SpringBootApplication +public class SampleApplication { + public static void main(String[] args) { + SpringApplication.run(SampleApplication.class, args); + } +} diff --git a/samples/spring-boot/src/main/resources/application.yml b/samples/spring-boot/src/main/resources/application.yml new file mode 100644 index 0000000..9ace5de --- /dev/null +++ b/samples/spring-boot/src/main/resources/application.yml @@ -0,0 +1,7 @@ +exceptionless: + api-key: ${EXCEPTIONLESS_API_KEY:} + # server-url: http://localhost:5200 + default-tags: sample, spring-boot + use-sessions: false + logging: + minimum-level: warn diff --git a/src/main/java/com/exceptionless/exceptionlessclient/ExceptionlessClient.java b/src/main/java/com/exceptionless/exceptionlessclient/ExceptionlessClient.java deleted file mode 100644 index 17eb074..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/ExceptionlessClient.java +++ /dev/null @@ -1,221 +0,0 @@ -package com.exceptionless.exceptionlessclient; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.enums.EventPropertyKey; -import com.exceptionless.exceptionlessclient.enums.EventType; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.PluginContext; -import com.exceptionless.exceptionlessclient.models.UserDescription; -import com.exceptionless.exceptionlessclient.plugins.EventPluginRunner; -import com.exceptionless.exceptionlessclient.submission.SubmissionResponse; -import com.exceptionless.exceptionlessclient.utils.VisibleForTesting; -import lombok.Builder; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; - -import java.time.OffsetDateTime; -import java.util.Timer; -import java.util.TimerTask; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -@Slf4j -public class ExceptionlessClient { - private static final String UPDATE_SETTINGS_TIMER_NAME = "update-settings-timer"; - private static final int UPDATE_SETTINGS_TIMER_INITIAL_DELAY = 5000; - private static final Integer DEFAULT_NTHREADS = 10; - - @Getter private final Configuration configuration; - private final EventPluginRunner eventPluginRunner; - private final Timer updateSettingsTimer; - private final ExecutorService executorService; - - @Builder - public ExceptionlessClient(Configuration configuration, Integer nThreads) { - this( - configuration, - UPDATE_SETTINGS_TIMER_INITIAL_DELAY, - nThreads == null ? DEFAULT_NTHREADS : nThreads); - } - - @VisibleForTesting - ExceptionlessClient( - Configuration configuration, long updateSettingsTimerInitialDelay, Integer nThreads) { - this.configuration = configuration; - this.eventPluginRunner = EventPluginRunner.builder().configuration(this.configuration).build(); - this.updateSettingsTimer = new Timer(UPDATE_SETTINGS_TIMER_NAME); - this.executorService = Executors.newFixedThreadPool(nThreads); - init(updateSettingsTimerInitialDelay); - } - - private void init(long delay) { - updateSettingsTimer.schedule( - new TimerTask() { - @Override - public void run() { - try { - configuration.getSettingsManager().updateSettings(); - } catch (Exception e) { - log.error("Error in updating settings", e); - } - } - }, - delay, - configuration.getUpdateSettingsWhenIdleInterval().get()); - - configuration.onChanged(ignored -> configuration.getSettingsManager().updateSettings()); - configuration - .getQueue() - .onEventsPosted( - (ignored1, ignored2) -> configuration.getSettingsManager().updateSettings()); - } - - public static ExceptionlessClient from(String apiKey, String serverUrl) { - return ExceptionlessClient.builder() - .configuration(Configuration.builder().apiKey(apiKey).serverUrl(serverUrl).build()) - .build(); - } - - public void submitException(Exception exception) { - submitException(null, exception); - } - - public void submitException(String message, Exception exception) { - Event event; - if (message == null) { - event = createError().build(); - } else { - event = createError().message(message).build(); - } - PluginContext pluginContext = PluginContext.builder().exception(exception).build(); - submitEventWithContext( - EventPluginContext.builder().event(event).context(pluginContext).build()); - } - - public Event.EventBuilder createError() { - return createEvent().type(EventType.ERROR.value()); - } - - public void submitUnhandledException(Exception exception, String submissionMethod) { - Event event = createError().build(); - PluginContext pluginContext = - PluginContext.builder() - .exception(exception) - .unhandledError(true) - .submissionMethod(submissionMethod) - .build(); - submitEventWithContext( - EventPluginContext.builder().event(event).context(pluginContext).build()); - } - - public void submitFeatureUsage(String feature) { - submitEvent(createFeatureUsage(feature).build()); - } - - public Event.EventBuilder createFeatureUsage(String feature) { - return createEvent().type(EventType.USAGE.value()).source(feature); - } - - public void submitLog(String message) { - submitLog(message, null, null); - } - - public void submitLog(String message, String source) { - submitLog(message, source, null); - } - - public void submitLog(String message, String source, String level) { - submitEvent(createLog(message, source, level).build()); - } - - public Event.EventBuilder createLog(String message) { - return createLog(message, null, null); - } - - public Event.EventBuilder createLog(String message, String source) { - return createLog(message, source, null); - } - - public Event.EventBuilder createLog(String message, String source, String level) { - if (source == null) { - source = getCallingMethod(); - } - - Event.EventBuilder builder = - createEvent().type(EventType.LOG.value()).source(source).message(message); - if (level == null) { - return builder; - } - - return builder.property(EventPropertyKey.LOG_LEVEL.value(), level); - } - - private String getCallingMethod() { - StackTraceElement[] traceElements = Thread.currentThread().getStackTrace(); - String source = traceElements[3].getMethodName(); - boolean cameFromOverridenMethod = source.equals("createLog"); - - return cameFromOverridenMethod ? traceElements[4].getMethodName() : source; - } - - public void submitNotFound(String resource) { - submitEvent(createNotFound(resource).build()); - } - - public Event.EventBuilder createNotFound(String resource) { - return createEvent().type(EventType.NOT_FOUND.value()).source(resource); - } - - public void submitSessionStart() { - submitEvent(createSessionStart().build()); - } - - public Event.EventBuilder createSessionStart() { - return createEvent().type(EventType.SESSION.value()); - } - - public Event.EventBuilder createEvent() { - return Event.builder().dataExclusions(configuration.getDataExclusions()).date(OffsetDateTime.now()); - } - - public void submitEvent(Event event) { - eventPluginRunner.run(EventPluginContext.from(event)); - } - - public void submitEventWithContext(EventPluginContext eventPluginContext) { - eventPluginRunner.run(eventPluginContext); - } - - public void submitSessionEnd(String sessionOrUserId) { - log.info(String.format("Submitting session end: %s", sessionOrUserId)); - configuration.getSubmissionClient().sendHeartBeat(sessionOrUserId, true); - } - - public SubmissionResponse updateEmailAndDescription( - String referenceId, String email, String description) { - SubmissionResponse response = - configuration - .getSubmissionClient() - .postUserDescription( - referenceId, - UserDescription.builder().description(description).emailAddress(email).build()); - if (response.hasException()) { - log.error( - String.format("Failed to submit user email and description for event: %s", referenceId), - response.getException()); - } - if (!response.isSuccess()) { - log.error( - String.format( - "Failed to submit user email and description for event: %s, code: %s", - referenceId, response.getCode())); - } - - return response; - } - - public String getLastReferenceId() { - return configuration.getLastReferenceIdManager().getLast(); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/configuration/Configuration.java b/src/main/java/com/exceptionless/exceptionlessclient/configuration/Configuration.java deleted file mode 100644 index d59c83c..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/configuration/Configuration.java +++ /dev/null @@ -1,360 +0,0 @@ -package com.exceptionless.exceptionlessclient.configuration; - -import ch.qos.logback.core.Context; -import com.exceptionless.exceptionlessclient.enums.EventPropertyKey; -import com.exceptionless.exceptionlessclient.exceptions.InvalidApiKeyException; -import com.exceptionless.exceptionlessclient.logging.LogCapturerAppender; -import com.exceptionless.exceptionlessclient.logging.LogCapturerIF; -import com.exceptionless.exceptionlessclient.logging.NullLogCapturer; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.UserInfo; -import com.exceptionless.exceptionlessclient.plugins.EventPluginIF; -import com.exceptionless.exceptionlessclient.plugins.PluginManager; -import com.exceptionless.exceptionlessclient.plugins.preconfigured.HeartbeatPlugin; -import com.exceptionless.exceptionlessclient.queue.DefaultEventQueue; -import com.exceptionless.exceptionlessclient.queue.EventQueueIF; -import com.exceptionless.exceptionlessclient.services.DefaultLastReferenceIdManager; -import com.exceptionless.exceptionlessclient.services.LastReferenceIdManagerIF; -import com.exceptionless.exceptionlessclient.settings.DefaultSettingsClient; -import com.exceptionless.exceptionlessclient.settings.SettingsClientIF; -import com.exceptionless.exceptionlessclient.settings.SettingsManager; -import com.exceptionless.exceptionlessclient.storage.InMemoryStorageProvider; -import com.exceptionless.exceptionlessclient.storage.StorageProviderIF; -import com.exceptionless.exceptionlessclient.submission.DefaultSubmissionClient; -import com.exceptionless.exceptionlessclient.submission.SubmissionClientIF; -import lombok.Builder; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.beans.PropertyChangeSupport; -import java.util.*; -import java.util.function.BiConsumer; -import java.util.function.Consumer; - -@Slf4j -public class Configuration { - private static final Integer API_KEY_MIN_LENGTH = 11; - private static final Integer DEFAULT_HEART_BEAT_INTERVAL_IN_SECS = 30; - private static final String DEFAULT_SERVER_URL = "https://collector.exceptionless.io"; - private static final String DEFAULT_HEARTBEAT_SERVER_URL = "https://heartbeat.exceptionless.io"; - private static final String DEFAULT_CONFIG_SERVER_URL = "https://config.exceptionless.io"; - private static final Long DEFAULT_UPDATE_SETTINGS_WHEN_IDLE_INTERVAL = 12000L; - private static final Integer DEFAULT_SUBMISSION_BATCH_SIZE = 50; - private static final Integer DEFAULT_SUBMISSION_CLIENT_TIMEOUT_IN_MILLIS = 500; - private static final Integer DEFAULT_SETTINGS_CLIENT_TIMEOUT_IN_MILLIS = 500; - - public static class Property { - public static final String API_KEY = "apiKey"; - public static final String SERVER_URL = "serverUrl"; - public static final String CONFIG_SERVER_URL = "configServerUrl"; - public static final String HEART_BEAT_SERVER_URL = "heartbeatServerUrl"; - public static final String UPDATE_SETTINGS_WHEN_IDLE_INTERVAL = - "updateSettingsWhenIdleInterval"; - public static final String SUBMISSION_BATCH_SIZE = "submissionBatchSize"; - public static final String SUBMISSION_CLIENT_TIMEOUT_IN_MILLIS = - "submissionClientTimeoutInMillis"; - public static final String SETTINGS_CLIENT_TIMEOUT_IN_MILLIS = "settingsClientTimeoutInMillis"; - } - - @Getter private final LastReferenceIdManagerIF lastReferenceIdManager; - @Getter private final SubmissionClientIF submissionClient; - @Getter private final EventQueueIF queue; - @Getter private final Set defaultTags; - @Getter private final Map defaultData; - private final List> onChangedHandlers; - @Getter private final SettingsManager settingsManager; - private final Set userAgentBotPatterns; - @Getter private final PrivateInformationInclusions privateInformationInclusions; - private final Set dataExclusions; - private final PluginManager pluginManager; - @Getter private final StorageProviderIF storageProvider; - @Getter private final ValueProvider apiKey; - @Getter private final ValueProvider serverUrl; - @Getter private final ValueProvider heartbeatServerUrl; - @Getter private final ValueProvider configServerUrl; - @Getter private final ValueProvider updateSettingsWhenIdleInterval; - @Getter private final ValueProvider submissionBatchSize; - @Getter private final ValueProvider submissionClientTimeoutInMillis; - @Getter private final ValueProvider settingsClientTimeoutInMillis; - private final PropertyChangeSupport propertyChangeSupport; - - @Builder - public Configuration( - LastReferenceIdManagerIF lastReferenceIdManager, - LogCapturerIF logCatpurer, - SubmissionClientIF submissionClient, - SettingsClientIF settingsClient, - StorageProviderIF storageProvider, - EventQueueIF queue, - Integer maxQueueItems, - Integer processingIntervalInSecs, - String apiKey, - String serverUrl, - String configServerUrl, - String heartbeatServerUrl, - Long updateSettingsWhenIdleInterval, - Integer submissionBatchSize, - Integer submissionClientTimeoutInMillis, - Integer settingsClientTimeoutInMillis) { - this.apiKey = ValueProvider.of(apiKey); - this.serverUrl = - serverUrl == null ? ValueProvider.of(DEFAULT_SERVER_URL) : ValueProvider.of(serverUrl); - this.heartbeatServerUrl = - heartbeatServerUrl == null - ? (serverUrl == null - ? ValueProvider.of(DEFAULT_HEARTBEAT_SERVER_URL) - : ValueProvider.of(serverUrl)) - : ValueProvider.of(heartbeatServerUrl); - this.configServerUrl = - configServerUrl == null - ? (serverUrl == null - ? ValueProvider.of(DEFAULT_CONFIG_SERVER_URL) - : ValueProvider.of(serverUrl)) - : ValueProvider.of(configServerUrl); - this.updateSettingsWhenIdleInterval = - updateSettingsWhenIdleInterval == null - ? ValueProvider.of(DEFAULT_UPDATE_SETTINGS_WHEN_IDLE_INTERVAL) - : ValueProvider.of(updateSettingsWhenIdleInterval); - this.submissionBatchSize = - submissionBatchSize == null - ? ValueProvider.of(DEFAULT_SUBMISSION_BATCH_SIZE) - : ValueProvider.of(submissionBatchSize); - this.submissionClientTimeoutInMillis = - submissionClientTimeoutInMillis == null - ? ValueProvider.of(DEFAULT_SUBMISSION_CLIENT_TIMEOUT_IN_MILLIS) - : ValueProvider.of(submissionClientTimeoutInMillis); - this.settingsClientTimeoutInMillis = - settingsClientTimeoutInMillis == null - ? ValueProvider.of(DEFAULT_SETTINGS_CLIENT_TIMEOUT_IN_MILLIS) - : ValueProvider.of(settingsClientTimeoutInMillis); - this.propertyChangeSupport = new PropertyChangeSupport(this); - this.lastReferenceIdManager = - lastReferenceIdManager == null - ? DefaultLastReferenceIdManager.builder().build() - : lastReferenceIdManager; - this.storageProvider = - storageProvider == null - ? InMemoryStorageProvider.builder().maxQueueItems(maxQueueItems).build() - : storageProvider; - this.settingsManager = - SettingsManager.builder() - .settingsClient( - settingsClient == null - ? DefaultSettingsClient.builder() - .configServerUrl(this.configServerUrl) - .settingsClientTimeoutInMillis(this.settingsClientTimeoutInMillis) - .apiKey(this.apiKey) - .build() - : settingsClient) - .storageProvider(this.storageProvider) - .build(); - this.userAgentBotPatterns = new HashSet<>(); - this.submissionClient = - submissionClient == null - ? DefaultSubmissionClient.builder() - .settingsManager(this.settingsManager) - .submissionClientTimeoutInMillis(this.submissionClientTimeoutInMillis) - .serverUrl(this.serverUrl) - .apiKey(this.apiKey) - .heartbeatServerUrl(this.heartbeatServerUrl) - .build() - : submissionClient; - this.queue = - queue == null - ? DefaultEventQueue.builder() - .submissionBatchSize(this.submissionBatchSize) - .processingIntervalInSecs(processingIntervalInSecs) - .storageProvider(this.storageProvider) - .submissionClient(this.submissionClient) - .build() - : queue; - this.pluginManager = PluginManager.builder().build(); - this.defaultData = new HashMap<>(); - this.defaultTags = new HashSet<>(); - this.onChangedHandlers = new ArrayList<>(); - this.dataExclusions = new HashSet<>(); - this.privateInformationInclusions = PrivateInformationInclusions.builder().build(); - checkApiKeyIsValid(); - addPropertyChangeListeners(); - addLogCapturer(logCatpurer); - } - - private void addPropertyChangeListeners() { - this.privateInformationInclusions.addPropertyChangeListener(ignored -> changed()); - this.propertyChangeSupport.addPropertyChangeListener(ignored -> changed()); - this.settingsManager.addPropertyChangeListener(ignored -> changed()); - } - - private void checkApiKeyIsValid() { - if (this.apiKey.get() != null && this.apiKey.get().length() >= API_KEY_MIN_LENGTH) { - return; - } - - throw new InvalidApiKeyException(String.format("Apikey is not valid: [%s]", this.apiKey.get())); - } - - private void addLogCapturer(LogCapturerIF logCatpurer) { - logCatpurer = logCatpurer == null ? NullLogCapturer.builder().build() : logCatpurer; - - ch.qos.logback.classic.Logger logBackRootLogger = - (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); - LogCapturerAppender appender = LogCapturerAppender.builder().logCapturer(logCatpurer).build(); - appender.setContext((Context) LoggerFactory.getILoggerFactory()); - appender.start(); - logBackRootLogger.addAppender(appender); - } - - public void setApiKey(String apiKey) { - String prevValue = this.apiKey.get(); - this.apiKey.update(apiKey); - propertyChangeSupport.firePropertyChange(Property.API_KEY, prevValue, apiKey); - } - - public void setServerUrl(String serverUrl) { - String prevValue = this.serverUrl.get(); - this.serverUrl.update(serverUrl); - propertyChangeSupport.firePropertyChange( - Property.SERVER_URL, prevValue, serverUrl); - } - - public void setConfigServerUrl(String configServerUrl) { - String prevValue = this.configServerUrl.get(); - this.configServerUrl.update(configServerUrl); - propertyChangeSupport.firePropertyChange( - Property.CONFIG_SERVER_URL, prevValue, configServerUrl); - } - - public void setHeartbeatServerUrl(String heartbeatServerUrl) { - String prevValue = this.heartbeatServerUrl.get(); - this.heartbeatServerUrl.update(heartbeatServerUrl); - propertyChangeSupport.firePropertyChange( - Property.HEART_BEAT_SERVER_URL, prevValue, heartbeatServerUrl); - } - - public void setUpdateSettingsWhenIdleInterval(Long updateSettingsWhenIdleInterval) { - Long prevValue = this.updateSettingsWhenIdleInterval.get(); - this.updateSettingsWhenIdleInterval.update(updateSettingsWhenIdleInterval); - propertyChangeSupport.firePropertyChange( - Property.UPDATE_SETTINGS_WHEN_IDLE_INTERVAL, - prevValue, - updateSettingsWhenIdleInterval); - } - - public void setSubmissionBatchSize(Integer submissionBatchSize) { - Integer prevValue = this.submissionBatchSize.get(); - this.submissionBatchSize.update(submissionBatchSize); - propertyChangeSupport.firePropertyChange( - Property.SUBMISSION_BATCH_SIZE, prevValue, submissionBatchSize); - } - - public void setSubmissionClientTimeoutInMillis(Integer submissionClientTimeoutInMillis) { - Integer prevValue = this.submissionClientTimeoutInMillis.get(); - this.submissionClientTimeoutInMillis.update(submissionClientTimeoutInMillis); - propertyChangeSupport.firePropertyChange( - Property.SUBMISSION_CLIENT_TIMEOUT_IN_MILLIS, - prevValue, - submissionClientTimeoutInMillis); - } - - public void setSettingsClientTimeoutInMillis(Integer settingsClientTimeoutInMillis) { - Integer prevValue = this.settingsClientTimeoutInMillis.get(); - this.settingsClientTimeoutInMillis.update(settingsClientTimeoutInMillis); - propertyChangeSupport.firePropertyChange( - Property.SETTINGS_CLIENT_TIMEOUT_IN_MILLIS, - prevValue, - settingsClientTimeoutInMillis); - } - - public void addDefaultTags(String... tags) { - defaultTags.addAll(Arrays.asList(tags)); - } - - public void addDataExclusions(String... exclusions) { - dataExclusions.addAll(Arrays.asList(exclusions)); - } - - public void addUserAgentBotPatterns(String... userAgentBotPatterns) { - this.userAgentBotPatterns.addAll(Arrays.asList(userAgentBotPatterns)); - } - - public Set getDataExclusions() { - Set combinedExclusions = settingsManager.getSavedServerSettings().getDataExclusions(); - combinedExclusions.addAll(dataExclusions); - return combinedExclusions; - } - - public Set getUserAgentBotPatterns() { - Set combinedPatterns = - settingsManager.getSavedServerSettings().getUserAgentBotPatterns(); - combinedPatterns.addAll(userAgentBotPatterns); - return combinedPatterns; - } - - public void submitSessionHeartbeat(String sessionOrUserId) { - log.info(String.format("Submitting session heartbeat: %s", sessionOrUserId)); - submissionClient.sendHeartBeat(sessionOrUserId, false); - } - - public void addPlugin(EventPluginIF eventPlugin) { - pluginManager.addPlugin(eventPlugin); - } - - public void addPlugin(BiConsumer pluginAction) { - pluginManager.addPlugin(pluginAction); - } - - public void addPlugin( - String name, - int priority, - BiConsumer pluginAction) { - pluginManager.addPlugin(name, priority, pluginAction); - } - - public void removePlugin(String name) { - pluginManager.removePlugin(name); - } - - public List getPlugins() { - return pluginManager.getPlugins(); - } - - public void setVersion(String version) { - this.defaultData.put(EventPropertyKey.VERSION.value(), version); - } - - public void removeUserIdentity() { - this.defaultData.remove(EventPropertyKey.USER.value()); - } - - public void setUserIdentity(String name, String identity) { - setUserIdentity(UserInfo.builder().name(name).identity(identity).build()); - } - - public void setUserIdentity(UserInfo userInfo) { - this.defaultData.put(EventPropertyKey.USER.value(), userInfo); - } - - public void useSessions() { - useSessions(DEFAULT_HEART_BEAT_INTERVAL_IN_SECS); - } - - public void useSessions(int heartbeatIntervalInSecs) { - addPlugin(HeartbeatPlugin.builder().heartbeatIntervalInSecs(heartbeatIntervalInSecs).build()); - } - - public void onChanged(Consumer onChangedHandler) { - onChangedHandlers.add(onChangedHandler); - } - - private void changed() { - for (Consumer onChangedHandler : onChangedHandlers) { - try { - onChangedHandler.accept(this); - } catch (Exception e) { - log.error(String.format("Error calling on changed handler: %s", e.getMessage()), e); - } - } - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/configuration/PrivateInformationInclusions.java b/src/main/java/com/exceptionless/exceptionlessclient/configuration/PrivateInformationInclusions.java deleted file mode 100644 index acd8361..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/configuration/PrivateInformationInclusions.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.exceptionless.exceptionlessclient.configuration; - -import lombok.Builder; -import lombok.Getter; - -import java.beans.PropertyChangeListener; -import java.beans.PropertyChangeSupport; - -public class PrivateInformationInclusions { - public static class Property { - public static final String ALL = "all"; - public static final String QUERY_STRING = "queryString"; - public static final String POST_DATA = "postData"; - public static final String COOKIES = "cookies"; - public static final String IP_ADDRESS = "ipAddress"; - public static final String MACHINE_NAME = "machineName"; - public static final String USER_NAME = "userName"; - } - - @Getter private Boolean queryString; - @Getter private Boolean postData; - @Getter private Boolean cookies; - @Getter private Boolean ipAddress; - @Getter private Boolean machineName; - @Getter private Boolean userName; - private final PropertyChangeSupport propertyChangeSupport; - - @Builder - public PrivateInformationInclusions( - Boolean queryString, - Boolean postData, - Boolean cookies, - Boolean ipAddress, - Boolean machineName, - Boolean userName) { - this.queryString = queryString != null && queryString; - this.postData = postData != null && postData; - this.cookies = cookies != null && cookies; - this.ipAddress = ipAddress != null && ipAddress; - this.machineName = machineName != null && machineName; - this.userName = userName != null && userName; - this.propertyChangeSupport = new PropertyChangeSupport(this); - } - - public void addPropertyChangeListener(PropertyChangeListener listener) { - propertyChangeSupport.addPropertyChangeListener(listener); - } - - public Boolean isAllIncluded() { - return queryString && postData && cookies && ipAddress && machineName && userName; - } - - public void applyToAll(Boolean include) { - Boolean prevValue = isAllIncluded(); - queryString = postData = cookies = ipAddress = machineName = userName = include; - propertyChangeSupport.firePropertyChange(Property.ALL, prevValue, include); - } - - public void setQueryString(Boolean queryString) { - Boolean prevValue = this.queryString; - this.queryString = queryString; - propertyChangeSupport.firePropertyChange(Property.QUERY_STRING, prevValue, queryString); - } - - public void setPostData(Boolean postData) { - Boolean prevValue = this.postData; - this.postData = postData; - propertyChangeSupport.firePropertyChange(Property.POST_DATA, prevValue, queryString); - } - - public void setCookies(Boolean cookies) { - Boolean prevValue = this.cookies; - this.cookies = cookies; - propertyChangeSupport.firePropertyChange(Property.COOKIES, prevValue, queryString); - } - - public void setIpAddress(Boolean ipAddress) { - Boolean prevValue = this.ipAddress; - this.ipAddress = ipAddress; - propertyChangeSupport.firePropertyChange(Property.IP_ADDRESS, prevValue, queryString); - } - - public void setMachineName(Boolean machineName) { - Boolean prevValue = this.machineName; - this.machineName = machineName; - propertyChangeSupport.firePropertyChange(Property.MACHINE_NAME, prevValue, queryString); - } - - public void setUserName(Boolean userName) { - Boolean prevValue = this.userName; - this.userName = userName; - propertyChangeSupport.firePropertyChange(Property.USER_NAME, prevValue, queryString); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/configuration/ValueProvider.java b/src/main/java/com/exceptionless/exceptionlessclient/configuration/ValueProvider.java deleted file mode 100644 index 3715c69..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/configuration/ValueProvider.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.exceptionless.exceptionlessclient.configuration; - -import lombok.Builder; - -@Builder -public class ValueProvider { - T value; - - public static ValueProvider of(X value) { - return ValueProvider.builder().value(value).build(); - } - - public void update(T newValue) { - this.value = newValue; - } - - public T get() { - return value; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/enums/EnvironmentInfoPropertyKey.java b/src/main/java/com/exceptionless/exceptionlessclient/enums/EnvironmentInfoPropertyKey.java deleted file mode 100644 index cf5a1ee..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/enums/EnvironmentInfoPropertyKey.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.exceptionless.exceptionlessclient.enums; - -public enum EnvironmentInfoPropertyKey { - LOAD_AVG("loadavg"), - TMP_DIR("tmpdir"), - UP_TIME("uptime"), - ENDIANESS("endianess"); - - private final String value; - - EnvironmentInfoPropertyKey(String value) { - this.value = value; - } - - public String value() { - return value; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/enums/EventPropertyKey.java b/src/main/java/com/exceptionless/exceptionlessclient/enums/EventPropertyKey.java deleted file mode 100644 index 6595bda..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/enums/EventPropertyKey.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.exceptionless.exceptionlessclient.enums; - -public enum EventPropertyKey { - REF("@ref"), - USER("@user"), - USER_DESCRIPTION("@user_description"), - STACK("@stack"), - LOG_LEVEL("@level"), - ERROR("@error"), - ENVIRONMENT("@environment"), - EXTRA("@ext"), - SUBMISSION_METHOD("@submission_method"), - VERSION("@version"), - REQUEST_INFO("@request"); - - private final String value; - - EventPropertyKey(String value) { - this.value = value; - } - - public String value() { - return value; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/enums/EventTag.java b/src/main/java/com/exceptionless/exceptionlessclient/enums/EventTag.java deleted file mode 100644 index 6febb3a..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/enums/EventTag.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.exceptionless.exceptionlessclient.enums; - -public enum EventTag { - CRITICAL("Critical"); - - private final String value; - - EventTag(String value) { - this.value = value; - } - - public String value() { - return value; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/enums/EventType.java b/src/main/java/com/exceptionless/exceptionlessclient/enums/EventType.java deleted file mode 100644 index 60ac698..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/enums/EventType.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.exceptionless.exceptionlessclient.enums; - -public enum EventType { - ERROR("error"), - USAGE("usage"), - LOG("log"), - SESSION("session"), - NOT_FOUND("404"); - - private final String value; - - EventType(String value) { - this.value = value; - } - - public String value() { - return value; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/enums/ServerSettingKey.java b/src/main/java/com/exceptionless/exceptionlessclient/enums/ServerSettingKey.java deleted file mode 100644 index 080f5c3..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/enums/ServerSettingKey.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.exceptionless.exceptionlessclient.enums; - -public enum ServerSettingKey { - DATA_EXCLUSIONS("@@DataExclusions"), - USER_AGENT_BOT_PATTERNS("@@UserAgentBotPatterns"); - - private final String value; - - ServerSettingKey(String value) { - this.value = value; - } - - public String value() { - return value; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/exceptions/BadEventDataException.java b/src/main/java/com/exceptionless/exceptionlessclient/exceptions/BadEventDataException.java deleted file mode 100644 index 519691b..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/exceptions/BadEventDataException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.exceptionless.exceptionlessclient.exceptions; - -public class BadEventDataException extends RuntimeException { - public BadEventDataException(String message) { - super(message); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/exceptions/InvalidApiKeyException.java b/src/main/java/com/exceptionless/exceptionlessclient/exceptions/InvalidApiKeyException.java deleted file mode 100644 index e5f49a9..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/exceptions/InvalidApiKeyException.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.exceptionless.exceptionlessclient.exceptions; - -public class InvalidApiKeyException extends RuntimeException { - public InvalidApiKeyException(String message) { - super(message); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/logging/LogCapturerAppender.java b/src/main/java/com/exceptionless/exceptionlessclient/logging/LogCapturerAppender.java deleted file mode 100644 index b324390..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/logging/LogCapturerAppender.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.exceptionless.exceptionlessclient.logging; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.classic.spi.IThrowableProxy; -import ch.qos.logback.classic.spi.ThrowableProxy; -import ch.qos.logback.core.AppenderBase; -import lombok.Builder; - -public class LogCapturerAppender extends AppenderBase { - private final LogCapturerIF logCapturer; - - @Builder - public LogCapturerAppender(LogCapturerIF logCapturer) { - this.logCapturer = logCapturer; - } - - @Override - protected void append(ILoggingEvent loggingEvent) { - if (loggingEvent.getLevel().equals(Level.TRACE)) { - logCapturer.trace(loggingEvent.getMessage()); - } else if (loggingEvent.getLevel().equals(Level.DEBUG)) { - logCapturer.debug(loggingEvent.getMessage()); - } else if (loggingEvent.getLevel().equals(Level.INFO)) { - logCapturer.info(loggingEvent.getMessage()); - } else if (loggingEvent.getLevel().equals(Level.WARN)) { - logCapturer.warn(loggingEvent.getMessage()); - } else if (loggingEvent.getLevel().equals(Level.ERROR)) { - IThrowableProxy throwableProxy = loggingEvent.getThrowableProxy(); - if (throwableProxy instanceof ThrowableProxy) { - Throwable throwable = ((ThrowableProxy) throwableProxy).getThrowable(); - if (throwable instanceof Exception) { - logCapturer.error(loggingEvent.getMessage(), (Exception) throwable); - } else { - logCapturer.error(loggingEvent.getMessage()); - } - } else { - logCapturer.error(loggingEvent.getMessage()); - } - } - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/logging/LogCapturerIF.java b/src/main/java/com/exceptionless/exceptionlessclient/logging/LogCapturerIF.java deleted file mode 100644 index 15e20a8..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/logging/LogCapturerIF.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.exceptionless.exceptionlessclient.logging; - -public interface LogCapturerIF { - void trace(String message); - - void debug(String message); - - void info(String message); - - void warn(String message); - - void error(String message); - - void error(String message, Exception e); -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/logging/NullLogCapturer.java b/src/main/java/com/exceptionless/exceptionlessclient/logging/NullLogCapturer.java deleted file mode 100644 index df7b85a..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/logging/NullLogCapturer.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.exceptionless.exceptionlessclient.logging; - -import lombok.Builder; - -public class NullLogCapturer implements LogCapturerIF { - @Builder - public NullLogCapturer() {} - - @Override - public void trace(String message) {} - - @Override - public void debug(String message) {} - - @Override - public void info(String message) {} - - @Override - public void warn(String message) {} - - @Override - public void error(String message) {} - - @Override - public void error(String message, Exception e) {} -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/EnvironmentInfo.java b/src/main/java/com/exceptionless/exceptionlessclient/models/EnvironmentInfo.java deleted file mode 100644 index 22460c4..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/EnvironmentInfo.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.exceptionless.exceptionlessclient.models; - -import com.exceptionless.exceptionlessclient.models.base.Model; -import lombok.EqualsAndHashCode; -import lombok.Value; -import lombok.experimental.SuperBuilder; - -@SuperBuilder -@Value -@EqualsAndHashCode(callSuper = true) -public class EnvironmentInfo extends Model { - Integer processorCount; - Long totalPhysicalMemory; - Long availablePhysicalMemory; - String commandLine; - String processName; - String processId; - Long processMemorySize; - String threadId; - String architecture; - String osName; - String osVersion; - String ipAddress; - String machineName; - String installId; - String runtimeVersion; -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/Event.java b/src/main/java/com/exceptionless/exceptionlessclient/models/Event.java deleted file mode 100644 index bc16011..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/Event.java +++ /dev/null @@ -1,263 +0,0 @@ -package com.exceptionless.exceptionlessclient.models; - -import com.exceptionless.exceptionlessclient.enums.EventPropertyKey; -import com.exceptionless.exceptionlessclient.enums.EventTag; -import com.exceptionless.exceptionlessclient.models.base.Model; -import com.exceptionless.exceptionlessclient.models.error.Error; -import com.exceptionless.exceptionlessclient.queue.EventDataFilter; -import com.exceptionless.exceptionlessclient.queue.EventValidator; -import com.exceptionless.exceptionlessclient.utils.Utils; -import lombok.Builder; -import lombok.Data; -import lombok.EqualsAndHashCode; - -import java.time.OffsetDateTime; -import java.util.*; -import java.util.stream.Collectors; - -// WARNING: `SuperBuilder` will not work for any class extending this. This class breaks the chain -// for customization -@Data -@EqualsAndHashCode(callSuper = true) -public class Event extends Model { - private static final Integer DEFAULT_COUNT = 1; - - private String type; - private String source; - private OffsetDateTime date; - private Set tags; - private String message; - private String geo; - private long value; - private String referenceId; - private Long count; - - @Builder(builderClassName = "EventInternalBuilder") - public Event( - String type, - String source, - OffsetDateTime date, - Set tags, - String message, - String geo, - long value, - String referenceId, - Long count, - Map data, - Set dataExclusions) { - this.type = type; - this.source = source; - this.date = date == null ? OffsetDateTime.now() : date; - this.tags = tags == null ? new HashSet<>() : tags; - this.message = message; - this.geo = geo; - this.value = value; - this.referenceId = referenceId == null ? getDefaultReferenceId() : referenceId; - this.count = count == null ? DEFAULT_COUNT : count; - initData(data == null ? new HashMap<>() : data, dataExclusions); - } - - private String getDefaultReferenceId() { - return String.format("%s-%s", Thread.currentThread().getId(), UUID.randomUUID()); - } - - private void initData(Map data, Set dataExclusions) { - EventDataFilter eventDataFilter = EventDataFilter.builder().exclusions(dataExclusions).build(); - this.data = - data.entrySet().stream() - .collect( - Collectors.toMap( - Map.Entry::getKey, entry -> eventDataFilter.filter(entry.getValue()))); - } - - public void addData(Map data, Set dataExclusions) { - EventDataFilter filter = EventDataFilter.builder().exclusions(dataExclusions).build(); - data = - data.entrySet().stream() - .collect(Collectors.toMap(Map.Entry::getKey, entry -> filter.filter(entry.getValue()))); - this.data.putAll(data); - } - - public void addTags(String... tags) { - this.tags.addAll(Arrays.asList(tags)); - } - - public void addError(Error error) { - data.put(EventPropertyKey.ERROR.value(), error); - } - - public void addEnvironmentInfo(EnvironmentInfo environmentInfo) { - data.put(EventPropertyKey.ENVIRONMENT.value(), environmentInfo); - } - - public void addSubmissionMethod(String submissionMethod) { - data.put(EventPropertyKey.SUBMISSION_METHOD.value(), submissionMethod); - } - - public void addRequestInfo(RequestInfo requestInfo) { - data.put(EventPropertyKey.REQUEST_INFO.value(), requestInfo); - } - - public Optional getError() { - return Optional.ofNullable( - Utils.safeGetAs(data.get(EventPropertyKey.ERROR.value()), Error.class)); - } - - public Optional getEnvironmentInfo() { - return Optional.ofNullable( - Utils.safeGetAs(data.get(EventPropertyKey.ENVIRONMENT.value()), EnvironmentInfo.class)); - } - - public Optional getLogLevel() { - return Optional.ofNullable( - Utils.safeGetAs(data.get(EventPropertyKey.LOG_LEVEL.value()), String.class)); - } - - public Optional getUserInfo() { - return Optional.ofNullable( - Utils.safeGetAs(data.get(EventPropertyKey.USER.value()), UserInfo.class)); - } - - public Optional getRequestInfo() { - return Optional.ofNullable( - Utils.safeGetAs(data.get(EventPropertyKey.REQUEST_INFO.value()), RequestInfo.class)); - } - - public Optional getSubmissionMethod() { - return Optional.ofNullable( - Utils.safeGetAs(data.get(EventPropertyKey.SUBMISSION_METHOD.value()), String.class)); - } - - public static EventBuilder builder() { - return new EventBuilder(); - } - - public static final class EventBuilder extends EventInternalBuilder { - @Override - public EventBuilder referenceId(String referenceId) { - EventValidator.validateIdentifier(referenceId); - super.referenceId(referenceId); - return this; - } - - public EventBuilder eventReference(String name, String id) { - EventValidator.validateIdentifier(id); - return property(String.format("%s:%s", EventPropertyKey.REF.value(), name), id); - } - - public EventBuilder property(String name, Object value) { - if (super.data == null) { - super.data(new HashMap<>()); - } - - super.data.put(name, value); - return this; - } - - @Override - public EventBuilder type(String type) { - super.type(type); - return this; - } - - @Override - public EventBuilder source(String source) { - super.source(source); - return this; - } - - @Override - public EventBuilder date(OffsetDateTime date) { - super.date(date); - return this; - } - - @Override - public EventBuilder tags(Set tags) { - super.tags(tags); - return this; - } - - public EventBuilder tags(String... tags) { - return tags(new HashSet<>(Arrays.asList(tags))); - } - - public EventBuilder markAsCritical() { - if (super.tags == null) { - super.tags = new HashSet<>(); - } - - super.tags.add(EventTag.CRITICAL.value()); - return this; - } - - @Override - public EventBuilder message(String message) { - super.message(message); - return this; - } - - public EventBuilder geo(int latitude, int longitude) { - EventValidator.validateGeo(latitude, longitude); - return geo(String.format("%s,%s", latitude, longitude)); - } - - @Override - public EventBuilder geo(String geo) { - super.geo(geo); - return this; - } - - @Override - public EventBuilder value(long value) { - super.value(value); - return this; - } - - @Override - public EventBuilder count(Long count) { - super.count(count); - return this; - } - - @Override - public EventBuilder data(Map data) { - super.data(data); - return this; - } - - @Override - public EventBuilder dataExclusions(Set dataExclusions) { - super.dataExclusions(dataExclusions); - return this; - } - - public EventBuilder userIdentity(UserInfo userInfo) { - return property(EventPropertyKey.USER.value(), userInfo); - } - - public EventBuilder userIdentity(String identity) { - return userIdentity(UserInfo.builder().identity(identity).build()); - } - - public EventBuilder userIdentity(String identity, String name) { - return userIdentity(UserInfo.builder().identity(identity).name(name).build()); - } - - public EventBuilder userDescription(String emailAddress, String descripton) { - return property( - EventPropertyKey.USER_DESCRIPTION.value(), - UserDescription.builder().emailAddress(emailAddress).description(descripton).build()); - } - - public EventBuilder manualStackingInfo(Map signatureData, String title) { - return property( - EventPropertyKey.STACK.value(), - ManualStackingInfo.builder().title(title).signatureData(signatureData).build()); - } - - public EventBuilder manualStackingKey(String manualStackingKey, String title) { - return manualStackingInfo(Map.of("ManualStackingKey", manualStackingKey), title); - } - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/EventPluginContext.java b/src/main/java/com/exceptionless/exceptionlessclient/models/EventPluginContext.java deleted file mode 100644 index 49ecbe0..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/EventPluginContext.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.exceptionless.exceptionlessclient.models; - -import lombok.Builder; -import lombok.Value; - -@Builder -@Value -public class EventPluginContext { - Event event; - PluginContext context; - - public static EventPluginContext from(Event event) { - return EventPluginContext.builder() - .context(PluginContext.builder().build()) - .event(event) - .build(); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/ManualStackingInfo.java b/src/main/java/com/exceptionless/exceptionlessclient/models/ManualStackingInfo.java deleted file mode 100644 index 850bd32..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/ManualStackingInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.exceptionless.exceptionlessclient.models; - -import lombok.Builder; -import lombok.Value; - -import java.util.Map; - -@Builder -@Value -public class ManualStackingInfo { - String title; - Map signatureData; -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/Module.java b/src/main/java/com/exceptionless/exceptionlessclient/models/Module.java deleted file mode 100644 index dbc97f6..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/Module.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.exceptionless.exceptionlessclient.models; - -import com.exceptionless.exceptionlessclient.models.base.Model; -import lombok.EqualsAndHashCode; -import lombok.Value; -import lombok.experimental.SuperBuilder; - -import java.time.LocalDate; - -@SuperBuilder -@Value -@EqualsAndHashCode(callSuper = true) -public class Module extends Model { - Long moduleId; - String name; - String version; - Boolean isEntry; - LocalDate createdDate; - LocalDate modifiedDate; -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/PluginContext.java b/src/main/java/com/exceptionless/exceptionlessclient/models/PluginContext.java deleted file mode 100644 index 9da3d17..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/PluginContext.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.exceptionless.exceptionlessclient.models; - -import com.exceptionless.exceptionlessclient.models.base.Model; -import lombok.Builder; -import lombok.Data; -import lombok.EqualsAndHashCode; - -import java.net.http.HttpRequest; - -// WARNING: `SuperBuilder` will not work for any class extending this. This class breaks the chain -// for customization -@Data -@EqualsAndHashCode(callSuper = true) -public class PluginContext extends Model { - private boolean eventCancelled; - private Exception exception; - private Boolean unhandledError; - private String submissionMethod; - private HttpRequest request; - - @Builder - public PluginContext( - Exception exception, - Boolean unhandledError, - String submissionMethod, - HttpRequest request) { - this.exception = exception; - this.unhandledError = unhandledError; - this.submissionMethod = submissionMethod; - this.request = request; - } - - public boolean isUnhandledError() { - return unhandledError != null && unhandledError; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/RequestInfo.java b/src/main/java/com/exceptionless/exceptionlessclient/models/RequestInfo.java deleted file mode 100644 index a372036..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/RequestInfo.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.exceptionless.exceptionlessclient.models; - -import com.exceptionless.exceptionlessclient.models.base.Model; -import lombok.Builder; -import lombok.EqualsAndHashCode; -import lombok.Value; -import lombok.experimental.SuperBuilder; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -@SuperBuilder -@Value -@EqualsAndHashCode(callSuper = true) -public class RequestInfo extends Model { - String userAgent; - String httpMethod; - Boolean secure; - String host; - Integer port; - String path; - String referrer; - String clientIpAddress; - @Builder.Default Map cookies = new HashMap<>(); - Object postData; - @Builder.Default Map> queryString = new HashMap<>(); - @Builder.Default Map> headers = new HashMap<>(); - - public Boolean isSecure() { - return secure; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/UserDescription.java b/src/main/java/com/exceptionless/exceptionlessclient/models/UserDescription.java deleted file mode 100644 index d30aa16..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/UserDescription.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.exceptionless.exceptionlessclient.models; - -import com.exceptionless.exceptionlessclient.models.base.Model; -import lombok.EqualsAndHashCode; -import lombok.Value; -import lombok.experimental.SuperBuilder; - -@SuperBuilder -@Value -@EqualsAndHashCode(callSuper = true) -public class UserDescription extends Model { - String emailAddress; - String description; -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/UserInfo.java b/src/main/java/com/exceptionless/exceptionlessclient/models/UserInfo.java deleted file mode 100644 index 54a33c4..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/UserInfo.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.exceptionless.exceptionlessclient.models; - -import com.exceptionless.exceptionlessclient.models.base.Model; -import lombok.EqualsAndHashCode; -import lombok.Value; -import lombok.experimental.SuperBuilder; - -@SuperBuilder -@Value -@EqualsAndHashCode(callSuper = true) -public class UserInfo extends Model { - String identity; - String name; -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/base/Model.java b/src/main/java/com/exceptionless/exceptionlessclient/models/base/Model.java deleted file mode 100644 index ef4958b..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/base/Model.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.exceptionless.exceptionlessclient.models.base; - -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.Singular; -import lombok.experimental.SuperBuilder; - -import java.util.Map; - -@SuperBuilder -@NoArgsConstructor // So that `@Data` classes can extend this and we can call `super()` from normal - // `@Builder` classes -@Data -public abstract class Model { - @Singular("property") - protected Map data; -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/error/Error.java b/src/main/java/com/exceptionless/exceptionlessclient/models/error/Error.java deleted file mode 100644 index 409da90..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/error/Error.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.exceptionless.exceptionlessclient.models.error; - -import com.exceptionless.exceptionlessclient.models.Module; -import lombok.Builder; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.experimental.SuperBuilder; - -import java.util.ArrayList; -import java.util.List; - -@SuperBuilder -@Data -@EqualsAndHashCode(callSuper = true) -public class Error extends InnerError { - @Builder.Default private List modules = new ArrayList<>(); -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/error/InnerError.java b/src/main/java/com/exceptionless/exceptionlessclient/models/error/InnerError.java deleted file mode 100644 index 42a4d5f..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/error/InnerError.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.exceptionless.exceptionlessclient.models.error; - -import com.exceptionless.exceptionlessclient.models.base.Model; -import lombok.EqualsAndHashCode; -import lombok.Value; -import lombok.experimental.NonFinal; -import lombok.experimental.SuperBuilder; - -import java.util.List; - -@SuperBuilder -@Value -@NonFinal -@EqualsAndHashCode(callSuper = true) -public class InnerError extends Model { - String message; - String type; - String code; - InnerError inner; - List stackTrace; - Method targetMethod; -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/error/Method.java b/src/main/java/com/exceptionless/exceptionlessclient/models/error/Method.java deleted file mode 100644 index e5495cf..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/error/Method.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.exceptionless.exceptionlessclient.models.error; - -import com.exceptionless.exceptionlessclient.models.base.Model; -import lombok.EqualsAndHashCode; -import lombok.Value; -import lombok.experimental.NonFinal; -import lombok.experimental.SuperBuilder; - -import java.util.List; - -@SuperBuilder -@Value -@NonFinal -@EqualsAndHashCode(callSuper = true) -public class Method extends Model { - List genericArguments; - List parameters; - boolean isSignatureTarget; - String declaringNamespace; - String declaringType; - String name; - long moduleId; -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/error/Parameter.java b/src/main/java/com/exceptionless/exceptionlessclient/models/error/Parameter.java deleted file mode 100644 index 8bb57fd..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/error/Parameter.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.exceptionless.exceptionlessclient.models.error; - -import com.exceptionless.exceptionlessclient.models.base.Model; -import lombok.EqualsAndHashCode; -import lombok.Value; -import lombok.experimental.SuperBuilder; - -import java.util.List; - -@SuperBuilder -@Value -@EqualsAndHashCode(callSuper = true) -public class Parameter extends Model { - List genericArguments; - String name; - String type; - String typeNamespace; -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/error/StackFrame.java b/src/main/java/com/exceptionless/exceptionlessclient/models/error/StackFrame.java deleted file mode 100644 index b570314..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/error/StackFrame.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.exceptionless.exceptionlessclient.models.error; - -import lombok.EqualsAndHashCode; -import lombok.Value; -import lombok.experimental.SuperBuilder; - -@SuperBuilder -@Value -@EqualsAndHashCode(callSuper = true) -public class StackFrame extends Method { - String filename; - int lineNumber; - int column; -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/models/storage/StorageItem.java b/src/main/java/com/exceptionless/exceptionlessclient/models/storage/StorageItem.java deleted file mode 100644 index 77c5192..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/models/storage/StorageItem.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.exceptionless.exceptionlessclient.models.storage; - -import lombok.Builder; -import lombok.Value; - -@Builder -@Value -public class StorageItem { - long timestamp; - X value; -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/EventPluginIF.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/EventPluginIF.java deleted file mode 100644 index d4378ef..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/EventPluginIF.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; - -public interface EventPluginIF { - int getPriority(); - - default String getName() { - return getClass().getName(); - } - - void run(EventPluginContext eventPluginContext, Configuration configuration); -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/EventPluginRunner.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/EventPluginRunner.java deleted file mode 100644 index 462a50a..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/EventPluginRunner.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import lombok.Builder; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class EventPluginRunner { - private final Configuration configuration; - - @Builder - public EventPluginRunner(Configuration configuration) { - this.configuration = configuration; - } - - public void run(EventPluginContext eventPluginContext) { - configuration - .getPlugins() - .forEach( - plugin -> { - if (eventPluginContext.getContext().isEventCancelled()) { - return; - } - - try { - plugin.run(eventPluginContext, configuration); - - } catch (Exception e) { - log.error( - String.format( - "Error running plugin: %s: %s. Discarding event", - plugin.getName(), e.getMessage()), - e); - eventPluginContext.getContext().setEventCancelled(true); - } - }); - - if (eventPluginContext.getContext().isEventCancelled()) { - log.info( - String.format( - "Event cancelled during plugin runs; Not submitting: %s", - eventPluginContext.getEvent().getReferenceId())); - return; - } - - configuration.getQueue().enqueue(eventPluginContext.getEvent()); - configuration - .getLastReferenceIdManager() - .setLast(eventPluginContext.getEvent().getReferenceId()); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/MergedEvent.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/MergedEvent.java deleted file mode 100644 index f58900d..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/MergedEvent.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins; - -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.queue.EventQueueIF; -import lombok.Builder; -import lombok.Data; - -import java.time.OffsetDateTime; - -@Data -public class MergedEvent { - private Long hash; - private Event event; - private EventQueueIF eventQueue; - - @Builder - public MergedEvent(Long hash, Event event, EventQueueIF eventQueue) { - this.hash = hash; - this.event = event; - this.eventQueue = eventQueue; - } - - public void incrementCount(long count) { - event.setCount(event.getCount() + count); - } - - public void updateDate(OffsetDateTime date) { - if (date.isAfter(event.getDate())) { - event.setDate(date); - } - } - - public void resubmit() { - eventQueue.enqueue(event); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/PluginManager.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/PluginManager.java deleted file mode 100644 index c7ec939..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/PluginManager.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.plugins.preconfigured.*; -import lombok.Builder; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; -import java.util.function.BiConsumer; -import java.util.stream.Collectors; - -@Slf4j -public class PluginManager { - private static final Integer DEFAULT_PLUGIN_PRIORITY = 0; - - @Getter private List plugins; - - @Builder - public PluginManager() { - this.plugins = new ArrayList<>(); - configureDefaultPlugins(); - sortPlugins(); - } - - private void configureDefaultPlugins() { - addPlugin(ConfigurationDefaultsPlugin.builder().build()); - addPlugin(ErrorPlugin.builder().build()); - addPlugin(DuplicateCheckerPlugin.builder().build()); - addPlugin(EventExclusionPlugin.builder().build()); - addPlugin(ModuleInfoPlugin.builder().build()); - addPlugin(RequestInfoPlugin.builder().build()); - addPlugin(EnvironmentInfoPlugin.builder().build()); - addPlugin(SubmissionMethodPlugin.builder().build()); - } - - public void addPlugin(EventPluginIF eventPlugin) { - if (plugins.stream().anyMatch(plugin -> plugin.getName().equals(eventPlugin.getName()))) { - log.info( - String.format( - "Can't add plugin, name: %s, priority: %s as a plugin with this name already configured", - eventPlugin.getName(), eventPlugin.getPriority())); - return; - } - plugins.add(eventPlugin); - sortPlugins(); - } - - private void sortPlugins() { - plugins.sort((o1, o2) -> o2.getPriority() - o1.getPriority()); - } - - public void addPlugin(BiConsumer pluginAction) { - addPlugin(UUID.randomUUID().toString(), DEFAULT_PLUGIN_PRIORITY, pluginAction); - } - - public void addPlugin( - String name, - int priority, - BiConsumer pluginAction) { - addPlugin( - new EventPluginIF() { - @Override - public int getPriority() { - return priority; - } - - @Override - public String getName() { - return name; - } - - @Override - public void run( - EventPluginContext eventPluginContext, Configuration configuration) { - pluginAction.accept(eventPluginContext, configuration); - } - }); - } - - public void removePlugin(String name) { - plugins = - plugins.stream() - .filter(eventPlugin -> !eventPlugin.getName().equals(name)) - .collect(Collectors.toList()); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ConfigurationDefaultsPlugin.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ConfigurationDefaultsPlugin.java deleted file mode 100644 index ba7f9de..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ConfigurationDefaultsPlugin.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.plugins.EventPluginIF; -import lombok.Builder; - -public class ConfigurationDefaultsPlugin implements EventPluginIF { - private static final Integer DEFAULT_PRIORITY = 10; - - @Builder - public ConfigurationDefaultsPlugin() {} - - @Override - public int getPriority() { - return DEFAULT_PRIORITY; - } - - @Override - public void run( - EventPluginContext eventPluginContext, Configuration configuration) { - Event event = eventPluginContext.getEvent(); - for (String tag : configuration.getDefaultTags()) { - event.addTags(tag); - } - - event.addData(configuration.getDefaultData(), configuration.getDataExclusions()); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/DuplicateCheckerPlugin.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/DuplicateCheckerPlugin.java deleted file mode 100644 index 4bab2aa..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/DuplicateCheckerPlugin.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.plugins.EventPluginIF; -import com.exceptionless.exceptionlessclient.plugins.MergedEvent; -import lombok.Builder; -import lombok.Getter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.*; - -public class DuplicateCheckerPlugin implements EventPluginIF { - private static final Logger LOG = LoggerFactory.getLogger(DuplicateCheckerPlugin.class); - private static final String MERGED_EVENTS_RESUBMISSION_TIMER_NAME = - "merged-events-resubmission-timer"; - private static final Integer DEFAULT_PRIORITY = 1010; - private static final Integer DEFAULT_MAX_HASHES_COUNT = 50; - private static final Integer DEFAULT_MERGED_EVENTS_RESUBMISSION_IN_SECS = 30; - - private final int maxHashesCount; - private final Queue mergedEvents; - private final Timer mergedEventsResubmissionTimer; - private final List hashes; - private final Integer mergedEventsResubmissionInSecs; - - @Builder - public DuplicateCheckerPlugin(Integer mergedEventsResubmissionInSecs, Integer maxHashesCount) { - this.maxHashesCount = maxHashesCount == null ? DEFAULT_MAX_HASHES_COUNT : maxHashesCount; - this.mergedEvents = new ArrayDeque<>(); - this.mergedEventsResubmissionTimer = new Timer(MERGED_EVENTS_RESUBMISSION_TIMER_NAME); - this.hashes = new ArrayList<>(); - this.mergedEventsResubmissionInSecs = - mergedEventsResubmissionInSecs == null - ? DEFAULT_MERGED_EVENTS_RESUBMISSION_IN_SECS - : mergedEventsResubmissionInSecs; - init(); - } - - private void init() { - mergedEventsResubmissionTimer.schedule( - new TimerTask() { - @Override - public void run() { - try { - MergedEvent event = mergedEvents.poll(); - if (event != null) { - event.resubmit(); - } - } catch (Exception e) { - LOG.error("Error in resubmitting merged events", e); - } - } - }, - mergedEventsResubmissionInSecs * 1000, - mergedEventsResubmissionInSecs * 1000); - } - - @Override - public int getPriority() { - return DEFAULT_PRIORITY; - } - - @Override - public void run( - EventPluginContext eventPluginContext, Configuration configuration) { - Event event = eventPluginContext.getEvent(); - long hash = getHash(event); - Optional maybeMergedEvent = - mergedEvents.stream().filter(mergedEvent -> mergedEvent.getHash() == hash).findFirst(); - if (maybeMergedEvent.isPresent()) { - MergedEvent mergedEvent = maybeMergedEvent.get(); - mergedEvent.incrementCount(event.getCount()); - mergedEvent.updateDate(event.getDate()); - LOG.info(String.format("Ignoring duplicate event with hash: %s", hash)); - eventPluginContext.getContext().setEventCancelled(true); - return; - } - - long now = System.currentTimeMillis(); - // All the merged events of one hash are supposed to be processed by the timer every - // `mergedEventsResubmissionInSecs` seconds - if (hashes.stream() - .anyMatch( - timeStampedHash -> - timeStampedHash.getHash() == hash - && timeStampedHash.getTimestamp() - >= (now - mergedEventsResubmissionInSecs * 1000))) { - LOG.trace(String.format("Adding event with hash :%s", hash)); - mergedEvents.add( - MergedEvent.builder() - .event(event) - .eventQueue(configuration.getQueue()) - .hash(hash) - .build()); - eventPluginContext.getContext().setEventCancelled(true); - return; - } - - addNewHashIfPossible(hash, now); - } - - private void addNewHashIfPossible(long hash, long now) { - if (hashes.size() == maxHashesCount) { - return; - } - - hashes.add(TimeStampedHash.builder().hash(hash).timestamp(now).build()); - } - - private long getHash(Event event) { - return Objects.hash( - event.getType(), - event.getSource(), - event.getDate(), - event.getTags(), - event.getMessage(), - event.getData(), - event.getGeo(), - event.getValue()); - } - - @Builder - @Getter - private static class TimeStampedHash { - private final long hash; - private final long timestamp; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EnvironmentInfoPlugin.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EnvironmentInfoPlugin.java deleted file mode 100644 index d775515..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EnvironmentInfoPlugin.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.enums.EnvironmentInfoPropertyKey; -import com.exceptionless.exceptionlessclient.models.EnvironmentInfo; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.plugins.EventPluginIF; -import com.exceptionless.exceptionlessclient.plugins.preconfigured.args.EnvironmentInfoGetArgs; -import com.sun.management.OperatingSystemMXBean; -import lombok.Builder; -import lombok.extern.slf4j.Slf4j; - -import java.lang.management.ManagementFactory; -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.nio.ByteOrder; -import java.util.HashMap; -import java.util.Map; - -@Slf4j -public class EnvironmentInfoPlugin implements EventPluginIF { - private static final Integer DEFAULT_PRIORITY = 80; - - private EnvironmentInfo defaultEnvironmentInfo; - - @Builder - public EnvironmentInfoPlugin() { - initDefaultEnvironmentInfo(); - } - - private void initDefaultEnvironmentInfo() { - OperatingSystemMXBean operatingSystemMXBean = - (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean(); - this.defaultEnvironmentInfo = - EnvironmentInfo.builder() - .processorCount(Runtime.getRuntime().availableProcessors()) - .totalPhysicalMemory(operatingSystemMXBean.getTotalPhysicalMemorySize()) - .osName(operatingSystemMXBean.getName()) - .architecture(operatingSystemMXBean.getArch()) - .osVersion(operatingSystemMXBean.getVersion()) - .runtimeVersion(System.getProperty("java.version")) - .processName(String.valueOf(ProcessHandle.current().pid())) - .processName(ManagementFactory.getRuntimeMXBean().getName()) - .commandLine(ProcessHandle.current().info().commandLine().orElse(null)) - .build(); - } - - @Override - public int getPriority() { - return DEFAULT_PRIORITY; - } - - @Override - public void run( - EventPluginContext eventPluginContext, Configuration configuration) { - Event event = eventPluginContext.getEvent(); - if (event.getEnvironmentInfo().isEmpty()) { - event.addEnvironmentInfo( - getEnvironmentInfo( - EnvironmentInfoGetArgs.builder() - .includeIpAddress( - configuration.getPrivateInformationInclusions().getIpAddress()) - .includeMachineName( - configuration.getPrivateInformationInclusions().getMachineName()) - .build())); - } - } - - private EnvironmentInfo getEnvironmentInfo(EnvironmentInfoGetArgs args) { - OperatingSystemMXBean operatingSystemMXBean = - (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean(); - - EnvironmentInfo.EnvironmentInfoBuilder builder = - EnvironmentInfo.builder() - .processorCount(defaultEnvironmentInfo.getProcessorCount()) - .totalPhysicalMemory(defaultEnvironmentInfo.getTotalPhysicalMemory()) - .availablePhysicalMemory(operatingSystemMXBean.getFreePhysicalMemorySize()) - .commandLine(defaultEnvironmentInfo.getCommandLine()) - .processName(defaultEnvironmentInfo.getProcessName()) - .processId(defaultEnvironmentInfo.getProcessId()) - .processMemorySize(ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed()) - .threadId(String.valueOf(Thread.currentThread().getId())) - .architecture(defaultEnvironmentInfo.getArchitecture()) - .osName(defaultEnvironmentInfo.getOsName()) - .osVersion(defaultEnvironmentInfo.getOsVersion()) - .runtimeVersion(defaultEnvironmentInfo.getRuntimeVersion()) - .data(getData()); - - try { - InetAddress localhost = InetAddress.getLocalHost(); - if (args.isIncludeMachineName()) { - builder.machineName(localhost.getHostName()); - } - if (args.isIncludeIpAddress()) { - builder.ipAddress(localhost.getHostAddress()); - } - } catch (UnknownHostException e) { - log.error("Error while getting machine name", e); - } - - return builder.build(); - } - - private Map getData() { - Map data = new HashMap<>(); - OperatingSystemMXBean operatingSystemMXBean = - (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean(); - data.put( - EnvironmentInfoPropertyKey.LOAD_AVG.value(), - String.valueOf(operatingSystemMXBean.getSystemLoadAverage())); - data.put(EnvironmentInfoPropertyKey.TMP_DIR.value(), System.getProperty("java.io.tmpdir")); - data.put( - EnvironmentInfoPropertyKey.UP_TIME.value(), - String.valueOf(ManagementFactory.getRuntimeMXBean().getUptime())); - data.put(EnvironmentInfoPropertyKey.ENDIANESS.value(), getEndianess()); - - return data; - } - - private String getEndianess() { - return ByteOrder.nativeOrder().equals(ByteOrder.BIG_ENDIAN) ? "Big-endian" : "Little-endian"; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ErrorPlugin.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ErrorPlugin.java deleted file mode 100644 index 4d585cc..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ErrorPlugin.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.enums.EventPropertyKey; -import com.exceptionless.exceptionlessclient.enums.EventType; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.error.Error; -import com.exceptionless.exceptionlessclient.models.error.StackFrame; -import com.exceptionless.exceptionlessclient.plugins.EventPluginIF; -import lombok.Builder; - -import java.util.*; -import java.util.stream.Collectors; - -public class ErrorPlugin implements EventPluginIF { - private static final Integer DEFAULT_PRIORITY = 30; - - @Builder - public ErrorPlugin() {} - - @Override - public int getPriority() { - return DEFAULT_PRIORITY; - } - - @Override - public void run( - EventPluginContext eventPluginContext, Configuration configuration) { - Exception exception = eventPluginContext.getContext().getException(); - if (exception == null) { - return; - } - - Event event = eventPluginContext.getEvent(); - event.setType(EventType.ERROR.value()); - if (event.getError().isPresent()) { - return; - } - - event.addError(parse(exception)); - - Set dataExclusions = new HashSet<>(configuration.getDataExclusions()); - event.addData(Map.of(EventPropertyKey.EXTRA.value(), exception), dataExclusions); - } - - private Error parse(Exception exception) { - return Error.builder() - .type(exception.getClass().getCanonicalName()) - .message(exception.getMessage()) - .stackTrace(getStackFrames(exception)) - .build(); - } - - private List getStackFrames(Exception exception) { - return Arrays.stream(exception.getStackTrace()) - .map( - stackTraceElement -> - StackFrame.builder() - .name(stackTraceElement.getMethodName()) - .filename(stackTraceElement.getFileName()) - .lineNumber(stackTraceElement.getLineNumber()) - .declaringType(stackTraceElement.getClassName()) - .build()) - .collect(Collectors.toList()); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EventExclusionPlugin.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EventExclusionPlugin.java deleted file mode 100644 index 851be50..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EventExclusionPlugin.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.enums.EventType; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.error.Error; -import com.exceptionless.exceptionlessclient.plugins.EventPluginIF; -import com.exceptionless.exceptionlessclient.settings.ServerSettings; -import lombok.Builder; -import lombok.extern.slf4j.Slf4j; - -import java.util.Optional; -import java.util.OptionalInt; - -@Slf4j -public class EventExclusionPlugin implements EventPluginIF { - private static final Integer DEFAULT_PRIORITY = 0; - - @Builder - public EventExclusionPlugin() { - } - - @Override - public int getPriority() { - return DEFAULT_PRIORITY; - } - - @Override - public void run( - EventPluginContext eventPluginContext, Configuration configuration) { - Event event = eventPluginContext.getEvent(); - ServerSettings serverSettings = - configuration.getSettingsManager().getSavedServerSettings(); - - if (event.getType().equals(EventType.LOG.value())) { - handleLogEvent(eventPluginContext, serverSettings); - } else if (event.getType().equals(EventType.ERROR.value())) { - handleErrorEvent(eventPluginContext, serverSettings); - } else { - Optional maybeSetting = - serverSettings.getTypeAndSourceSetting(event.getType(), event.getSource()); - if (maybeSetting.isEmpty() || ServerSettings.getAsBoolean(maybeSetting.get())) { - return; - } - log.info( - String.format( - "Cancelling event from excluded type: %s and source :%s", - event.getType(), event.getSource())); - eventPluginContext.getContext().setEventCancelled(true); - } - } - - private void handleLogEvent( - EventPluginContext eventPluginContext, ServerSettings serverSettings) { - Event event = eventPluginContext.getEvent(); - Optional maybeLogSetting = - serverSettings.getTypeAndSourceSetting(EventType.LOG.value(), event.getSource()); - if (maybeLogSetting.isEmpty()) { - return; - } - Optional maybeLogLevel = event.getLogLevel(); - if (maybeLogLevel.isEmpty()) { - return; - } - - OptionalInt maybeMinLogPriority = getLogPriority(maybeLogSetting.get()); - OptionalInt maybeLogPriority = getLogPriority(maybeLogLevel.get()); - if (maybeLogPriority.isEmpty() || maybeMinLogPriority.isEmpty()) { - return; - } - - if (maybeLogPriority.getAsInt() >= maybeMinLogPriority.getAsInt()) { - return; - } - - eventPluginContext.getContext().setEventCancelled(true); - } - - private OptionalInt getLogPriority(String level) { - switch (level) { - case "trace": - case "true": - case "1": - case "yes": - return OptionalInt.of(0); - case "debug": - return OptionalInt.of(1); - case "info": - return OptionalInt.of(2); - case "warn": - return OptionalInt.of(3); - case "error": - return OptionalInt.of(4); - case "fatal": - return OptionalInt.of(5); - case "off": - case "false": - case "0": - case "no": - return OptionalInt.of(6); - default: - return OptionalInt.empty(); - } - } - - private void handleErrorEvent( - EventPluginContext eventPluginContext, ServerSettings serverSettings) { - Optional maybeError = eventPluginContext.getEvent().getError(); - if (maybeError.isEmpty()) { - return; - } - Error error = maybeError.get(); - - while (error != null) { - Optional maybeSetting = - serverSettings.getTypeAndSourceSetting(EventType.ERROR.value(), error.getType()); - if (maybeSetting.isPresent() && !ServerSettings.getAsBoolean(maybeSetting.get())) { - log.info( - String.format("Cancelling error from excluded exception type: %s", error.getType())); - eventPluginContext.getContext().setEventCancelled(true); - break; - } - error = (Error) error.getInner(); - } - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/HeartbeatPlugin.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/HeartbeatPlugin.java deleted file mode 100644 index d1f5137..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/HeartbeatPlugin.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.UserInfo; -import com.exceptionless.exceptionlessclient.plugins.EventPluginIF; -import lombok.Builder; -import lombok.extern.slf4j.Slf4j; - -import java.util.Optional; -import java.util.Timer; -import java.util.TimerTask; - -@Slf4j -public class HeartbeatPlugin implements EventPluginIF { - private static final String HEART_BEAT_TIMER_NAME = "heart-beat-timer"; - private static final Integer DEFAULT_PRIORITY = 100; - private static final Integer DEFAULT_HEART_BEAT_INTERVAL_IN_SECS = 1; - - private final int heartbeatIntervalInSecs; - private Timer heartbeatTimer; - private String prevIdentity; - - @Builder - public HeartbeatPlugin(Integer heartbeatIntervalInSecs) { - this.heartbeatIntervalInSecs = - heartbeatIntervalInSecs == null - ? DEFAULT_HEART_BEAT_INTERVAL_IN_SECS - : heartbeatIntervalInSecs; - this.heartbeatTimer = new Timer(HEART_BEAT_TIMER_NAME); - } - - @Override - public int getPriority() { - return DEFAULT_PRIORITY; - } - - @Override - public void run( - EventPluginContext eventPluginContext, Configuration configuration) { - Optional maybeUserInfo = eventPluginContext.getEvent().getUserInfo(); - if (maybeUserInfo.isEmpty()) { - return; - } - if (maybeUserInfo.get().getIdentity().equals(prevIdentity)) { - return; - } - - prevIdentity = maybeUserInfo.get().getIdentity(); - // reset if identity is changed - resetHeartbeatTimer(); - heartbeatTimer.schedule( - new TimerTask() { - @Override - public void run() { - try { - configuration.submitSessionHeartbeat(prevIdentity); - } catch (Exception e) { - log.error( - String.format("Error in submitting hearbeat for identity: %s", prevIdentity), e); - } - } - }, - heartbeatIntervalInSecs * 1000L, - heartbeatIntervalInSecs * 1000L); - } - - private void resetHeartbeatTimer() { - heartbeatTimer.cancel(); - heartbeatTimer = new Timer(HEART_BEAT_TIMER_NAME); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ModuleInfoPlugin.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ModuleInfoPlugin.java deleted file mode 100644 index 1fa8bb9..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ModuleInfoPlugin.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.Module; -import com.exceptionless.exceptionlessclient.models.error.Error; -import com.exceptionless.exceptionlessclient.plugins.EventPluginIF; -import lombok.Builder; - -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; - -public class ModuleInfoPlugin implements EventPluginIF { - private static final Integer DEFAULT_PRIORITY = 50; - - private final List modules; - - @Builder - public ModuleInfoPlugin() { - this.modules = - ModuleLayer.boot().modules().stream() - .map(module -> Module.builder().name(module.getName()).build()) - .collect(Collectors.toList()); - } - - @Override - public int getPriority() { - return DEFAULT_PRIORITY; - } - - @Override - public void run( - EventPluginContext eventPluginContext, Configuration configuration) { - Optional maybeError = eventPluginContext.getEvent().getError(); - if (maybeError.isEmpty()) { - return; - } - Error error = maybeError.get(); - - if (!error.getModules().isEmpty()) { - return; - } - - error.setModules(modules); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/RequestInfoPlugin.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/RequestInfoPlugin.java deleted file mode 100644 index ca5d960..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/RequestInfoPlugin.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.RequestInfo; -import com.exceptionless.exceptionlessclient.plugins.EventPluginIF; -import com.exceptionless.exceptionlessclient.plugins.preconfigured.args.RequestInfoGetArgs; -import com.exceptionless.exceptionlessclient.utils.Utils; -import lombok.Builder; -import lombok.extern.slf4j.Slf4j; - -import java.net.InetAddress; -import java.net.URI; -import java.net.UnknownHostException; -import java.net.http.HttpRequest; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -@Slf4j -public class RequestInfoPlugin implements EventPluginIF { - private static final Integer DEFAULT_PRIORITY = 70; - private static final Set IGNORED_HEADERS = - Set.of( - "Authorization", - "Cookie", - "Host", - "Method", - "Path", - "Proxy-Authorization", - "Referer", - "User-Agent"); - - @Builder - public RequestInfoPlugin() {} - - @Override - public int getPriority() { - return DEFAULT_PRIORITY; - } - - @Override - public void run(EventPluginContext eventPluginContext, Configuration configuration) { - Event event = eventPluginContext.getEvent(); - if (event.getRequestInfo().isPresent()) { - return; - } - HttpRequest request = eventPluginContext.getContext().getRequest(); - if (request == null) { - return; - } - RequestInfo requestInfo = - getRequestInfo( - request, - RequestInfoGetArgs.builder() - .exclusions(configuration.getDataExclusions()) - .includeCookies(configuration.getPrivateInformationInclusions().getCookies()) - .includeIpAddress(configuration.getPrivateInformationInclusions().getIpAddress()) - .includePostData(configuration.getPrivateInformationInclusions().getPostData()) - .includeQueryString( - configuration.getPrivateInformationInclusions().getQueryString()) - .build()); - - if (configuration.getUserAgentBotPatterns().stream() - .anyMatch(pattern -> Utils.match(requestInfo.getUserAgent(), pattern))) { - log.info("Cancelling event as the request user agent matches a known bot pattern"); - eventPluginContext.getContext().setEventCancelled(true); - return; - } - - event.addRequestInfo(requestInfo); - } - - private RequestInfo getRequestInfo(HttpRequest request, RequestInfoGetArgs args) { - RequestInfo.RequestInfoBuilder builder = - RequestInfo.builder() - .userAgent(request.headers().firstValue("User-Agent").orElse(null)) - .secure(isSecure(request.uri())) - .httpMethod(request.method()) - .host(request.uri().getHost()) - .path(request.uri().getPath()) - .port(request.uri().getPort()) - .headers(getHeaders(request, args)); - - if (args.isIncludeIpAddress()) { - try { - InetAddress address = InetAddress.getByName(request.uri().getHost()); - builder.clientIpAddress(address.getHostAddress()); - } catch (UnknownHostException e) { - log.error( - String.format("Error while getting ip-address for host: %s", request.uri().getHost())); - } - } - - if (args.isIncludeCookies()) { - builder.cookies(filterExclusions(Utils.getCookies(request), args.getExclusions())); - } - - if (args.isIncludeQueryString()) { - builder.queryString( - filterExclusions(Utils.getQueryParams(request.uri()), args.getExclusions())); - } - - return builder.build(); - } - - private Map> getHeaders(HttpRequest request, RequestInfoGetArgs args) { - Map> headers = - filterExclusions(request.headers().map(), args.getExclusions()); - return headers.entrySet().stream() - .filter(entry -> !IGNORED_HEADERS.contains(entry.getKey())) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - } - - private Map filterExclusions(Map map, Set exclusions) { - return map.entrySet().stream() - .filter( - entry -> - exclusions.stream().noneMatch(exclusion -> Utils.match(entry.getKey(), exclusion))) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - } - - private boolean isSecure(URI uri) { - return uri.getScheme().contains("https"); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/SubmissionMethodPlugin.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/SubmissionMethodPlugin.java deleted file mode 100644 index a4bcce2..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/SubmissionMethodPlugin.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.plugins.EventPluginIF; -import lombok.Builder; - -public class SubmissionMethodPlugin implements EventPluginIF { - private static final Integer DEFAULT_PRIORITY = 100; - - @Builder - public SubmissionMethodPlugin() {} - - @Override - public int getPriority() { - return DEFAULT_PRIORITY; - } - - @Override - public void run( - EventPluginContext eventPluginContext, Configuration configuration) { - String submissionMethod = eventPluginContext.getContext().getSubmissionMethod(); - if (submissionMethod == null) { - return; - } - - eventPluginContext.getEvent().addSubmissionMethod(submissionMethod); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/args/EnvironmentInfoGetArgs.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/args/EnvironmentInfoGetArgs.java deleted file mode 100644 index a9ff278..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/args/EnvironmentInfoGetArgs.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured.args; - -import lombok.Builder; -import lombok.Value; - -@Builder -@Value -public class EnvironmentInfoGetArgs { - @Builder.Default Boolean includeMachineName = false; - @Builder.Default Boolean includeIpAddress = false; - - public Boolean isIncludeMachineName() { - return includeIpAddress; - } - - public Boolean isIncludeIpAddress() { - return includeIpAddress; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/args/RequestInfoGetArgs.java b/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/args/RequestInfoGetArgs.java deleted file mode 100644 index 041fd57..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/args/RequestInfoGetArgs.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured.args; - -import lombok.Builder; -import lombok.Value; - -import java.util.HashSet; -import java.util.Set; - -@Builder -@Value -public class RequestInfoGetArgs { - @Builder.Default Boolean includeIpAddress = false; - @Builder.Default Boolean includeCookies = false; - @Builder.Default Boolean includeQueryString = false; - @Builder.Default Boolean includePostData = false; - @Builder.Default Set exclusions = new HashSet<>(); - - public Boolean isIncludeIpAddress() { - return includeIpAddress; - } - - public Boolean isIncludeCookies() { - return includeCookies; - } - - public Boolean isIncludeQueryString() { - return includeQueryString; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/queue/DefaultEventQueue.java b/src/main/java/com/exceptionless/exceptionlessclient/queue/DefaultEventQueue.java deleted file mode 100644 index e736652..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/queue/DefaultEventQueue.java +++ /dev/null @@ -1,271 +0,0 @@ -package com.exceptionless.exceptionlessclient.queue; - -import com.exceptionless.exceptionlessclient.configuration.ValueProvider; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.storage.StorageItem; -import com.exceptionless.exceptionlessclient.storage.StorageProviderIF; -import com.exceptionless.exceptionlessclient.submission.SubmissionClientIF; -import com.exceptionless.exceptionlessclient.submission.SubmissionResponse; -import com.exceptionless.exceptionlessclient.utils.VisibleForTesting; -import lombok.Builder; -import lombok.extern.slf4j.Slf4j; - -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Timer; -import java.util.TimerTask; -import java.util.function.BiConsumer; -import java.util.stream.Collectors; - -@Slf4j -public class DefaultEventQueue implements EventQueueIF { - private static final String QUEUE_TIMER_NAME = "queue-timer"; - private static final Integer DEFAULT_PROCESSING_INTERVAL_IN_SECS = 10; - private static final Double BATCH_SIZE_DIVISOR = 1.5; - private static final Integer DEFAULT_SUSPENSION_DURATION_IN_MINS = 5; - - private final StorageProviderIF storageProvider; - private final ValueProvider submissionBatchSize; - private final SubmissionClientIF submissionClient; - private LocalDateTime discardQueueItemsUntil; - private LocalDateTime suspendProcessingUntil; - private boolean processingQueue; - private final Timer queueTimer; - private int currentSubmissionBatchSize; - private final List, SubmissionResponse>> handlers; - - @Builder - public DefaultEventQueue( - StorageProviderIF storageProvider, - ValueProvider submissionBatchSize, - SubmissionClientIF submissionClient, - Integer processingIntervalInSecs) { - this.storageProvider = storageProvider; - this.submissionBatchSize = submissionBatchSize; - this.submissionClient = submissionClient; - this.queueTimer = new Timer(QUEUE_TIMER_NAME); - this.handlers = new ArrayList<>(); - this.currentSubmissionBatchSize = submissionBatchSize.get(); - init( - processingIntervalInSecs == null - ? DEFAULT_PROCESSING_INTERVAL_IN_SECS - : processingIntervalInSecs); - } - - private void init(Integer processingIntervalInSecs) { - queueTimer.schedule( - new TimerTask() { - @Override - public void run() { - try { - onProcessQueue(); - } catch (Exception e) { - log.error("Error in processing queue", e); - } - } - }, - processingIntervalInSecs * 1000, - processingIntervalInSecs * 1000); - } - - private void onProcessQueue() { - if (processingQueue || shouldSuspendProcessing()) { - return; - } - - process(); - } - - private boolean shouldSuspendProcessing() { - if (suspendProcessingUntil == null) { - return false; - } - - return LocalDateTime.now().isBefore(suspendProcessingUntil); - } - - @VisibleForTesting - Boolean isProcessingCurrentlySuspended() { - return shouldSuspendProcessing(); - } - - @Override - public void enqueue(Event event) { - if (shouldDiscard()) { - log.info("Queue items are currently being discarded. This event will not be enqueued"); - return; - } - - long timestamp = storageProvider.getQueue().save(event); - log.info( - String.format( - "Enqueueing event: %s type: %s, refId: %s", - timestamp, event.getType(), event.getReferenceId())); - } - - private boolean shouldDiscard() { - if (discardQueueItemsUntil == null) { - return false; - } - - return LocalDateTime.now().isBefore(discardQueueItemsUntil); - } - - @Override - public void process() { - synchronized (this) { - if (processingQueue) { - log.trace("Currently processing queue; Returning..."); - return; - } - processingQueue = true; - } - - try { - List> storedEvents = - storageProvider.getQueue().get(currentSubmissionBatchSize); - if (storedEvents.isEmpty()) { - log.trace("No events found to submit; Returning..."); - return; - } - - List events = - storedEvents.stream().map(StorageItem::getValue).collect(Collectors.toList()); - log.info(String.format("Sending %s events", events.size())); - SubmissionResponse response = submissionClient.postEvents(events); - if (response.hasException()) { - log.error("Error submitting events from queue", response.getException()); - suspendProcessing(); - return; - } - processSubmissionResponse(response, storedEvents); - eventPosted(response, events); - } finally { - synchronized (this) { - processingQueue = false; - } - } - } - - private void processSubmissionResponse( - SubmissionResponse response, List> storedEvents) { - if (response.isSuccess()) { - log.info(String.format("Sent %s events", storedEvents.size())); - setBatchSizeToConfigured(); - removeEvents(storedEvents); - return; - } - - if (response.isRateLimited()) { - log.error( - "Service is rate limited because of either you have exceeded your rate limit or server is under stress."); - suspendProcessing(); - return; - } - - if (response.isServiceUnavailable()) { - log.error("Service returns service unavailable"); - suspendProcessing(); - return; - } - - if (response.isPaymentRequired()) { - log.info("Too many events have been submitted, please upgrade your plan"); - suspendProcessingForNoPayment(); - return; - } - - if (response.unableToAuthenticate()) { - log.info( - "Unable to authenticate, please check your configuration. Events will not be submitted"); - suspendProcessing(Duration.ofMinutes(15)); - removeEvents(storedEvents); - return; - } - - if (response.isNotFound() || response.isBadRequest()) { - log.error( - String.format( - "Error while trying to submit data, Code:%s, Body:%s", - response.getCode(), response.getBody())); - suspendProcessing(Duration.ofMinutes(4)); - removeEvents(storedEvents); - return; - } - - if (response.isRequestEntityTooLarge()) { - if (currentSubmissionBatchSize > 1) { - log.error( - "Event submission discarded for being too large. Retrying with smaller batch size"); - currentSubmissionBatchSize = - Math.max(1, (int) Math.round(currentSubmissionBatchSize / BATCH_SIZE_DIVISOR)); - } else { - log.error("Event submission discarded for being too large. Events will not be submitted"); - removeEvents(storedEvents); - } - return; - } - - log.error( - String.format( - "Error submitting events, Code: %s, Body: %s", response.getCode(), response.getBody())); - suspendProcessing(); - } - - private void setBatchSizeToConfigured() { - currentSubmissionBatchSize = submissionBatchSize.get(); - } - - private void removeEvents(List> storedEvents) { - for (StorageItem storedEvent : storedEvents) { - storageProvider.getQueue().remove(storedEvent.getTimestamp()); - } - } - - private void eventPosted(SubmissionResponse response, List events) { - for (BiConsumer, SubmissionResponse> handler : handlers) { - try { - handler.accept(events, response); - } catch (Exception e) { - log.error("Error while processing an event submission handler", e); - } - } - } - - private void suspendProcessing() { - suspendProcessing(null, false, false); - } - - private void suspendProcessingForNoPayment() { - suspendProcessing(null, true, true); - } - - private void suspendProcessing(Duration duration) { - suspendProcessing(duration, false, false); - } - - @Override - public void suspendProcessing( - Duration duration, boolean discardFutureQueueItems, boolean clearQueue) { - if (duration == null) { - duration = Duration.ofMinutes(DEFAULT_SUSPENSION_DURATION_IN_MINS); - } - - log.info(String.format("Suspending processing for %s", duration)); - suspendProcessingUntil = LocalDateTime.now().plus(duration); - if (discardFutureQueueItems) { - discardQueueItemsUntil = suspendProcessingUntil; - } - - if (clearQueue) { - storageProvider.getQueue().clear(); - } - } - - @Override - public void onEventsPosted(BiConsumer, SubmissionResponse> handler) { - handlers.add(handler); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/queue/EventDataFilter.java b/src/main/java/com/exceptionless/exceptionlessclient/queue/EventDataFilter.java deleted file mode 100644 index 907cdd3..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/queue/EventDataFilter.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.exceptionless.exceptionlessclient.queue; - -import com.exceptionless.exceptionlessclient.utils.Utils; -import lombok.Builder; - -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -public class EventDataFilter { - private static final Integer DEFAULT_MAX_DEPTH = 3; - private final Set exclusions; - private final int maxDepth; - - @Builder - public EventDataFilter(Set exclusions, Integer maxDepth) { - this.exclusions = exclusions == null ? new HashSet<>() : exclusions; - this.maxDepth = maxDepth == null ? DEFAULT_MAX_DEPTH : maxDepth; - } - - public Object filter(Object data) { - if (exclusions.isEmpty()) { - return data; - } - - return filter(data, 1); - } - - private Object filter(Object data, int currDepth) { - if (shouldNotFilter(data, currDepth)) { - return data; - } - - // Object other than JSON primitive - if (!(data instanceof Map || data instanceof List)) { - data = Utils.JSON_MAPPER.convertValue(data, Map.class); - } - - if (data instanceof List) { - List dataList = (List) data; - return dataList.stream().map(val -> filter(val, currDepth)).collect(Collectors.toList()); - } - - return ((Map) data) - .entrySet().stream() - .filter( - entry -> - entry.getValue() != null - && exclusions.stream() - .noneMatch(exclusion -> Utils.match(entry.getKey(), exclusion))) - .collect( - Collectors.toMap( - Map.Entry::getKey, entry -> filter(entry.getValue(), currDepth + 1))); - } - - private boolean shouldNotFilter(Object data, int currentDepth) { - return currentDepth > maxDepth - || data == null - || (data instanceof Number) - || (data instanceof String) - || (data instanceof Boolean); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/queue/EventQueueIF.java b/src/main/java/com/exceptionless/exceptionlessclient/queue/EventQueueIF.java deleted file mode 100644 index 7b7843b..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/queue/EventQueueIF.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.exceptionless.exceptionlessclient.queue; - -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.submission.SubmissionResponse; - -import java.time.Duration; -import java.util.List; -import java.util.function.BiConsumer; - -public interface EventQueueIF { - void enqueue(Event event); - - void process(); - - void suspendProcessing(Duration duration, boolean discardFutureQueueItems, boolean clearQueue); - - void onEventsPosted(BiConsumer, SubmissionResponse> handler); -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/queue/EventValidator.java b/src/main/java/com/exceptionless/exceptionlessclient/queue/EventValidator.java deleted file mode 100644 index d54585c..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/queue/EventValidator.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.exceptionless.exceptionlessclient.queue; - -import com.exceptionless.exceptionlessclient.exceptions.BadEventDataException; - -public final class EventValidator { - private static final int VALID_IDENTIFIER_MIN_LENGTH = 8; - private static final int VALID_IDENTIFIER_MAX_LENGTH = 100; - - private EventValidator() {} - - public static void validateIdentifier(String value) { - if (value.length() < VALID_IDENTIFIER_MIN_LENGTH - || value.length() > VALID_IDENTIFIER_MAX_LENGTH) { - throw new BadEventDataException( - String.format("Value must contain between 8 and 100 characters, found: [%s]", value)); - } - - if (!value.chars().allMatch(ch -> Character.isLetterOrDigit(ch) || ch == '-')) { - throw new BadEventDataException( - String.format( - "Value should contain only alphanumeric characters and hyphen sign, found: [%s]", - value)); - } - } - - public static void validateGeo(int latitude, int longitude) { - if (latitude < -90 || latitude > 90) { - throw new BadEventDataException( - String.format("Latitude should be between -90 and 90, found: [%s]", latitude)); - } - - if (longitude < -180 || longitude > 180) { - throw new BadEventDataException( - String.format("Longitude should be between -180 and 180, found: [%s]", longitude)); - } - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/services/DefaultLastReferenceIdManager.java b/src/main/java/com/exceptionless/exceptionlessclient/services/DefaultLastReferenceIdManager.java deleted file mode 100644 index e9e1c45..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/services/DefaultLastReferenceIdManager.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.exceptionless.exceptionlessclient.services; - -import lombok.Builder; - -public class DefaultLastReferenceIdManager implements LastReferenceIdManagerIF { - private String lastReferencedId; - - @Builder - public DefaultLastReferenceIdManager() {} - - @Override - public String getLast() { - return lastReferencedId; - } - - @Override - public void clearLast() { - lastReferencedId = null; - } - - @Override - public void setLast(String eventId) { - lastReferencedId = eventId; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/services/LastReferenceIdManagerIF.java b/src/main/java/com/exceptionless/exceptionlessclient/services/LastReferenceIdManagerIF.java deleted file mode 100644 index 786787b..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/services/LastReferenceIdManagerIF.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.exceptionless.exceptionlessclient.services; - -public interface LastReferenceIdManagerIF { - String getLast(); - void clearLast(); - void setLast(String eventId); -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/settings/DefaultSettingsClient.java b/src/main/java/com/exceptionless/exceptionlessclient/settings/DefaultSettingsClient.java deleted file mode 100644 index 1b507f0..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/settings/DefaultSettingsClient.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.exceptionless.exceptionlessclient.settings; - -import com.exceptionless.exceptionlessclient.configuration.ValueProvider; -import com.exceptionless.exceptionlessclient.utils.Utils; -import com.exceptionless.exceptionlessclient.utils.VisibleForTesting; -import com.fasterxml.jackson.core.type.TypeReference; -import lombok.Builder; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; - -import java.time.Duration; - -public class DefaultSettingsClient implements SettingsClientIF { - private final OkHttpClient defaultHttpClient; - private final ValueProvider settingsClientTimeoutInMillis; - private final ValueProvider configServerUrl; - private final ValueProvider apiKey; - - @Builder - public DefaultSettingsClient( - ValueProvider settingsClientTimeoutInMillis, - ValueProvider configServerUrl, - ValueProvider apiKey) { - this( - new OkHttpClient().newBuilder().build(), - settingsClientTimeoutInMillis, - configServerUrl, - apiKey); - } - - @VisibleForTesting - DefaultSettingsClient( - OkHttpClient defaultHttpClient, - ValueProvider settingsClientTimeoutInMillis, - ValueProvider configServerUrl, - ValueProvider apiKey) { - this.defaultHttpClient = defaultHttpClient; - this.settingsClientTimeoutInMillis = settingsClientTimeoutInMillis; - this.configServerUrl = configServerUrl; - this.apiKey = apiKey; - } - - @Override - public SettingsResponse getSettings(long version) { - try { - Request request = - new Request.Builder() - .url( - String.format( - "%s/api/v2/projects/config?v=%s&access_token=%s", - configServerUrl.get(), version, apiKey.get())) - .get() - .build(); - - Response response = - defaultHttpClient - .newBuilder() - .connectTimeout(Duration.ofMillis(settingsClientTimeoutInMillis.get())) - .build() - .newCall(request) - .execute(); - - ResponseBody body = response.body(); - String bodyStr = body == null ? null : body.string(); - if (bodyStr == null) { - return SettingsResponse.builder().code(response.code()).body("").build(); - } - if (!response.isSuccessful()) { - return SettingsResponse.builder().code(response.code()).body(bodyStr).build(); - } - - ServerSettings serverSettings = - Utils.JSON_MAPPER.readValue(bodyStr, new TypeReference() {}); - return SettingsResponse.builder() - .code(response.code()) - .body(bodyStr) - .settings(serverSettings) - .build(); - } catch (Exception e) { - return SettingsResponse.builder().exception(e).build(); - } - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/settings/ServerSettings.java b/src/main/java/com/exceptionless/exceptionlessclient/settings/ServerSettings.java deleted file mode 100644 index ce161e2..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/settings/ServerSettings.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.exceptionless.exceptionlessclient.settings; - -import com.exceptionless.exceptionlessclient.enums.ServerSettingKey; -import com.exceptionless.exceptionlessclient.utils.Utils; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; -import lombok.Builder; -import lombok.Value; - -import java.util.*; -import java.util.stream.Collectors; - -@JsonDeserialize(builder = ServerSettings.ServerSettingsBuilder.class) -@Builder(builderClassName = "ServerSettingsBuilder") -@Value -public class ServerSettings { - Long version; - @Builder.Default Map settings = new HashMap<>(); - - // Required for Jackson to work with Lombok Immutable(@Value + @Builder) - @JsonPOJOBuilder(withPrefix = "") - public static class ServerSettingsBuilder {} - - public Optional getTypeAndSourceSetting(String type, String source) { - String prefix = String.format("@@%s:", type); - String value = settings.get(String.format("%s%s", prefix, source)); - if (value != null) { - return Optional.of(value); - } - - List keys = new ArrayList<>(settings.keySet()); - // Sort keys longest first, then alphabetically - keys.sort( - (o1, o2) -> o1.length() != o2.length() ? o2.length() - o1.length() : o1.compareTo(o2)); - for (String key : keys) { - if (!key.startsWith(prefix)) { - continue; - } - if (Utils.match(source, key.substring(prefix.length()))) { - return Optional.of(settings.get(key)); - } - } - - return Optional.empty(); - } - - public Set getDataExclusions() { - if (!settings.containsKey(ServerSettingKey.DATA_EXCLUSIONS.value())) { - return new HashSet<>(); - } - - return Arrays.stream(settings.get(ServerSettingKey.DATA_EXCLUSIONS.value()).split(",")) - .collect(Collectors.toSet()); - } - - public Set getUserAgentBotPatterns() { - if (!settings.containsKey(ServerSettingKey.USER_AGENT_BOT_PATTERNS.value())) { - return new HashSet<>(); - } - - return Arrays.stream(settings.get(ServerSettingKey.USER_AGENT_BOT_PATTERNS.value()).split(",")) - .collect(Collectors.toSet()); - } - - public static boolean getAsBoolean(String setting) { - switch (setting) { - case "true": - case "yes": - case "1": - return true; - default: - return false; - } - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/settings/SettingsClientIF.java b/src/main/java/com/exceptionless/exceptionlessclient/settings/SettingsClientIF.java deleted file mode 100644 index 0a2050e..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/settings/SettingsClientIF.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.exceptionless.exceptionlessclient.settings; - - -public interface SettingsClientIF { - SettingsResponse getSettings(long version); -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/settings/SettingsManager.java b/src/main/java/com/exceptionless/exceptionlessclient/settings/SettingsManager.java deleted file mode 100644 index 508dd57..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/settings/SettingsManager.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.exceptionless.exceptionlessclient.settings; - -import com.exceptionless.exceptionlessclient.models.storage.StorageItem; -import com.exceptionless.exceptionlessclient.storage.StorageProviderIF; -import lombok.Builder; -import lombok.extern.slf4j.Slf4j; - -import java.beans.PropertyChangeListener; -import java.beans.PropertyChangeSupport; -import java.util.Map; - -@Slf4j -public class SettingsManager { - private static final long DEFAULT_VERSION = 0; - - public static class Property { - public static final String SETTINGS = "settings"; - } - - private final StorageProviderIF storageProvider; - private final SettingsClientIF settingsClient; - private Boolean updatingSettings; - private final PropertyChangeSupport propertyChangeSupport; - - @Builder - public SettingsManager(StorageProviderIF storageProvider, SettingsClientIF settingsClient) { - this.storageProvider = storageProvider; - this.settingsClient = settingsClient; - this.propertyChangeSupport = new PropertyChangeSupport(this); - this.updatingSettings = false; - } - - public void addPropertyChangeListener(PropertyChangeListener listener) { - propertyChangeSupport.addPropertyChangeListener(listener); - } - - public void checkVersion(long version) { - long currentVersion = getVersion(); - if (version <= currentVersion) { - return; - } - - log.info(String.format("Updating settings from v%s to v%s", currentVersion, version)); - updateSettings(); - } - - private long getVersion() { - return getSavedServerSettings().getVersion(); - } - - public ServerSettings getSavedServerSettings() { - StorageItem storageItem = storageProvider.getSettings().peek(); - if (storageItem == null) { - return ServerSettings.builder().version(DEFAULT_VERSION).settings(Map.of()).build(); - } - - return storageItem.getValue(); - } - - public void updateSettings() { - synchronized (this) { - if (updatingSettings) { - log.trace("Already updating settings; Returning..."); - return; - } - updatingSettings = true; - } - - try { - long currentVersion = getVersion(); - log.debug(String.format("Checking for updated settings from v%s", currentVersion)); - - SettingsResponse response = settingsClient.getSettings(currentVersion); - if (shouldNotUpdate(response)) { - return; - } - - ServerSettings prevValue = getSavedServerSettings(); - storageProvider.getSettings().save(response.getSettings()); - propertyChangeSupport.firePropertyChange( - Property.SETTINGS, prevValue, response.getSettings()); - } finally { - synchronized (this) { - updatingSettings = false; - } - } - } - - private boolean shouldNotUpdate(SettingsResponse response) { - if (response.hasException()) { - log.error( - String.format("Error retrieving settings for v%s", getVersion()), - response.getException()); - return true; - } - if (response.isNotModified()) { - log.debug("No need to update, settings are not modified"); - return true; - } - if (!response.isSuccess()) { - log.warn( - String.format( - "Unable to update settings, body: %s, code: %s", - response.getBody(), response.getCode())); - return true; - } - if (response.getSettings() == null) { - log.warn("Not settings returned by server!"); - return true; - } - - return false; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/settings/SettingsResponse.java b/src/main/java/com/exceptionless/exceptionlessclient/settings/SettingsResponse.java deleted file mode 100644 index 536de9b..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/settings/SettingsResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.exceptionless.exceptionlessclient.settings; - -import lombok.Builder; -import lombok.Value; - -@Builder -@Value -public class SettingsResponse { - int code; - String body; - ServerSettings settings; - Exception exception; - - public boolean isSuccess() { - return code >= 200 && code <= 299; - } - - public boolean isNotModified() { - return code == 304; - } - - public boolean hasException() { - return exception != null; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorage.java b/src/main/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorage.java deleted file mode 100644 index 9b3c147..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorage.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.exceptionless.exceptionlessclient.storage; - -import com.exceptionless.exceptionlessclient.models.storage.StorageItem; -import lombok.Builder; - -import java.util.ArrayList; -import java.util.LinkedList; -import java.util.List; - -public class InMemoryStorage implements StorageIF { - private static final Integer DEFAULT_MAX_ITEMS = 50; - - private final Integer maxItems; - // Used linked list because we are always appending at end and removing is fast than `ArrayList` - private List> items; - private long lastTimestamp; - - @Builder - public InMemoryStorage(Integer maxItems) { - this.maxItems = maxItems == null ? DEFAULT_MAX_ITEMS : maxItems; - items = new LinkedList<>(); - } - - @Override - public long save(X value) { - long timestamp = Math.max(lastTimestamp, System.currentTimeMillis()); - items.add(StorageItem.builder().value(value).timestamp(timestamp).build()); - if (items.size() > maxItems) { - items.remove(0); - } - - return lastTimestamp = timestamp; - } - - @Override - public StorageItem peek() { - return !items.isEmpty() ? items.get(0) : null; - } - - @Override - public List> get(int limit) { - return new ArrayList<>(items.subList(0, Math.min(limit, items.size()))); - } - - @Override - public void remove(long timestamp) { - for (int i = 0; i < items.size(); i++) { - if (items.get(i).getTimestamp() == timestamp) { - items.remove(i); - return; - } - } - } - - @Override - public void clear() { - items = new ArrayList<>(); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorageProvider.java b/src/main/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorageProvider.java deleted file mode 100644 index 54606f7..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorageProvider.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.exceptionless.exceptionlessclient.storage; - -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.settings.ServerSettings; -import lombok.Builder; - -public class InMemoryStorageProvider implements StorageProviderIF { - private static final Integer DEFAULT_MAX_QUEUE_ITEMS = 250; - - private final StorageIF eventQueue; - private final StorageIF settingsStore; - - @Builder - private InMemoryStorageProvider(Integer maxQueueItems) { - this.eventQueue = - InMemoryStorage.builder() - .maxItems(maxQueueItems == null ? DEFAULT_MAX_QUEUE_ITEMS : maxQueueItems) - .build(); - this.settingsStore = InMemoryStorage.builder().maxItems(1).build(); - } - - @Override - public StorageIF getQueue() { - return eventQueue; - } - - @Override - public StorageIF getSettings() { - return settingsStore; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/storage/StorageIF.java b/src/main/java/com/exceptionless/exceptionlessclient/storage/StorageIF.java deleted file mode 100644 index 5de6811..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/storage/StorageIF.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.exceptionless.exceptionlessclient.storage; - -import com.exceptionless.exceptionlessclient.models.storage.StorageItem; - -import java.util.List; - -public interface StorageIF { - long save(X value); - - StorageItem peek(); - - List> get(int limit); - - void remove(long timestamp); - - void clear(); -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/storage/StorageProviderIF.java b/src/main/java/com/exceptionless/exceptionlessclient/storage/StorageProviderIF.java deleted file mode 100644 index 0ecd916..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/storage/StorageProviderIF.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.exceptionless.exceptionlessclient.storage; - - -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.settings.ServerSettings; - -public interface StorageProviderIF { - StorageIF getQueue(); - StorageIF getSettings(); -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/submission/DefaultSubmissionClient.java b/src/main/java/com/exceptionless/exceptionlessclient/submission/DefaultSubmissionClient.java deleted file mode 100644 index d8ae6f2..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/submission/DefaultSubmissionClient.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.exceptionless.exceptionlessclient.submission; - -import com.exceptionless.exceptionlessclient.configuration.ValueProvider; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.UserDescription; -import com.exceptionless.exceptionlessclient.settings.SettingsManager; -import com.exceptionless.exceptionlessclient.utils.Utils; -import com.exceptionless.exceptionlessclient.utils.VisibleForTesting; -import lombok.Builder; -import lombok.extern.slf4j.Slf4j; -import okhttp3.*; - -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.List; -import java.util.stream.Collectors; - -@Slf4j -public class DefaultSubmissionClient implements SubmissionClientIF { - private static final String CONFIGURATION_VERSION_HEADER = "x-exceptionless-configversion"; - private static final String RATE_LIMITING_HEADER = "x-ratelimit-remaining"; - - private final SettingsManager settingsManager; - private final OkHttpClient defaultHttpClient; - private final ValueProvider submissionClientTimeoutInMillis; - private final ValueProvider serverUrl; - private final ValueProvider apiKey; - private final ValueProvider heartbeatServerUrl; - - @Builder - public DefaultSubmissionClient( - SettingsManager settingsManager, - ValueProvider submissionClientTimeoutInMillis, - ValueProvider serverUrl, - ValueProvider apiKey, - ValueProvider heartbeatServerUrl) { - this( - new OkHttpClient().newBuilder().build(), - settingsManager, - submissionClientTimeoutInMillis, - serverUrl, - apiKey, - heartbeatServerUrl); - } - - @VisibleForTesting - DefaultSubmissionClient( - OkHttpClient defaultHttpClient, - SettingsManager settingsManager, - ValueProvider submissionClientTimeoutInMillis, - ValueProvider serverUrl, - ValueProvider apiKey, - ValueProvider heartbeatServerUrl) { - this.settingsManager = settingsManager; - this.defaultHttpClient = defaultHttpClient; - this.submissionClientTimeoutInMillis = submissionClientTimeoutInMillis; - this.serverUrl = serverUrl; - this.apiKey = apiKey; - this.heartbeatServerUrl = heartbeatServerUrl; - } - - @Override - public SubmissionResponse postEvents(List events) { - return postSubmission( - String.format("%s/api/v2/events?access_token=%s", serverUrl.get(), apiKey.get()), - events.stream().map(SubmissionMapper::toRequest).collect(Collectors.toList())); - } - - @Override - public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { - return postSubmission( - String.format( - "%s/api/v2/events/by-ref/%s/user-description?access_token=%s", - serverUrl.get(), referenceId, apiKey.get()), - SubmissionMapper.toRequest(description)); - } - - private SubmissionResponse postSubmission(String url, Object data) { - try { - String requestJSON = Utils.JSON_MAPPER.writeValueAsString(data); - Request request = - new Request.Builder() - .url(url) - .post(RequestBody.create(requestJSON, MediaType.parse("application/json"))) - .build(); - - Response response = - defaultHttpClient - .newBuilder() - .connectTimeout(Duration.ofMillis(submissionClientTimeoutInMillis.get())) - .build() - .newCall(request) - .execute(); - - if (response.isSuccessful()) { - updateSettingsFromHeaders(response); - } - - ResponseBody body = response.body(); - return SubmissionResponse.builder() - .code(response.code()) - .body(body == null ? "" : body.string()) - .rateLimitingHeaderFound(isRateLimitingHeaderFound(response)) - .build(); - } catch (Exception e) { - return SubmissionResponse.builder().exception(e).build(); - } - } - - private void updateSettingsFromHeaders(Response response) { - String settingsVersion = response.headers().get(CONFIGURATION_VERSION_HEADER); - if (settingsVersion != null) { - settingsManager.checkVersion(Long.parseLong(settingsVersion)); - } else { - log.error("No config version header was returned"); - } - } - - private boolean isRateLimitingHeaderFound(Response response) { - return response.headers().get(RATE_LIMITING_HEADER) != null; - } - - @Override - public void sendHeartBeat(String sessionIdOrUserId, boolean closeSession) { - try { - Request request = - new Request.Builder() - .url( - String.format( - "%s/api/v2/events/session/heartbeat?id=%s&close=%s&access_token=%s", - heartbeatServerUrl.get(), - URLEncoder.encode(sessionIdOrUserId, StandardCharsets.UTF_8), - closeSession, - apiKey.get())) - .get() - .build(); - - Response response = - defaultHttpClient - .newBuilder() - .connectTimeout(Duration.ofMillis(submissionClientTimeoutInMillis.get())) - .build() - .newCall(request) - .execute(); - - if (!response.isSuccessful()) { - log.error( - String.format( - "Error in submitting heartbeat to the server for sessionOrUserId: %s", - sessionIdOrUserId)); - } - } catch (Exception e) { - log.error("Error while submitting heartbeat", e); - } - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/submission/EventRequest.java b/src/main/java/com/exceptionless/exceptionlessclient/submission/EventRequest.java deleted file mode 100644 index ce4554a..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/submission/EventRequest.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.exceptionless.exceptionlessclient.submission; - -import lombok.Builder; -import lombok.Value; - -import java.time.OffsetDateTime; -import java.util.Map; -import java.util.Set; - -@Value -@Builder -public class EventRequest { - String type; - String source; - OffsetDateTime date; - Set tags; - String message; - String geo; - String referenceId; - long value; - Long count; - Map data; -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/submission/SubmissionClientIF.java b/src/main/java/com/exceptionless/exceptionlessclient/submission/SubmissionClientIF.java deleted file mode 100644 index 11256ab..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/submission/SubmissionClientIF.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.exceptionless.exceptionlessclient.submission; - -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.UserDescription; - -import java.util.List; - -public interface SubmissionClientIF { - SubmissionResponse postEvents( - List events); - - SubmissionResponse postUserDescription( - String referenceId, - UserDescription description); - - void sendHeartBeat(String sessionIdOrUserId, boolean closeSession); -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/submission/SubmissionMapper.java b/src/main/java/com/exceptionless/exceptionlessclient/submission/SubmissionMapper.java deleted file mode 100644 index 3b0802a..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/submission/SubmissionMapper.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.exceptionless.exceptionlessclient.submission; - -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.UserDescription; - -public final class SubmissionMapper { - private SubmissionMapper() {} - - public static UserDescriptionRequest toRequest(UserDescription userDescription) { - return UserDescriptionRequest.builder() - .description(userDescription.getDescription()) - .emailAddress(userDescription.getEmailAddress()) - .data(userDescription.getData()) - .build(); - } - - public static EventRequest toRequest(Event event) { - return EventRequest.builder() - .type(event.getType()) - .source(event.getSource()) - .date(event.getDate()) - .tags(event.getTags()) - .message(event.getMessage()) - .geo(event.getGeo()) - .referenceId(event.getReferenceId()) - .value(event.getValue()) - .count(event.getCount()) - .data(event.getData()) - .build(); - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/submission/SubmissionResponse.java b/src/main/java/com/exceptionless/exceptionlessclient/submission/SubmissionResponse.java deleted file mode 100644 index f678703..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/submission/SubmissionResponse.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.exceptionless.exceptionlessclient.submission; - -import lombok.Builder; -import lombok.Value; - -@Builder -@Value -public class SubmissionResponse { - int code; - String body; - boolean rateLimitingHeaderFound; - Exception exception; - - public boolean isSuccess() { - return code >= 200 && code <= 299; - } - - public boolean isBadRequest() { - return code == 400; - } - - public boolean isServiceUnavailable() { - return code == 503; - } - - public boolean isPaymentRequired() { - return code == 402; - } - - public boolean unableToAuthenticate() { - return code == 401 || code == 403; - } - - public boolean isNotFound() { - return code == 404; - } - - public boolean isRequestEntityTooLarge() { - return code == 413; - } - - public boolean isRateLimited() { - return rateLimitingHeaderFound || code == 429; - } - - public boolean hasException() { - return exception != null; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/submission/UserDescriptionRequest.java b/src/main/java/com/exceptionless/exceptionlessclient/submission/UserDescriptionRequest.java deleted file mode 100644 index 81b7cfb..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/submission/UserDescriptionRequest.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.exceptionless.exceptionlessclient.submission; - -import lombok.Builder; -import lombok.Value; - -import java.util.Map; - -@Value -@Builder -public class UserDescriptionRequest { - String emailAddress; - String description; - Map data; -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/utils/Utils.java b/src/main/java/com/exceptionless/exceptionlessclient/utils/Utils.java deleted file mode 100644 index 677b728..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/utils/Utils.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.exceptionless.exceptionlessclient.utils; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.net.URI; -import java.net.http.HttpRequest; -import java.util.*; - -public final class Utils { - public static final ObjectMapper JSON_MAPPER; - - static { - JSON_MAPPER = new ObjectMapper(); - JSON_MAPPER.registerModule(new JavaTimeModule()); - JSON_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - JSON_MAPPER.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); - } - - private static final Logger LOG = LoggerFactory.getLogger(Utils.class); - - private Utils() {} - - public static T safeGetAs(Object value, Class cls) { - if (value == null) { - return null; - } - - try { - return cls.cast(value); - } catch (ClassCastException ignored) { - return null; - } - } - - public static Map getCookies(HttpRequest request) { - Optional maybeRawCookie = request.headers().firstValue("Cookie"); - if (maybeRawCookie.isEmpty()) { - return new HashMap<>(); - } - - Map cookies = new HashMap<>(); - String[] rawCookieParams = maybeRawCookie.get().split(";"); - for (String rawCookieNameAndValue : rawCookieParams) { - String[] rawCookieNameAndValuePair = rawCookieNameAndValue.split("="); - cookies.put(rawCookieNameAndValuePair[0], rawCookieNameAndValuePair[1]); - } - - return cookies; - } - - public static Map> getQueryParams(URI uri) { - String query = uri.getQuery(); - if (query == null || query.isBlank()) { - return Map.of(); - } - - String[] rawQueryParams = query.split("&"); - Map> queryParams = new HashMap<>(); - for (String queryParamsNameAndValue : rawQueryParams) { - String[] queryParamsNameAndValuePair = queryParamsNameAndValue.split("="); - if (!queryParams.containsKey(queryParamsNameAndValuePair[0])) { - queryParams.put(queryParamsNameAndValuePair[0], new ArrayList<>()); - } - - queryParams.get(queryParamsNameAndValuePair[0]).add(queryParamsNameAndValuePair[1]); - } - - return queryParams; - } - - public static boolean match(String value, String pattern) { - if (value == null) { - return false; - } - if (pattern == null) { - return false; - } - // todo check this works or not; - boolean result = value.toLowerCase().matches(pattern); - - if (result) { - LOG.trace(String.format("Value [%s] matches pattern [%s]", value, pattern)); - return true; - } - return false; - } -} diff --git a/src/main/java/com/exceptionless/exceptionlessclient/utils/VisibleForTesting.java b/src/main/java/com/exceptionless/exceptionlessclient/utils/VisibleForTesting.java deleted file mode 100644 index 97f3c48..0000000 --- a/src/main/java/com/exceptionless/exceptionlessclient/utils/VisibleForTesting.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.exceptionless.exceptionlessclient.utils; - -public @interface VisibleForTesting {} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/ExceptionlessClientTest.java b/src/test/java/com/exceptionless/exceptionlessclient/ExceptionlessClientTest.java deleted file mode 100644 index 74a18c7..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/ExceptionlessClientTest.java +++ /dev/null @@ -1,304 +0,0 @@ -package com.exceptionless.exceptionlessclient; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.enums.EventPropertyKey; -import com.exceptionless.exceptionlessclient.enums.EventType; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.UserDescription; -import com.exceptionless.exceptionlessclient.queue.DefaultEventQueue; -import com.exceptionless.exceptionlessclient.settings.DefaultSettingsClient; -import com.exceptionless.exceptionlessclient.settings.ServerSettings; -import com.exceptionless.exceptionlessclient.settings.SettingsResponse; -import com.exceptionless.exceptionlessclient.storage.InMemoryStorage; -import com.exceptionless.exceptionlessclient.storage.InMemoryStorageProvider; -import com.exceptionless.exceptionlessclient.submission.DefaultSubmissionClient; -import com.exceptionless.exceptionlessclient.submission.SubmissionResponse; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.LocalDate; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -public class ExceptionlessClientTest { - @Mock private DefaultSettingsClient settingsClient; - @Mock private DefaultSubmissionClient submissionClient; - @Mock private InMemoryStorageProvider storageProvider; - @Mock private DefaultEventQueue eventQueue; - private Configuration configuration; - private ExceptionlessClient client; - private InMemoryStorage settingsStorage; - - @BeforeEach - public void setup() { - settingsStorage = InMemoryStorage.builder().build(); - configuration = - TestFixtures.aDefaultConfigurationManager() - // We don't want the timer to run by default - .updateSettingsWhenIdleInterval(3600L) - .settingsClient(settingsClient) - .storageProvider(storageProvider) - .submissionClient(submissionClient) - .queue(eventQueue) - .build(); - client = new ExceptionlessClient(configuration, 1000, 10); - } - - @Test - public void itCanSetupASettingsChangeTimer() throws InterruptedException { - doReturn(settingsStorage).when(storageProvider).getSettings(); - settingsStorage.save(ServerSettings.builder().version(3L).build()); - doReturn(SettingsResponse.builder().body("test-message").code(400).build()) - .when(settingsClient) - .getSettings(anyLong()); - - Thread.sleep(1500); - verify(settingsClient, times(1)).getSettings(3L); - } - - @Test - public void itCanSubmitAnExceptionEvent() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - client.submitException(new RuntimeException("test")); - - verify(eventQueue, times(1)) - .enqueue( - argThat( - event -> - event.getType().equals(EventType.ERROR.value()) - && event.getReferenceId() != null - && event.getDate().toLocalDate().equals(LocalDate.now()) - && event.getError().isPresent() - && event.getEnvironmentInfo().isPresent() - && event.getData().containsKey(EventPropertyKey.EXTRA.value()))); - assertThat(configuration.getLastReferenceIdManager().getLast()).isNotNull(); - } - - @Test - public void itCanCreateAnExceptionEvent() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - Event event = client.createError().build(); - - assertThat(event.getType()).isEqualTo(EventType.ERROR.value()); - assertThat(event.getDate()).isNotNull(); - } - - @Test - public void itCanSubmitUnhandledExceptionEvent() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - client.submitUnhandledException(new RuntimeException("test"), "test-submission-method"); - - verify(eventQueue, times(1)) - .enqueue( - argThat( - event -> - event.getType().equals(EventType.ERROR.value()) - && event.getDate().toLocalDate().equals(LocalDate.now()) - && event.getReferenceId() != null - && event.getError().isPresent() - && event.getEnvironmentInfo().isPresent() - && event.getData().containsKey(EventPropertyKey.EXTRA.value()) - && event.getSubmissionMethod().isPresent() - && event.getSubmissionMethod().get().equals("test-submission-method"))); - assertThat(configuration.getLastReferenceIdManager().getLast()).isNotNull(); - } - - @Test - public void itCanSubmitFeatureUsageEvent() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - client.submitFeatureUsage("test-feature"); - - verify(eventQueue, times(1)) - .enqueue( - argThat( - event -> - event.getType().equals(EventType.USAGE.value()) - && event.getSource().equals("test-feature") - && event.getReferenceId() != null - && event.getDate().toLocalDate().equals(LocalDate.now()) - && event.getEnvironmentInfo().isPresent())); - assertThat(configuration.getLastReferenceIdManager().getLast()).isNotNull(); - } - - @Test - public void itCanCreateFeatureUsageEvent() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - Event event = client.createFeatureUsage("test-feature").build(); - - assertThat(event.getType()).isEqualTo(EventType.USAGE.value()); - assertThat(event.getSource()).isEqualTo("test-feature"); - assertThat(event.getDate()).isNotNull(); - } - - @Test - public void itCanSubmitALogEvent() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - client.submitLog("test-log-message", "test-log-source", "test-log-level"); - - verify(eventQueue, times(1)) - .enqueue( - argThat( - event -> - event.getType().equals(EventType.LOG.value()) - && event.getSource().equals("test-log-source") - && event.getMessage().equals("test-log-message") - && event.getDate().toLocalDate().equals(LocalDate.now()) - && event.getReferenceId() != null - && event.getEnvironmentInfo().isPresent() - && event.getData().containsKey(EventPropertyKey.LOG_LEVEL.value()))); - assertThat(configuration.getLastReferenceIdManager().getLast()).isNotNull(); - } - - @Test - public void itCanCreateALogEvent() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - Event event = client.createLog("test-message", "test-source", "test-level").build(); - - assertThat(event.getType()).isEqualTo(EventType.LOG.value()); - assertThat(event.getSource()).isEqualTo("test-source"); - assertThat(event.getMessage()).isEqualTo("test-message"); - assertThat(event.getData().get(EventPropertyKey.LOG_LEVEL.value())).isEqualTo("test-level"); - assertThat(event.getDate()).isNotNull(); - } - - @Test - public void itCanCreateALogEventWithoutLevel() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - Event event = client.createLog("test-message", "test-source").build(); - - assertThat(event.getType()).isEqualTo(EventType.LOG.value()); - assertThat(event.getSource()).isEqualTo("test-source"); - assertThat(event.getMessage()).isEqualTo("test-message"); - assertThat(event.getDate()).isNotNull(); - } - - @Test - public void itCanCreateALogEventWithoutLevelAndSourceUsingOverridedMethod() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - Event event = client.createLog("test-message").build(); - - assertThat(event.getType()).isEqualTo(EventType.LOG.value()); - assertThat(event.getSource()) - .isEqualTo( - "itCanCreateALogEventWithoutLevelAndSourceUsingOverridedMethod"); // should be equal to - // the test method - assertThat(event.getMessage()).isEqualTo("test-message"); - assertThat(event.getDate()).isNotNull(); - } - - @Test - public void itCanCreateALogEventWithoutLevelAndSource() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - Event event = client.createLog("test-message", null, null).build(); - - assertThat(event.getType()).isEqualTo(EventType.LOG.value()); - assertThat(event.getSource()) - .isEqualTo("itCanCreateALogEventWithoutLevelAndSource"); // should be equal to - // the test method - assertThat(event.getMessage()).isEqualTo("test-message"); - assertThat(event.getDate()).isNotNull(); - } - - @Test - public void itCanSubmitANotFoundEvent() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - client.submitNotFound("test-resource"); - - verify(eventQueue, times(1)) - .enqueue( - argThat( - event -> - event.getType().equals(EventType.NOT_FOUND.value()) - && event.getSource().equals("test-resource") - && event.getDate().toLocalDate().equals(LocalDate.now()) - && event.getReferenceId() != null - && event.getEnvironmentInfo().isPresent())); - assertThat(configuration.getLastReferenceIdManager().getLast()).isNotNull(); - } - - @Test - public void itCanCreateANotFoundEvent() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - Event event = client.createNotFound("test-resource").build(); - - assertThat(event.getType()).isEqualTo(EventType.NOT_FOUND.value()); - assertThat(event.getSource()).isEqualTo("test-resource"); - assertThat(event.getDate()).isNotNull(); - } - - @Test - public void itCanSubmitASessionStartEvent() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - client.submitSessionStart(); - - verify(eventQueue, times(1)) - .enqueue( - argThat( - event -> - event.getType().equals(EventType.SESSION.value()) - && event.getDate().toLocalDate().equals(LocalDate.now()) - && event.getReferenceId() != null - && event.getEnvironmentInfo().isPresent())); - assertThat(configuration.getLastReferenceIdManager().getLast()).isNotNull(); - } - - @Test - public void itCanCreateASessionStartEvent() { - doReturn(settingsStorage).when(storageProvider).getSettings(); - - Event event = client.createSessionStart().build(); - - assertThat(event.getType()).isEqualTo(EventType.SESSION.value()); - assertThat(event.getDate()).isNotNull(); - } - - @Test - public void itCanSubmitSessionEnd() { - client.submitSessionEnd("test-user-id"); - - verify(submissionClient, times(1)).sendHeartBeat("test-user-id", true); - } - - @Test - public void itCanUpdateEmailAndDescription() { - SubmissionResponse expectedResponse = SubmissionResponse.builder().code(200).build(); - doReturn(expectedResponse) - .when(submissionClient) - .postUserDescription( - "test-reference-id", - UserDescription.builder() - .description("test-description") - .emailAddress("test-email") - .build()); - - SubmissionResponse actualResponse = - client.updateEmailAndDescription("test-reference-id", "test-email", "test-description"); - - assertThat(actualResponse).isEqualTo(expectedResponse); - } - - @Test - public void itCanGetLastReferenceId() { - configuration.getLastReferenceIdManager().setLast("123"); - - assertThat(client.getLastReferenceId()).isEqualTo("123"); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/TestFixtures.java b/src/test/java/com/exceptionless/exceptionlessclient/TestFixtures.java deleted file mode 100644 index 4105796..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/TestFixtures.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.exceptionless.exceptionlessclient; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.queue.DefaultEventQueue; -import com.exceptionless.exceptionlessclient.storage.InMemoryStorageProvider; -import org.mockito.Mockito; - -public final class TestFixtures { - private TestFixtures() {} - - public static Configuration.ConfigurationBuilder aDefaultConfigurationManager() { - return Configuration.builder() - .apiKey("12456790abcdef") - .queue(Mockito.mock(DefaultEventQueue.class)) - .storageProvider(InMemoryStorageProvider.builder().build()); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/configuration/ConfigurationTest.java b/src/test/java/com/exceptionless/exceptionlessclient/configuration/ConfigurationTest.java deleted file mode 100644 index 7ab55cd..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/configuration/ConfigurationTest.java +++ /dev/null @@ -1,220 +0,0 @@ -package com.exceptionless.exceptionlessclient.configuration; - -import com.exceptionless.exceptionlessclient.TestFixtures; -import com.exceptionless.exceptionlessclient.enums.EventPropertyKey; -import com.exceptionless.exceptionlessclient.exceptions.InvalidApiKeyException; -import com.exceptionless.exceptionlessclient.logging.LogCapturerIF; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.UserInfo; -import com.exceptionless.exceptionlessclient.plugins.EventPluginIF; -import com.exceptionless.exceptionlessclient.settings.ServerSettings; -import com.exceptionless.exceptionlessclient.storage.InMemoryStorage; -import com.exceptionless.exceptionlessclient.storage.InMemoryStorageProvider; -import com.exceptionless.exceptionlessclient.submission.DefaultSubmissionClient; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.function.Consumer; -import java.util.stream.Collectors; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Java6Assertions.assertThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -public class ConfigurationTest { - private static final Logger LOG = LoggerFactory.getLogger(ConfigurationTest.class); - - @Mock private InMemoryStorageProvider storageProvider; - @Mock private DefaultSubmissionClient submissionClient; - @Mock private Consumer handler; - @Mock private LogCapturerIF logCapturer; - private Configuration configuration; - private InMemoryStorage storage; - - @BeforeEach - public void setup() { - storage = InMemoryStorage.builder().build(); - configuration = - TestFixtures.aDefaultConfigurationManager() - .storageProvider(storageProvider) - .submissionClient(submissionClient) - .build(); - } - - @Test - public void itThrowsInvalidApiKeyExceptionForInvalidApiKeys() { - assertThatThrownBy(() -> TestFixtures.aDefaultConfigurationManager().apiKey("xxx").build()) - .isInstanceOf(InvalidApiKeyException.class) - .hasMessage("Apikey is not valid: [xxx]"); - } - - @Test - public void itCanAddALogCapturer() { - configuration = - TestFixtures.aDefaultConfigurationManager().logCatpurer(logCapturer).build(); - // trace is disabled by default - LOG.debug("debug message"); - LOG.info("info message"); - LOG.warn("warn message"); - LOG.error("error message"); - Exception e = new RuntimeException("test"); - LOG.error("exception message", e); - - verify(logCapturer, times(1)).debug("debug message"); - verify(logCapturer, times(1)).info("info message"); - verify(logCapturer, times(1)).warn("warn message"); - verify(logCapturer, times(1)).error("error message"); - verify(logCapturer, times(1)).error("exception message", e); - } - - @Test - public void itCanAddDefaultTags() { - configuration.addDefaultTags("tag1", "tag2"); - - assertThat(configuration.getDefaultTags()).isEqualTo(Set.of("tag1", "tag2")); - } - - @Test - public void itCanAddDataExclusions() { - doReturn(storage).when(storageProvider).getSettings(); - storage.save( - ServerSettings.builder() - .version(1L) - .settings(Map.of("@@DataExclusions", "exclusion1,exclusion2")) - .build()); - - configuration.addDataExclusions("exclusion3", "exclusion4"); - - assertThat(configuration.getDataExclusions()) - .isEqualTo(Set.of("exclusion1", "exclusion2", "exclusion3", "exclusion4")); - } - - @Test - public void itCanAddUserAgentBotPatterns() { - doReturn(storage).when(storageProvider).getSettings(); - storage.save( - ServerSettings.builder() - .version(1L) - .settings(Map.of("@@UserAgentBotPatterns", "pattern1,pattern2")) - .build()); - - configuration.addUserAgentBotPatterns("pattern3", "pattern4"); - - assertThat(configuration.getUserAgentBotPatterns()) - .isEqualTo(Set.of("pattern1", "pattern2", "pattern3", "pattern4")); - } - - @Test - public void itCanSubmitSessionHeartBeat() { - configuration.submitSessionHeartbeat("test-user-id"); - - verify(submissionClient, times(1)).sendHeartBeat("test-user-id", false); - } - - @Test - public void itCanAddAndRemovePlugins() { - configuration.addPlugin( - new EventPluginIF() { - @Override - public int getPriority() { - return Integer.MAX_VALUE; - } - - @Override - public String getName() { - return "testPlugin"; - } - - @Override - public void run( - EventPluginContext eventPluginContext, Configuration configuration) {} - }); - - assertThat( - configuration.getPlugins().stream() - .anyMatch(plugin -> plugin.getName().equals("testPlugin"))) - .isTrue(); - - configuration.removePlugin("testPlugin"); - - assertThat( - configuration.getPlugins().stream() - .anyMatch(plugin -> plugin.getName().equals("testPlugin"))) - .isFalse(); - } - - @Test - public void itCanAddVersionInDefaultData() { - configuration.setVersion("123"); - - assertThat(configuration.getDefaultData()) - .isEqualTo(Map.of(EventPropertyKey.VERSION.value(), "123")); - } - - @Test - public void itCanAddAndRemoveUserIdentityInDefaultData() { - UserInfo userInfo = UserInfo.builder().identity("test-identity").name("test-name").build(); - - configuration.setUserIdentity(userInfo); - - assertThat(configuration.getDefaultData()) - .isEqualTo(Map.of(EventPropertyKey.USER.value(), userInfo)); - - configuration.removeUserIdentity(); - - assertThat(configuration.getDefaultData()).isEqualTo(Map.of()); - } - - @Test - public void itCanUseSessions() { - configuration.useSessions(); - List plugins = - configuration.getPlugins().stream() - .filter(plugin -> plugin.getName().contains("HeartbeatPlugin")) - .collect(Collectors.toList()); - - assertThat(plugins).hasSize(1); - } - - @Test - public void itCanDetectChanges() { - configuration.onChanged(handler); - configuration.setApiKey("test-api-key"); - - verify(handler, times(1)).accept(configuration); - } - - @Test - public void itCanSetDefaultValueHeartBeatServerUrlToServerUrlIfAbsent() { - Configuration configuration = - Configuration.builder() - .serverUrl("test-server-url") - .apiKey("12345678abcdef") - .build(); - - Assertions.assertThat(configuration.getHeartbeatServerUrl().get()) - .isEqualTo("test-server-url"); - } - - @Test - public void itCanSetDefaultValueConfigServerUrlToServerUrlIfAbsent() { - Configuration configuration = - Configuration.builder() - .serverUrl("test-server-url") - .apiKey("12345678abcdef") - .build(); - - Assertions.assertThat(configuration.getConfigServerUrl().get()) - .isEqualTo("test-server-url"); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/configuration/PluginManagerTest.java b/src/test/java/com/exceptionless/exceptionlessclient/configuration/PluginManagerTest.java deleted file mode 100644 index 19fad8f..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/configuration/PluginManagerTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.exceptionless.exceptionlessclient.configuration; - -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.plugins.EventPluginIF; -import com.exceptionless.exceptionlessclient.plugins.PluginManager; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; - -public class PluginManagerTest { - private PluginManager pluginManager; - - @BeforeEach - public void setup() { - pluginManager = PluginManager.builder().build(); - } - - @Test - public void itCanConfigureDefaultPlugins() { - List pluginNames = - List.of( - "ConfigurationDefaultsPlugin", - "ErrorPlugin", - "DuplicateCheckerPlugin", - "EventExclusionPlugin", - "ModuleInfoPlugin", - "RequestInfoPlugin", - "EnvironmentInfoPlugin", - "SubmissionMethodPlugin"); - pluginNames.forEach( - pluginName -> - assertThat( - pluginManager.getPlugins().stream() - .anyMatch(plugin -> plugin.getName().contains(pluginName))) - .isTrue()); - } - - @Test - public void itCanSortThemAccordingToPriority() { - List plugins = pluginManager.getPlugins(); - for (int i = 1; i < plugins.size(); i++) { - if (plugins.get(i - 1).getPriority() < plugins.get(i).getPriority()) { - fail("The plugins should be in non increasing order"); - } - } - } - - @Test - public void itCanAddPluginWhichIsInsertedInSortedOrder() { - pluginManager.addPlugin( - new EventPluginIF() { - @Override - public int getPriority() { - return Integer.MAX_VALUE; - } - - @Override - public String getName() { - return "test-plugin"; - } - - @Override - public void run( - EventPluginContext eventPluginContext, Configuration configuration) {} - }); - - assertThat(pluginManager.getPlugins().get(0).getName()).isEqualTo("test-plugin"); - } - - @Test - public void itCanAddAndRemovePlugins() { - pluginManager.addPlugin( - new EventPluginIF() { - @Override - public int getPriority() { - return 10; - } - - @Override - public String getName() { - return "test-plugin"; - } - - @Override - public void run( - EventPluginContext eventPluginContext, Configuration configuration) {} - }); - assertThat( - pluginManager.getPlugins().stream() - .anyMatch(plugin -> plugin.getName().equals("test-plugin"))) - .isTrue(); - - pluginManager.removePlugin("test-plugin"); - assertThat( - pluginManager.getPlugins().stream() - .anyMatch(plugin -> plugin.getName().equals("test-plugin"))) - .isFalse(); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/configuration/PrivateInformationInclusionsTest.java b/src/test/java/com/exceptionless/exceptionlessclient/configuration/PrivateInformationInclusionsTest.java deleted file mode 100644 index d5c3470..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/configuration/PrivateInformationInclusionsTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.exceptionless.exceptionlessclient.configuration; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.beans.PropertyChangeListener; -import java.util.List; - -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -public class PrivateInformationInclusionsTest { - @Mock private PropertyChangeListener listener; - - @Test - public void itCanSendChangesToListeners() { - PrivateInformationInclusions privateInformationInclusions = - PrivateInformationInclusions.builder().build(); - privateInformationInclusions.addPropertyChangeListener(listener); - - privateInformationInclusions.applyToAll(true); - privateInformationInclusions.setQueryString(false); - privateInformationInclusions.setPostData(false); - privateInformationInclusions.setCookies(false); - privateInformationInclusions.setIpAddress(false); - privateInformationInclusions.setMachineName(false); - privateInformationInclusions.setUserName(false); - - List properties = - List.of( - "all", "queryString", "postData", "cookies", "ipAddress", "machineName", "userName"); - List oldValues = List.of(false, true, true, true, true, true, true); - List newValues = List.of(true, false, false, false, false, false, false); - - properties.forEach( - property -> - verify(listener, times(1)) - .propertyChange( - argThat( - event -> - event.getPropertyName().equals(property) - && event - .getOldValue() - .equals(oldValues.get(properties.indexOf(property))) - && event - .getNewValue() - .equals(newValues.get(properties.indexOf(property)))))); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/plugins/EventPluginRunnerTest.java b/src/test/java/com/exceptionless/exceptionlessclient/plugins/EventPluginRunnerTest.java deleted file mode 100644 index f2f002f..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/plugins/EventPluginRunnerTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins; - -import com.exceptionless.exceptionlessclient.TestFixtures; -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.services.DefaultLastReferenceIdManager; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.queue.DefaultEventQueue; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -public class EventPluginRunnerTest { - @Mock private DefaultEventQueue eventQueue; - @Mock private DefaultLastReferenceIdManager lastReferenceIdManager; - @Mock private EventPluginIF testPlugin; - private EventPluginRunner pluginRunner; - private EventPluginContext context; - - @BeforeEach - public void setup() { - context = - EventPluginContext.from(Event.builder().type("test-type").referenceId("123456789").build()); - Configuration configuration = - TestFixtures.aDefaultConfigurationManager() - .queue(eventQueue) - .lastReferenceIdManager(lastReferenceIdManager) - .build(); - // should run first - doReturn(Integer.MAX_VALUE).when(testPlugin).getPriority(); - doReturn("test-plugin").when(testPlugin).getName(); - configuration.addPlugin(testPlugin); - pluginRunner = EventPluginRunner.builder().configuration(configuration).build(); - } - - @Test - public void itShouldNotRunPluginIfEventIsCancelled() { - context.getContext().setEventCancelled(true); - - pluginRunner.run(context); - - verify(testPlugin, times(0)).run(any(), any()); - verify(eventQueue, times(0)).enqueue(any()); - verifyZeroInteractions(lastReferenceIdManager); - } - - @Test - public void itShouldCancelEventIfExceptionOccursWhileRunningPlugin() { - doThrow(new RuntimeException("test")).when(testPlugin).run(any(), any()); - - pluginRunner.run(context); - - verify(eventQueue, times(0)).enqueue(any()); - verifyZeroInteractions(lastReferenceIdManager); - assertThat(context.getContext().isEventCancelled()).isTrue(); - } - - @Test - public void itShouldRunPluginsSuccessfully() { - pluginRunner.run(context); - - verify(testPlugin, times(1)).run(eq(context), any()); - verify(eventQueue, times(1)).enqueue(context.getEvent()); - verify(lastReferenceIdManager, times(1)).setLast("123456789"); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/plugins/MergedEventTest.java b/src/test/java/com/exceptionless/exceptionlessclient/plugins/MergedEventTest.java deleted file mode 100644 index b71cbf8..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/plugins/MergedEventTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins; - -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.queue.DefaultEventQueue; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.LocalDate; -import java.time.OffsetDateTime; - -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -public class MergedEventTest { - @Mock private DefaultEventQueue eventQueue; - private MergedEvent mergedEvent; - - @BeforeEach - public void setup() { - mergedEvent = - MergedEvent.builder() - .event(Event.builder().date(OffsetDateTime.now()).build()) - .eventQueue(eventQueue) - .build(); - } - - @Test - public void itCanIncrementCount() { - mergedEvent.incrementCount(100); - mergedEvent.resubmit(); - - verify(eventQueue, times(1)).enqueue(argThat(event -> event.getCount().equals(101L))); - } - - @Test - public void itCanUpdateDate() { - OffsetDateTime tomorrow = OffsetDateTime.now().plusDays(1); - mergedEvent.updateDate(tomorrow); - mergedEvent.resubmit(); - - verify(eventQueue, times(1)).enqueue(argThat(event -> event.getDate().equals(tomorrow))); - } - - @Test - public void itShouldNotUpdateDateIfSameAsTheEvent() { - mergedEvent.updateDate(OffsetDateTime.now()); - mergedEvent.resubmit(); - - verify(eventQueue, times(1)) - .enqueue(argThat(event -> event.getDate().toLocalDate().equals(LocalDate.now()))); - } - - @Test - public void itShouldNotUpdateDateIfBeforeAsTheEvent() { - OffsetDateTime yesterday = OffsetDateTime.now().minusDays(1); - mergedEvent.updateDate(yesterday); - mergedEvent.resubmit(); - - verify(eventQueue, times(1)) - .enqueue(argThat(event -> event.getDate().toLocalDate().equals(LocalDate.now()))); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ConfigurationDefaultsPluginTest.java b/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ConfigurationDefaultsPluginTest.java deleted file mode 100644 index 1f3ca6b..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ConfigurationDefaultsPluginTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.UserInfo; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.Arrays; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -public class ConfigurationDefaultsPluginTest { - private ConfigurationDefaultsPlugin plugin; - private EventPluginContext context; - private Configuration configuration; - - @BeforeEach - public void setup() { - plugin = ConfigurationDefaultsPlugin.builder().build(); - context = EventPluginContext.from(Event.builder().build()); - configuration = Configuration.builder().apiKey("12456790abcdef").build(); - } - - @Test - public void itAddsDefaultTags() { - configuration.addDefaultTags("test1", "test2"); - - plugin.run(context, configuration); - - Event event = context.getEvent(); - assertThat(event.getTags()).containsAll(Arrays.asList("test1", "test2")); - } - - @Test - public void itAddsDefaultData() { - configuration.setVersion("123"); - configuration.setUserIdentity("test-name", "test-identity"); - - plugin.run(context, configuration); - - Event event = context.getEvent(); - assertThat(event.getData()) - .isEqualTo( - Map.of( - "@version", - "123", - "@user", - UserInfo.builder().identity("test-identity").name("test-name").build())); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/DuplicateCheckerPluginTest.java b/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/DuplicateCheckerPluginTest.java deleted file mode 100644 index 0b3d04b..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/DuplicateCheckerPluginTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.enums.EventPropertyKey; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.error.Error; -import com.exceptionless.exceptionlessclient.models.error.StackFrame; -import com.exceptionless.exceptionlessclient.queue.DefaultEventQueue; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -public class DuplicateCheckerPluginTest { - private static final String EVENT_REF_ID = "test-event"; - private static final Long EVENT_COUNT = 100L; - - @Mock private DefaultEventQueue eventQueue; - - private EventPluginContext context; - private Configuration configuration; - private DuplicateCheckerPlugin plugin; - - @BeforeEach - public void setup() { - Error error = - Error.builder() - .message("test-error-message") - .stackTrace(List.of(StackFrame.builder().column(1).filename("test-file").build())) - .build(); - context = - EventPluginContext.from( - Event.builder() - .referenceId(EVENT_REF_ID) - .property(EventPropertyKey.ERROR.value(), error) - .count(EVENT_COUNT) - .build()); - configuration = - Configuration.builder().apiKey("12456790abcdef").queue(eventQueue).build(); - } - - @Test - public void itCanDetectAPotentialToBeMergedEvent() throws InterruptedException { - plugin = DuplicateCheckerPlugin.builder().mergedEventsResubmissionInSecs(1).build(); - plugin.run(context, configuration); - plugin.run(context, configuration); - Thread.sleep(1500); - assertThat(context.getContext().isEventCancelled()).isTrue(); - verify(eventQueue) - .enqueue( - argThat( - event -> - event.getCount().equals(EVENT_COUNT) - && event.getReferenceId().equals(EVENT_REF_ID))); - } - - @Test - public void itCanMergeEventsWithSameHash() throws InterruptedException { - plugin = DuplicateCheckerPlugin.builder().mergedEventsResubmissionInSecs(1).build(); - plugin.run(context, configuration); - plugin.run(context, configuration); - plugin.run(context, configuration); - Thread.sleep(1500); - assertThat(context.getContext().isEventCancelled()).isTrue(); - verify(eventQueue) - .enqueue( - argThat( - event -> - event.getCount().equals(EVENT_COUNT * 2) - && event.getReferenceId().equals(EVENT_REF_ID))); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EnvironmentInfoPluginTest.java b/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EnvironmentInfoPluginTest.java deleted file mode 100644 index 21b1b48..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EnvironmentInfoPluginTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.TestFixtures; -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.configuration.PrivateInformationInclusions; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.EnvironmentInfo; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class EnvironmentInfoPluginTest { - private EventPluginContext context; - private Configuration configuration; - private EnvironmentInfoPlugin plugin; - - @BeforeEach - public void setup() { - context = EventPluginContext.from(Event.builder().build()); - configuration = TestFixtures.aDefaultConfigurationManager().build(); - plugin = EnvironmentInfoPlugin.builder().build(); - } - - @Test - public void itShouldNotIncludeMachineNameAndIpAddressUntilExplicitlyTold() { - assertThat(context.getEvent().getEnvironmentInfo()).isEmpty(); - - plugin.run(context, configuration); - - assertThat(context.getEvent().getEnvironmentInfo()).isPresent(); - EnvironmentInfo info = context.getEvent().getEnvironmentInfo().get(); - assertThat(info.getMachineName()).isNull(); - assertThat(info.getIpAddress()).isNull(); - - assertThat(info.getProcessorCount()).isNotNull(); - } - - @Test - public void itCanIncludeMachineNameAndIpAddress() { - assertThat(context.getEvent().getEnvironmentInfo()).isEmpty(); - PrivateInformationInclusions inclusions = - configuration.getPrivateInformationInclusions(); - inclusions.setIpAddress(true); - inclusions.setMachineName(true); - - plugin.run(context, configuration); - - assertThat(context.getEvent().getEnvironmentInfo()).isPresent(); - EnvironmentInfo info = context.getEvent().getEnvironmentInfo().get(); - - assertThat(info.getMachineName()).isNotNull(); - assertThat(info.getIpAddress()).isNotNull(); - - assertThat(info.getProcessorCount()).isNotNull(); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ErrorPluginTest.java b/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ErrorPluginTest.java deleted file mode 100644 index 3d25524..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ErrorPluginTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.TestFixtures; -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.enums.EventPropertyKey; -import com.exceptionless.exceptionlessclient.enums.EventType; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.PluginContext; -import com.exceptionless.exceptionlessclient.models.error.StackFrame; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -public class ErrorPluginTest { - private Configuration configuration; - private ErrorPlugin plugin; - - @BeforeEach - public void setup() { - configuration = TestFixtures.aDefaultConfigurationManager().build(); - plugin = ErrorPlugin.builder().build(); - } - - @Test - public void itCanAddExceptionToEventCorrectly() { - Exception exc = new RuntimeException("test"); - EventPluginContext context = - EventPluginContext.builder() - .event(Event.builder().build()) - .context(PluginContext.builder().exception(exc).build()) - .build(); - - plugin.run(context, configuration); - - Event event = context.getEvent(); - assertThat(event.getType()).isEqualTo(EventType.ERROR.value()); - assertThat(event.getError()).isPresent(); - assertThat(event.getError().get().getMessage()).isEqualTo("test"); - assertThat(event.getError().get().getType()).isEqualTo("java.lang.RuntimeException"); - assertThat(event.getError().get().getStackTrace()).isNotEmpty(); - - StackFrame frame = event.getError().get().getStackTrace().get(0); - // it should be the test file name. - assertThat(frame.getFilename()).isEqualTo("ErrorPluginTest.java"); - - Map data = event.getData(); - assertThat(data).isNotNull(); - assertThat(data).containsKey(EventPropertyKey.EXTRA.value()); - - assertThat(data.get(EventPropertyKey.EXTRA.value())).isSameAs(exc); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EventExclusionPluginTest.java b/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EventExclusionPluginTest.java deleted file mode 100644 index 2d2557a..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EventExclusionPluginTest.java +++ /dev/null @@ -1,289 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.TestFixtures; -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.enums.EventPropertyKey; -import com.exceptionless.exceptionlessclient.enums.EventType; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.error.Error; -import com.exceptionless.exceptionlessclient.settings.ServerSettings; -import com.exceptionless.exceptionlessclient.storage.InMemoryStorage; -import com.exceptionless.exceptionlessclient.storage.InMemoryStorageProvider; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.doReturn; - -@ExtendWith(MockitoExtension.class) -public class EventExclusionPluginTest { - @Mock private InMemoryStorageProvider storageProvider; - - private Configuration configuration; - private EventPluginContext context; - private EventExclusionPlugin plugin; - - @BeforeEach - public void setup() { - configuration = - TestFixtures.aDefaultConfigurationManager().storageProvider(storageProvider).build(); - plugin = EventExclusionPlugin.builder().build(); - } - - @Test - public void itShouldNotCancelLogEventIfNoLogSettingInServerSettings() { - context = EventPluginContext.from(Event.builder().type(EventType.LOG.value()).build()); - doReturn(InMemoryStorage.builder().build()).when(storageProvider).getSettings(); - - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isFalse(); - } - - @Test - public void itShouldNotCancelLogEventIfThereIsNoLogLevel() { - context = - EventPluginContext.from(Event.builder().type(EventType.LOG.value()).source("test").build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - storage.save(ServerSettings.builder().settings(Map.of("@@log:test", "trace")).build()); - doReturn(storage).when(storageProvider).getSettings(); - - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isFalse(); - } - - @Test - public void itShouldNotCancelLogEventIfLogPriorityIsNotIdentifiedForServerSettings() { - context = - EventPluginContext.from( - Event.builder() - .type(EventType.LOG.value()) - .source("test") - .property(EventPropertyKey.LOG_LEVEL.value(), "trace") - .build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - storage.save(ServerSettings.builder().settings(Map.of("@@log:test", "xxx")).build()); - doReturn(storage).when(storageProvider).getSettings(); - - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isFalse(); - } - - @Test - public void itShouldNotCancelLogEventIfLogPriorityIsNotIdentifiedForLogLevel() { - context = - EventPluginContext.from( - Event.builder() - .type(EventType.LOG.value()) - .source("test") - .property(EventPropertyKey.LOG_LEVEL.value(), "xxx") - .build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - storage.save(ServerSettings.builder().settings(Map.of("@@log:test", "trace")).build()); - doReturn(storage).when(storageProvider).getSettings(); - - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isFalse(); - } - - @Test - public void itShouldNotCancelIfLogLevelIsMoreThanLevelSetInServerSettings() { - context = - EventPluginContext.from( - Event.builder() - .type(EventType.LOG.value()) - .source("test") - .property(EventPropertyKey.LOG_LEVEL.value(), "info") - .build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - storage.save(ServerSettings.builder().settings(Map.of("@@log:test", "trace")).build()); - doReturn(storage).when(storageProvider).getSettings(); - - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isFalse(); - } - - @Test - public void itShouldNotCancelIfLogLevelIsEqualToLevelSetInServerSettings() { - context = - EventPluginContext.from( - Event.builder() - .type(EventType.LOG.value()) - .source("test") - .property(EventPropertyKey.LOG_LEVEL.value(), "info") - .build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - storage.save(ServerSettings.builder().settings(Map.of("@@log:test", "info")).build()); - doReturn(storage).when(storageProvider).getSettings(); - - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isFalse(); - } - - @Test - public void itShouldCancelIfLogLevelIsLessThanLevelSetInServerSettings() { - context = - EventPluginContext.from( - Event.builder() - .type(EventType.LOG.value()) - .source("test") - .property(EventPropertyKey.LOG_LEVEL.value(), "trace") - .build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - storage.save(ServerSettings.builder().settings(Map.of("@@log:test", "info")).build()); - doReturn(storage).when(storageProvider).getSettings(); - - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isTrue(); - } - - @Test - public void itShouldNotCancelErrorEventIfErrorIsNotSet() { - context = EventPluginContext.from(Event.builder().type(EventType.ERROR.value()).build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - doReturn(storage).when(storageProvider).getSettings(); - - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isFalse(); - } - - @Test - public void itShouldNotCancelErrorEventIfServerSettingsIsNotSet() { - context = - EventPluginContext.from( - Event.builder() - .type(EventType.ERROR.value()) - .property(EventPropertyKey.ERROR.value(), Error.builder().build()) - .build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - doReturn(storage).when(storageProvider).getSettings(); - - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isFalse(); - } - - @Test - public void itShouldNotCancelErrorEventIfServerSettingIsSetToTrue() { - context = - EventPluginContext.from( - Event.builder() - .type(EventType.ERROR.value()) - .property( - EventPropertyKey.ERROR.value(), Error.builder().type("test-error").build()) - .build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - doReturn(storage).when(storageProvider).getSettings(); - storage.save(ServerSettings.builder().settings(Map.of("@@error:test-error", "true")).build()); - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isFalse(); - } - - @Test - public void itShouldCancelErrorEventIfServerSettingIsSetToFalse() { - context = - EventPluginContext.from( - Event.builder() - .type(EventType.ERROR.value()) - .property( - EventPropertyKey.ERROR.value(), Error.builder().type("test-error").build()) - .build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - doReturn(storage).when(storageProvider).getSettings(); - storage.save(ServerSettings.builder().settings(Map.of("@@error:test-error", "false")).build()); - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isTrue(); - } - - @Test - public void itShouldNotCancelErrorEventIfServerSettingIsSetToTrueForInnerError() { - context = - EventPluginContext.from( - Event.builder() - .type(EventType.ERROR.value()) - .property( - EventPropertyKey.ERROR.value(), - Error.builder() - .type("test-error") - .inner(Error.builder().type("test-inner-error").build()) - .build()) - .build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - doReturn(storage).when(storageProvider).getSettings(); - storage.save( - ServerSettings.builder().settings(Map.of("@@error:test-inner-error", "false")).build()); - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isTrue(); - } - - @Test - public void itShouldNotCancelEventOtherThanLogAndErrorIfServerSettingsIsNotPresent() { - context = - EventPluginContext.from( - Event.builder().type(EventType.USAGE.value()).source("test").build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - doReturn(storage).when(storageProvider).getSettings(); - - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isFalse(); - } - - @Test - public void itShouldNotCancelEventOtherThanLogAndErrorIfServerSettingsIsTrue() { - context = - EventPluginContext.from( - Event.builder().type(EventType.USAGE.value()).source("test").build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - storage.save(ServerSettings.builder().settings(Map.of("@@usage:test", "true")).build()); - doReturn(storage).when(storageProvider).getSettings(); - - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isFalse(); - } - - @Test - public void itShouldNotCancelEventOtherThanLogAndErrorIfServerSettingsIsFalse() { - context = - EventPluginContext.from( - Event.builder().type(EventType.USAGE.value()).source("test").build()); - - InMemoryStorage storage = InMemoryStorage.builder().build(); - storage.save(ServerSettings.builder().settings(Map.of("@@usage:test", "false")).build()); - doReturn(storage).when(storageProvider).getSettings(); - - plugin.run(context, configuration); - - assertThat(context.getContext().isEventCancelled()).isTrue(); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/HeartbeatPluginTest.java b/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/HeartbeatPluginTest.java deleted file mode 100644 index 41aee61..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/HeartbeatPluginTest.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.TestFixtures; -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.submission.DefaultSubmissionClient; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -public class HeartbeatPluginTest { - @Mock private DefaultSubmissionClient submissionClient; - private Configuration configuration; - private HeartbeatPlugin plugin; - - @BeforeEach - public void setup() { - plugin = HeartbeatPlugin.builder().heartbeatIntervalInSecs(1).build(); - configuration = - TestFixtures.aDefaultConfigurationManager().submissionClient(submissionClient).build(); - } - - @Test - public void itShouldNotSubmitHeartbeatForNoUserIdentity() throws InterruptedException { - plugin.run(EventPluginContext.from(Event.builder().build()), configuration); - - Thread.sleep(1500); - verifyZeroInteractions(submissionClient); - } - - @Test - public void itShouldSendHeartBeatForAUserIdentity() throws InterruptedException { - plugin.run( - EventPluginContext.from(Event.builder().userIdentity("test-identity").build()), - configuration); - - Thread.sleep(1500); - verify(submissionClient, times(1)).sendHeartBeat("test-identity", false); - } - - @Test - public void itShouldResetHeartBeatTimerForANewUserIdentity() throws InterruptedException { - plugin.run( - EventPluginContext.from(Event.builder().userIdentity("test-identity").build()), - configuration); - Thread.sleep(1500); - plugin.run( - EventPluginContext.from(Event.builder().userIdentity("test-identity2").build()), - configuration); - Thread.sleep(1500); - - verify(submissionClient, times(1)).sendHeartBeat("test-identity", false); - verify(submissionClient, times(1)).sendHeartBeat("test-identity2", false); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ModuleInfoPluginTest.java b/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ModuleInfoPluginTest.java deleted file mode 100644 index 6d7a5d0..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ModuleInfoPluginTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.TestFixtures; -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.enums.EventPropertyKey; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.Module; -import com.exceptionless.exceptionlessclient.models.error.Error; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -@ExtendWith(MockitoExtension.class) -public class ModuleInfoPluginTest { - private Configuration configuration; - private ModuleInfoPlugin plugin; - private EventPluginContext context; - - @BeforeEach - public void setup() { - configuration = TestFixtures.aDefaultConfigurationManager().build(); - plugin = ModuleInfoPlugin.builder().build(); - } - - @Test - public void itShouldNotAddModulesIfErrorIsNotFound() { - context = EventPluginContext.from(Event.builder().build()); - plugin.run(context, configuration); - - assertThat(context.getEvent().getError()).isEmpty(); - } - - @Test - public void itShouldNotAddModulesIfPresent() { - List modules = List.of(Module.builder().moduleId(123L).build()); - context = - EventPluginContext.from( - Event.builder() - .property(EventPropertyKey.ERROR.value(), Error.builder().modules(modules).build()) - .build()); - plugin.run(context, configuration); - - assertThat(context.getEvent().getError()).isPresent(); - assertThat(context.getEvent().getError().get().getModules()).isEqualTo(modules); - } - - @Test - public void itShouldAddModulesIfAbsent() { - context = - EventPluginContext.from( - Event.builder() - .property(EventPropertyKey.ERROR.value(), Error.builder().build()) - .build()); - plugin.run(context, configuration); - - assertThat(context.getEvent().getError()).isPresent(); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/RequestInfoPluginTest.java b/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/RequestInfoPluginTest.java deleted file mode 100644 index be1b6b5..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/RequestInfoPluginTest.java +++ /dev/null @@ -1,230 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.TestFixtures; -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.configuration.PrivateInformationInclusions; -import com.exceptionless.exceptionlessclient.enums.EventPropertyKey; -import com.exceptionless.exceptionlessclient.enums.ServerSettingKey; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.PluginContext; -import com.exceptionless.exceptionlessclient.models.RequestInfo; -import com.exceptionless.exceptionlessclient.settings.ServerSettings; -import com.exceptionless.exceptionlessclient.storage.InMemoryStorageProvider; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.net.URI; -import java.net.http.HttpRequest; -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -@ExtendWith(MockitoExtension.class) -public class RequestInfoPluginTest { - private InMemoryStorageProvider storageProvider; - private Configuration configuration; - private RequestInfoPlugin plugin; - private EventPluginContext context; - - @BeforeEach - public void setup() { - plugin = RequestInfoPlugin.builder().build(); - storageProvider = InMemoryStorageProvider.builder().build(); - configuration = - TestFixtures.aDefaultConfigurationManager().storageProvider(storageProvider).build(); - } - - @Test - public void itShouldNotDoAnythingIfRequestInfoIsPresentInEvent() { - RequestInfo requestInfo = RequestInfo.builder().userAgent("test-agent").build(); - context = - EventPluginContext.from( - Event.builder().property(EventPropertyKey.REQUEST_INFO.value(), requestInfo).build()); - - plugin.run(context, configuration); - - assertThat(context.getEvent().getRequestInfo()).isPresent(); - assertThat(context.getEvent().getRequestInfo().get()).isSameAs(requestInfo); - } - - @Test - public void itShouldNotDoAnythingIfRequestInfoIsNotPresentInEventButAbsentFromContext() { - context = EventPluginContext.from(Event.builder().build()); - - plugin.run(context, configuration); - - assertThat(context.getEvent().getRequestInfo()).isNotPresent(); - } - - @Test - public void itShouldCancelEventIfUserAgentIsABotPattern() { - HttpRequest httpRequest = - HttpRequest.newBuilder() - .uri(URI.create("http://localhost:5000/test-path")) - .header("User-Agent", "test-user-agent") - .GET() - .build(); - context = - EventPluginContext.builder() - .event(Event.builder().build()) - .context(PluginContext.builder().request(httpRequest).build()) - .build(); - - storageProvider - .getSettings() - .save( - ServerSettings.builder() - .settings( - Map.of(ServerSettingKey.USER_AGENT_BOT_PATTERNS.value(), "test-user-agent")) - .build()); - - plugin.run(context, configuration); - - assertThat(context.getEvent().getRequestInfo()).isNotPresent(); - assertThat(context.getContext().isEventCancelled()).isTrue(); - } - - @Test - public void itShouldAddRequestInfoToEvent() { - HttpRequest httpRequest = - HttpRequest.newBuilder() - .uri(URI.create("http://localhost:5000/test-path")) - .header("test-header", "test-header-value") - .header("test-header", "test-header-value-2") - .GET() - .build(); - context = - EventPluginContext.builder() - .event(Event.builder().build()) - .context(PluginContext.builder().request(httpRequest).build()) - .build(); - - plugin.run(context, configuration); - - assertThat(context.getEvent().getRequestInfo()).isPresent(); - RequestInfo requestInfo = context.getEvent().getRequestInfo().get(); - assertThat(requestInfo.getUserAgent()).isNull(); - assertThat(requestInfo.isSecure()).isFalse(); - assertThat(requestInfo.getHttpMethod()).isEqualTo("GET"); - assertThat(requestInfo.getHost()).isEqualTo("localhost"); - assertThat(requestInfo.getPath()).isEqualTo("/test-path"); - assertThat(requestInfo.getPort()).isEqualTo(5000); - assertThat(requestInfo.getHeaders()) - .isEqualTo(Map.of("test-header", List.of("test-header-value", "test-header-value-2"))); - } - - @Test - public void itCanIdentifyASecuredUri() { - HttpRequest httpRequest = - HttpRequest.newBuilder().uri(URI.create("https://localhost:5000/test-path")).GET().build(); - - context = - EventPluginContext.builder() - .event(Event.builder().build()) - .context(PluginContext.builder().request(httpRequest).build()) - .build(); - - plugin.run(context, configuration); - - assertThat(context.getEvent().getRequestInfo()).isPresent(); - RequestInfo requestInfo = context.getEvent().getRequestInfo().get(); - assertThat(requestInfo.isSecure()).isTrue(); - } - - @Test - public void itShouldNotIncludeIpAddressCookiesQueryStringByDefault() { - HttpRequest httpRequest = - HttpRequest.newBuilder().uri(URI.create("https://localhost:5000/test-path")).GET().build(); - - context = - EventPluginContext.builder() - .event(Event.builder().build()) - .context(PluginContext.builder().request(httpRequest).build()) - .build(); - - plugin.run(context, configuration); - - assertThat(context.getEvent().getRequestInfo()).isPresent(); - RequestInfo requestInfo = context.getEvent().getRequestInfo().get(); - assertThat(requestInfo.getClientIpAddress()).isNull(); - assertThat(requestInfo.getCookies()).isEmpty(); - assertThat(requestInfo.getQueryString()).isEmpty(); - } - - @Test - public void itCanGetIpAddressCookiesAndQueryStringFromAHttpRequest() { - HttpRequest httpRequest = - HttpRequest.newBuilder() - .uri(URI.create("https://localhost:5000/test-path?query-param-key=query-param-value")) - .header("Cookie", "cookie1=value1;cookie2=value2") - .GET() - .build(); - - context = - EventPluginContext.builder() - .event(Event.builder().build()) - .context(PluginContext.builder().request(httpRequest).build()) - .build(); - - PrivateInformationInclusions inclusions = configuration.getPrivateInformationInclusions(); - inclusions.setIpAddress(true); - inclusions.setCookies(true); - inclusions.setQueryString(true); - - plugin.run(context, configuration); - - assertThat(context.getEvent().getRequestInfo()).isPresent(); - RequestInfo requestInfo = context.getEvent().getRequestInfo().get(); - assertThat(requestInfo.getClientIpAddress()).isNotNull(); - assertThat(requestInfo.getCookies()) - .isEqualTo(Map.of("cookie1", "value1", "cookie2", "value2")); - assertThat(requestInfo.getQueryString()) - .isEqualTo(Map.of("query-param-key", List.of("query-param-value"))); - } - - @Test - public void itCanExcludeData() { - HttpRequest httpRequest = - HttpRequest.newBuilder() - .uri( - URI.create( - "https://localhost:5000/test-path?query-param-key=query-param-value&exclude-query-param=exclude-value")) - .headers( - "Cookie", - "cookie1=value1;cookie2=value2;exclude-cookie=exclude-value", - "test-header", - "test-value", - "Authorization", - "test-auth", - "exclude-header", - "exclude-value") - .GET() - .build(); - - context = - EventPluginContext.builder() - .event(Event.builder().build()) - .context(PluginContext.builder().request(httpRequest).build()) - .build(); - configuration.addDataExclusions("exclude-query-param", "exclude-cookie", "exclude-header"); - PrivateInformationInclusions inclusions = configuration.getPrivateInformationInclusions(); - inclusions.setIpAddress(true); - inclusions.setCookies(true); - inclusions.setQueryString(true); - - plugin.run(context, configuration); - - assertThat(context.getEvent().getRequestInfo()).isPresent(); - RequestInfo requestInfo = context.getEvent().getRequestInfo().get(); - assertThat(requestInfo.getClientIpAddress()).isNotNull(); - assertThat(requestInfo.getCookies()) - .isEqualTo(Map.of("cookie1", "value1", "cookie2", "value2")); - assertThat(requestInfo.getQueryString()) - .isEqualTo(Map.of("query-param-key", List.of("query-param-value"))); - assertThat(requestInfo.getHeaders()).isEqualTo(Map.of("test-header", List.of("test-value"))); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/SubmissionMethodPluginTest.java b/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/SubmissionMethodPluginTest.java deleted file mode 100644 index 5f25f4f..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/SubmissionMethodPluginTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.exceptionless.exceptionlessclient.plugins.preconfigured; - -import com.exceptionless.exceptionlessclient.TestFixtures; -import com.exceptionless.exceptionlessclient.configuration.Configuration; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.EventPluginContext; -import com.exceptionless.exceptionlessclient.models.PluginContext; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class SubmissionMethodPluginTest { - private Configuration configuration; - private SubmissionMethodPlugin plugin; - private EventPluginContext context; - - @BeforeEach - public void setup() { - plugin = SubmissionMethodPlugin.builder().build(); - configuration = TestFixtures.aDefaultConfigurationManager().build(); - } - - @Test - public void itShouldNotDoAnythingIfNoSubmissionMethodInTheContext() { - context = EventPluginContext.from(Event.builder().build()); - - plugin.run(context, configuration); - - assertThat(context.getEvent().getSubmissionMethod()).isNotPresent(); - } - - @Test - public void itShouldAddSubmissionMethodToTheEvent() { - context = - EventPluginContext.builder() - .event(Event.builder().build()) - .context(PluginContext.builder().submissionMethod("test-submission-method").build()) - .build(); - - plugin.run(context, configuration); - - assertThat(context.getEvent().getSubmissionMethod()).isPresent(); - assertThat(context.getEvent().getSubmissionMethod().get()).isEqualTo("test-submission-method"); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/queue/DefaultEventQueueTest.java b/src/test/java/com/exceptionless/exceptionlessclient/queue/DefaultEventQueueTest.java deleted file mode 100644 index b34147f..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/queue/DefaultEventQueueTest.java +++ /dev/null @@ -1,359 +0,0 @@ -package com.exceptionless.exceptionlessclient.queue; - -import com.exceptionless.exceptionlessclient.configuration.ValueProvider; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.storage.InMemoryStorage; -import com.exceptionless.exceptionlessclient.storage.InMemoryStorageProvider; -import com.exceptionless.exceptionlessclient.submission.DefaultSubmissionClient; -import com.exceptionless.exceptionlessclient.submission.SubmissionResponse; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.List; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.function.BiConsumer; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -public class DefaultEventQueueTest { - @Mock private InMemoryStorageProvider storageProvider; - @Mock private DefaultSubmissionClient submissionClient; - @Mock private BiConsumer, SubmissionResponse> testHandler; - private DefaultEventQueue queue; - private InMemoryStorage storage; - private Event event; - - @BeforeEach - public void setup() { - storage = InMemoryStorage.builder().build(); - doReturn(storage).when(storageProvider).getQueue(); - - event = Event.builder().build(); - queue = - DefaultEventQueue.builder() - .storageProvider(storageProvider) - .submissionClient(submissionClient) - .submissionBatchSize(ValueProvider.of(1)) - .processingIntervalInSecs( - 3600) // We don't want the automatic timer to run in between tests by default - .build(); - } - - @Test - public void itCanEnqueueSuccessfully() { - queue.enqueue(event); - - assertThat(storage.peek().getValue()).isEqualTo(event); - } - - @Test - public void itCanProcessABatchSuccessfully() { - storage.save(event); - - SubmissionResponse response = - SubmissionResponse.builder().body("test-message").code(200).build(); - doReturn(response).when(submissionClient).postEvents(List.of(event)); - - queue.onEventsPosted(testHandler); - queue.process(); - - assertThat(storage.peek()).isNull(); - verify(testHandler, times(1)).accept(List.of(event), response); - } - - @Test - public void itShouldNotProcessIfCurrentlyProcessingEvents() - throws ExecutionException, InterruptedException { - storage.save(event); - storage.save(event); - - SubmissionResponse response = - SubmissionResponse.builder().body("test-message").code(200).build(); - doAnswer( - invocationOnMock -> { - Thread.sleep(1000); - return response; - }) - .when(submissionClient) - .postEvents(List.of(event)); - - queue.onEventsPosted(testHandler); - Future future = Executors.newSingleThreadExecutor().submit(() -> queue.process()); - queue.process(); - future.get(); - - assertThat(storage.get(2)).hasSize(1); // Only one is processed - verify(testHandler, times(1)).accept(List.of(event), response); - } - - @Test - public void itShouldNotPostEmptyEvents() { - queue.onEventsPosted(testHandler); - queue.process(); - - verifyZeroInteractions(submissionClient); - verifyZeroInteractions(testHandler); - } - - @Test - public void itShouldSuspendProcessingOnClientException() { - storage.save(event); - - doReturn(SubmissionResponse.builder().exception(new RuntimeException("test")).build()) - .when(submissionClient) - .postEvents(List.of(event)); - - queue.onEventsPosted(testHandler); - queue.process(); - - assertThat(queue.isProcessingCurrentlySuspended()).isTrue(); - assertThat(storage.peek().getValue()).isEqualTo(event); - verifyZeroInteractions(testHandler); - } - - @Test - public void itShouldSuspendProcessingIfServiceIsUnavailable() { - storage.save(event); - - SubmissionResponse response = - SubmissionResponse.builder().body("test-message").code(503).build(); - doReturn(response).when(submissionClient).postEvents(List.of(event)); - - queue.onEventsPosted(testHandler); - queue.process(); - - assertThat(queue.isProcessingCurrentlySuspended()).isTrue(); - assertThat(storage.peek().getValue()).isEqualTo(event); - verify(testHandler, times(1)).accept(List.of(event), response); - } - - @Test - public void itShouldSuspendProcessingIfServiceIsRateLimitedByHeader() { - storage.save(event); - - SubmissionResponse response = - SubmissionResponse.builder() - .body("test-message") - .code(999) - .rateLimitingHeaderFound(true) - .build(); - doReturn(response).when(submissionClient).postEvents(List.of(event)); - - queue.onEventsPosted(testHandler); - queue.process(); - - assertThat(queue.isProcessingCurrentlySuspended()).isTrue(); - assertThat(storage.peek().getValue()).isEqualTo(event); - verify(testHandler, times(1)).accept(List.of(event), response); - } - - @Test - public void itShouldSuspendProcessingIfServiceIsRateLimitedByCode() { - storage.save(event); - - SubmissionResponse response = - SubmissionResponse.builder().body("test-message").code(429).build(); - doReturn(response).when(submissionClient).postEvents(List.of(event)); - - queue.onEventsPosted(testHandler); - queue.process(); - - assertThat(queue.isProcessingCurrentlySuspended()).isTrue(); - assertThat(storage.peek().getValue()).isEqualTo(event); - verify(testHandler, times(1)).accept(List.of(event), response); - } - - @Test - public void itShouldSuspendAndDiscardProcessingAndClearQueueIfNoPayment() { - storage.save(event); - - SubmissionResponse response = - SubmissionResponse.builder().body("test-message").code(402).build(); - doReturn(response).when(submissionClient).postEvents(List.of(event)); - - queue.onEventsPosted(testHandler); - queue.process(); - - assertThat(queue.isProcessingCurrentlySuspended()).isTrue(); - assertThat(storage.peek()).isNull(); // queue is cleared - verify(testHandler, times(1)).accept(List.of(event), response); - - queue.enqueue(event); - - assertThat(storage.peek()).isNull(); // discarding events - } - - @Test - public void itShouldSuspendProcessingAndClearQueueIfUnableToAuthenticate() { - storage.save(event); - - SubmissionResponse response = - SubmissionResponse.builder().body("test-message").code(401).build(); - doReturn(response).when(submissionClient).postEvents(List.of(event)); - - queue.onEventsPosted(testHandler); - queue.process(); - - assertThat(queue.isProcessingCurrentlySuspended()).isTrue(); - assertThat(storage.peek()).isNull(); // queue is cleared - verify(testHandler, times(1)).accept(List.of(event), response); - } - - @Test - public void itShouldSuspendProcessingAndClearQueueForNotFoundResponse() { - storage.save(event); - - SubmissionResponse response = - SubmissionResponse.builder().body("test-message").code(404).build(); - doReturn(response).when(submissionClient).postEvents(List.of(event)); - - queue.onEventsPosted(testHandler); - queue.process(); - - assertThat(queue.isProcessingCurrentlySuspended()).isTrue(); - assertThat(storage.peek()).isNull(); // queue is cleared - verify(testHandler, times(1)).accept(List.of(event), response); - } - - @Test - public void itShouldSuspendProcessingAndClearQueueForBadRequest() { - storage.save(event); - - SubmissionResponse response = - SubmissionResponse.builder().body("test-message").code(400).build(); - doReturn(response).when(submissionClient).postEvents(List.of(event)); - - queue.onEventsPosted(testHandler); - queue.process(); - - assertThat(queue.isProcessingCurrentlySuspended()).isTrue(); - assertThat(storage.peek()).isNull(); // queue is cleared - verify(testHandler, times(1)).accept(List.of(event), response); - } - - @Test - public void itShouldSuspendProcessingByDefault() { - storage.save(event); - - SubmissionResponse response = - SubmissionResponse.builder().body("test-message").code(-1).build(); - doReturn(response).when(submissionClient).postEvents(List.of(event)); - - queue.onEventsPosted(testHandler); - queue.process(); - - assertThat(queue.isProcessingCurrentlySuspended()).isTrue(); - assertThat(storage.peek().getValue()).isEqualTo(event); - verify(testHandler, times(1)).accept(List.of(event), response); - } - - @Test - public void itShouldReduceSubmissionBatchSizeIfRequestEntitiesAreTooLarge() { - queue = - DefaultEventQueue.builder() - .storageProvider(storageProvider) - .submissionClient(submissionClient) - .submissionBatchSize(ValueProvider.of(3)) - .processingIntervalInSecs( - 3600) // We don't want the automatic timer to run in between tests by default - .build(); - for (int i = 0; i < 10; i++) { - storage.save(event); - } - - SubmissionResponse response = - SubmissionResponse.builder().body("test-message").code(413).build(); - doReturn(response).when(submissionClient).postEvents(anyList()); - - queue.onEventsPosted(testHandler); - queue.process(); - - assertThat(storage.get(10)).hasSize(10); - verify(testHandler, times(1)).accept(anyList(), eq(response)); - - queue.process(); - - // One invocation with full batch - verify(submissionClient, times(1)).postEvents(argThat(argument -> argument.size() == 3)); - // One invocation with reduced batch - verify(submissionClient, times(1)).postEvents(argThat(argument -> argument.size() == 2)); - } - - @Test - public void itShouldDiscardEventsIfItCantReduceSubmissionSizeAndRequestEntitiesAreTooLarge() { - for (int i = 0; i < 10; i++) { - storage.save(event); - } - - SubmissionResponse response = - SubmissionResponse.builder().body("test-message").code(413).build(); - doReturn(response).when(submissionClient).postEvents(anyList()); - - queue.onEventsPosted(testHandler); - queue.process(); - - assertThat(storage.get(10)).hasSize(9); - verify(testHandler, times(1)).accept(anyList(), eq(response)); - } - - @Test - public void itShouldResetSubmissionBatchSizeOnNextSuccessfulResponse() { - queue = - DefaultEventQueue.builder() - .storageProvider(storageProvider) - .submissionClient(submissionClient) - .submissionBatchSize(ValueProvider.of(3)) - .processingIntervalInSecs( - 3600) // We don't want the automatic timer to run in between tests by default - .build(); - for (int i = 0; i < 10; i++) { - storage.save(event); - } - - doReturn(SubmissionResponse.builder().body("test-message").code(413).build()) - .when(submissionClient) - .postEvents(anyList()); - queue.process(); - - doReturn(SubmissionResponse.builder().body("test-message").code(200).build()) - .when(submissionClient) - .postEvents(anyList()); - queue.process(); - queue.process(); - - // Two invocations with full batch; First with the default size and next after a successful - // response - verify(submissionClient, times(2)).postEvents(argThat(argument -> argument.size() == 3)); - // One invocation with reduced batch - verify(submissionClient, times(1)).postEvents(argThat(argument -> argument.size() == 2)); - } - - @Test - public void itShouldProcessEventsUsingTimer() throws InterruptedException { - storage.save(event); - SubmissionResponse response = - SubmissionResponse.builder().body("test-message").code(200).build(); - doReturn(response).when(submissionClient).postEvents(List.of(event)); - - queue = - DefaultEventQueue.builder() - .storageProvider(storageProvider) - .submissionClient(submissionClient) - .submissionBatchSize(ValueProvider.of(1)) - .processingIntervalInSecs(1) - .build(); - queue.onEventsPosted(testHandler); - - // Need more time due to synchronization - Thread.sleep(2000); - assertThat(storage.peek()).isNull(); - verify(testHandler, times(1)).accept(List.of(event), response); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/queue/EventDataFilterTest.java b/src/test/java/com/exceptionless/exceptionlessclient/queue/EventDataFilterTest.java deleted file mode 100644 index 420bdaf..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/queue/EventDataFilterTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.exceptionless.exceptionlessclient.queue; - -import lombok.Getter; -import lombok.Setter; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Map; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; - -public class EventDataFilterTest { - @Test - public void itShouldFilterCorrectly() { - EventDataFilter filter = - EventDataFilter.builder() - .exclusions(Set.of("exclude", "exclude-exceeding-depth")) - .maxDepth(2) - .build(); - - Map filteredObject = - (Map) filter.filter(new TestEventFilterObject()); - - Map expected = - Map.of( - "intValue", - 1, - "stringValue", - "abc", - "booleanValue", - true, - "depth3Map", - Map.of("depth3Key", Map.of("exclude-exceeding-depth", "exclude-value")), - "depth2Map", - Map.of("depth2Key", "value"), - "depth2List", - List.of(123, Map.of()), - "depth3List", - List.of(123, Map.of("depth3Key", Map.of("exclude-exceeding-depth", "exclude-value")))); - assertThat(filteredObject).isEqualTo(expected); - } -} - -@Getter -@Setter -class TestEventFilterObject { - private int intValue = 1; - private String stringValue = "abc"; - private boolean booleanValue = true; - private Object nullValue = null; - private Map depth3Map = - Map.of("depth3Key", Map.of("exclude-exceeding-depth", "exclude-value"), "exclude", "value"); - private Map depth2Map = Map.of("depth2Key", "value", "exclude", "value"); - private List depth2List = List.of(123, Map.of("exclude", "value")); - private List depth3List = - List.of( - 123, - Map.of( - "exclude", "value", "depth3Key", Map.of("exclude-exceeding-depth", "exclude-value"))); - private Object exclude = "exclude-value"; -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/queue/EventValidatorTest.java b/src/test/java/com/exceptionless/exceptionlessclient/queue/EventValidatorTest.java deleted file mode 100644 index 238f232..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/queue/EventValidatorTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.exceptionless.exceptionlessclient.queue; - -import com.exceptionless.exceptionlessclient.exceptions.BadEventDataException; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Java6Assertions.assertThatThrownBy; - -public class EventValidatorTest { - @Test - public void itShouldFailIfIdentifierIsLessThanMinimumLength() { - assertThatThrownBy(() -> EventValidator.validateIdentifier("123")) - .isInstanceOf(BadEventDataException.class) - .hasMessage("Value must contain between 8 and 100 characters, found: [123]"); - } - - @Test - public void itShouldFailIfIdentifierIsGreaterThanMaximumLength() { - assertThatThrownBy( - () -> - EventValidator.validateIdentifier( - "12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890")) - .isInstanceOf(BadEventDataException.class) - .hasMessage( - "Value must contain between 8 and 100 characters, found: [12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890]"); - } - - @Test - public void itShouldFailIfIdentifierContainsUnwantedValues() { - assertThatThrownBy(() -> EventValidator.validateIdentifier("12345678?!")) - .isInstanceOf(BadEventDataException.class) - .hasMessage( - "Value should contain only alphanumeric characters and hyphen sign, found: [12345678?!]"); - } - - @Test - public void itShouldNotFailForAValidIdentifier() { - EventValidator.validateIdentifier("12aA-34bB"); - } - - @Test - public void itShouldFailForAWrongLatitudeValue() { - assertThatThrownBy(() -> EventValidator.validateGeo(-92, 100)) - .isInstanceOf(BadEventDataException.class) - .hasMessage("Latitude should be between -90 and 90, found: [-92]"); - - assertThatThrownBy(() -> EventValidator.validateGeo(95, 100)) - .isInstanceOf(BadEventDataException.class) - .hasMessage("Latitude should be between -90 and 90, found: [95]"); - } - - @Test - public void itShouldFailForAWrongLongitudeValue() { - assertThatThrownBy(() -> EventValidator.validateGeo(0, -182)) - .isInstanceOf(BadEventDataException.class) - .hasMessage("Longitude should be between -180 and 180, found: [-182]"); - - assertThatThrownBy(() -> EventValidator.validateGeo(0, 185)) - .isInstanceOf(BadEventDataException.class) - .hasMessage("Longitude should be between -180 and 180, found: [185]"); - } - - @Test - public void itShouldNotFailForAValidGeo() { - EventValidator.validateGeo(88,-178); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/services/DefaultLastReferenceIdManagerTest.java b/src/test/java/com/exceptionless/exceptionlessclient/services/DefaultLastReferenceIdManagerTest.java deleted file mode 100644 index ba70596..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/services/DefaultLastReferenceIdManagerTest.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.exceptionless.exceptionlessclient.services; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class DefaultLastReferenceIdManagerTest { - @Test - public void itCanSetAndClearLastReferenceId() { - DefaultLastReferenceIdManager manager = DefaultLastReferenceIdManager.builder().build(); - - manager.setLast("123"); - assertThat(manager.getLast()).isEqualTo("123"); - - manager.clearLast(); - assertThat(manager.getLast()).isNull(); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/settings/DefaultSettingsClientTest.java b/src/test/java/com/exceptionless/exceptionlessclient/settings/DefaultSettingsClientTest.java deleted file mode 100644 index 9c7009e..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/settings/DefaultSettingsClientTest.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.exceptionless.exceptionlessclient.settings; - -import com.exceptionless.exceptionlessclient.configuration.ValueProvider; -import okhttp3.*; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.io.IOException; -import java.time.Duration; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.doThrow; - -@ExtendWith(MockitoExtension.class) -public class DefaultSettingsClientTest { - @Mock private OkHttpClient httpClient; - @Mock private Call call; - private DefaultSettingsClient settingsClient; - private Response.Builder responseBuilder; - - @BeforeEach - public void setup() { - settingsClient = - new DefaultSettingsClient( - httpClient, - ValueProvider.of(10), - ValueProvider.of("http://test-server-url"), - ValueProvider.of("test-api-key")); - responseBuilder = - new Response.Builder() - .request(new Request.Builder().url("http://test-url").build()) - .protocol(Protocol.HTTP_2) - .message("test-message") - .body(ResponseBody.create("test-body", MediaType.get("text/plain"))) - .code(200); - - OkHttpClient.Builder mockBuilder = Mockito.mock(OkHttpClient.Builder.class); - doReturn(mockBuilder).when(httpClient).newBuilder(); - doReturn(mockBuilder).when(mockBuilder).connectTimeout(Duration.ofMillis(10)); - doReturn(httpClient).when(mockBuilder).build(); - } - - @Test - public void itCanHandleASuccessfulResponse() throws IOException { - doReturn( - responseBuilder - .body( - ResponseBody.create( - "{\n" - + "\t\"version\":1,\n" - + "\t\"settings\":{\n" - + "\t\t\"key\":\"value\"\n" - + "\t}\n" - + "}", - MediaType.get("application/json"))) - .build()) - .when(call) - .execute(); - doReturn(call) - .when(httpClient) - .newCall( - argThat( - httpRequest -> - httpRequest.method().equals("GET") - && httpRequest - .url() - .toString() - .equals( - "http://test-server-url/api/v2/projects/config?v=1&access_token=test-api-key"))); - - SettingsResponse response = settingsClient.getSettings(1); - - assertThat(response.getBody()) - .isEqualTo( - "{\n" - + "\t\"version\":1,\n" - + "\t\"settings\":{\n" - + "\t\t\"key\":\"value\"\n" - + "\t}\n" - + "}"); - assertThat(response.getCode()).isEqualTo(200); - assertThat(response.getSettings()) - .isEqualTo(ServerSettings.builder().version(1L).settings(Map.of("key", "value")).build()); - } - - @Test - public void itCanHandleNullSettingsReturnedByTheServer() throws IOException { - doReturn(responseBuilder.body(null).build()).when(call).execute(); - doReturn(call).when(httpClient).newCall(any()); - - SettingsResponse response = settingsClient.getSettings(1); - - assertThat(response.getBody()).isEqualTo(""); - assertThat(response.getCode()).isEqualTo(200); - assertThat(response.getSettings()).isNull(); - } - - @Test - public void itCanHandleAllExceptions() { - Exception e = new RuntimeException("test"); - doThrow(e).when(httpClient).newCall(any()); - - SettingsResponse response = settingsClient.getSettings(1); - - assertThat(response.hasException()).isTrue(); - assertThat(response.getException()).isSameAs(e); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/settings/ServerSettingsTest.java b/src/test/java/com/exceptionless/exceptionlessclient/settings/ServerSettingsTest.java deleted file mode 100644 index 12737e3..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/settings/ServerSettingsTest.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.exceptionless.exceptionlessclient.settings; - -import org.junit.jupiter.api.Test; - -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; - -public class ServerSettingsTest { - @Test - public void itCanGetTypeAndSourceSetting() { - ServerSettings serverSettings = - ServerSettings.builder() - .settings(Map.of("@@test-type:test-source", "test-setting")) - .build(); - - Optional maybeSetting = - serverSettings.getTypeAndSourceSetting("test-type", "test-source"); - - assertThat(maybeSetting).isPresent(); - assertThat(maybeSetting.get()).isEqualTo("test-setting"); - } - - @Test - public void itCanGetNearestMatchForTypeAndSourceSettingFromTheLongestSetting() { - ServerSettings serverSettings = - ServerSettings.builder() - .settings(Map.of("@@test-type:test-source.*", "test-setting")) - .build(); - - Optional maybeSetting = - serverSettings.getTypeAndSourceSetting("test-type", "test-source"); - - assertThat(maybeSetting).isPresent(); - assertThat(maybeSetting.get()).isEqualTo("test-setting"); - - serverSettings = - ServerSettings.builder() - .settings(Map.of("@@test-type:.*source*", "test-setting-2")) - .build(); - - maybeSetting = serverSettings.getTypeAndSourceSetting("test-type", "test-source"); - - assertThat(maybeSetting).isPresent(); - assertThat(maybeSetting.get()).isEqualTo("test-setting-2"); - - serverSettings = - ServerSettings.builder() - .settings( - Map.of( - "@@test-type:test-source.*", - "test-setting", - "@@test-type:.*source*", - "test-setting-2")) - .build(); - - maybeSetting = serverSettings.getTypeAndSourceSetting("test-type", "test-source"); - - assertThat(maybeSetting).isPresent(); - assertThat(maybeSetting.get()).isEqualTo("test-setting"); - } - - @Test - public void itCanGetNearestMatchForTypeAndSettingForAlphabeticallySmallerSetting() { - ServerSettings serverSettings = - ServerSettings.builder().settings(Map.of("@@test-type:test.*", "test-setting")).build(); - - Optional maybeSetting = - serverSettings.getTypeAndSourceSetting("test-type", "test-source"); - - assertThat(maybeSetting).isPresent(); - assertThat(maybeSetting.get()).isEqualTo("test-setting"); - - serverSettings = - ServerSettings.builder().settings(Map.of("@@test-type:.*so.*", "test-setting-2")).build(); - - maybeSetting = serverSettings.getTypeAndSourceSetting("test-type", "test-source"); - - assertThat(maybeSetting).isPresent(); - assertThat(maybeSetting.get()).isEqualTo("test-setting-2"); - - serverSettings = - ServerSettings.builder() - .settings( - Map.of( - "@@test-type:test.*", "test-setting", "@@test-type:.*so.*", "test-setting-2")) - .build(); - - maybeSetting = serverSettings.getTypeAndSourceSetting("test-type", "test-source"); - - assertThat(maybeSetting).isPresent(); - // '.'(46) is alphabetically smaller that 't'(116) - assertThat(maybeSetting.get()).isEqualTo("test-setting-2"); - } - - @Test - public void itCanGetDataExclusionsIfEmpty() { - ServerSettings serverSettings = ServerSettings.builder().settings(Map.of()).build(); - - assertThat(serverSettings.getDataExclusions()).isEmpty(); - } - - @Test - public void itCanGetDataExclusions() { - ServerSettings serverSettings = - ServerSettings.builder() - .settings(Map.of("@@DataExclusions", "exclusion1,exclusion2")) - .build(); - - assertThat(serverSettings.getDataExclusions()).isEqualTo(Set.of("exclusion1", "exclusion2")); - } - - @Test - public void itCanGetUserAgentBotPatterns() { - ServerSettings serverSettings = - ServerSettings.builder() - .settings(Map.of("@@UserAgentBotPatterns", "pattern1,pattern2")) - .build(); - - assertThat(serverSettings.getUserAgentBotPatterns()).isEqualTo(Set.of("pattern1", "pattern2")); - } - - @Test - public void itCanGetUserAgentBotPatternsIfEmpty() { - ServerSettings serverSettings = ServerSettings.builder().settings(Map.of()).build(); - - assertThat(serverSettings.getUserAgentBotPatterns()).isEmpty(); - } - - @Test - public void itCanGetAsBoolean() { - assertThat(ServerSettings.getAsBoolean("true")).isTrue(); - assertThat(ServerSettings.getAsBoolean("yes")).isTrue(); - assertThat(ServerSettings.getAsBoolean("1")).isTrue(); - assertThat(ServerSettings.getAsBoolean("xxx")).isFalse(); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/settings/SettingsManagerTest.java b/src/test/java/com/exceptionless/exceptionlessclient/settings/SettingsManagerTest.java deleted file mode 100644 index 265bcde..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/settings/SettingsManagerTest.java +++ /dev/null @@ -1,181 +0,0 @@ -package com.exceptionless.exceptionlessclient.settings; - -import com.exceptionless.exceptionlessclient.storage.InMemoryStorage; -import com.exceptionless.exceptionlessclient.storage.InMemoryStorageProvider; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.beans.PropertyChangeListener; -import java.util.Map; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -public class SettingsManagerTest { - @Mock private InMemoryStorageProvider storageProvider; - @Mock private DefaultSettingsClient settingsClient; - @Mock private PropertyChangeListener listener; - private SettingsManager settingsManager; - private InMemoryStorage storage; - - @BeforeEach - public void setup() { - storage = InMemoryStorage.builder().maxItems(1).build(); - doReturn(storage).when(storageProvider).getSettings(); - - settingsManager = - SettingsManager.builder() - .settingsClient(settingsClient) - .storageProvider(storageProvider) - .build(); - } - - @Test - public void itReturnsADefaultValueIfNoServerSettingsArePresent() { - ServerSettings serverSettings = settingsManager.getSavedServerSettings(); - - assertThat(serverSettings.getVersion()).isEqualTo(0); - assertThat(serverSettings.getSettings()).isEmpty(); - } - - @Test - public void itCanGetSavedServerSettings() { - ServerSettings expectedSettings = - ServerSettings.builder().version(1L).settings(Map.of("key", "value")).build(); - storage.save(expectedSettings); - - ServerSettings actualSettings = settingsManager.getSavedServerSettings(); - - assertThat(expectedSettings).isEqualTo(actualSettings); - } - - @Test - public void itDontUpdateSettingsIfCheckedVersionIsLessThanCurrentVersion() { - ServerSettings serverSettings = - ServerSettings.builder().version(3L).settings(Map.of("key", "value")).build(); - storage.save(serverSettings); - - settingsManager.checkVersion(1); - - verifyZeroInteractions(settingsClient); - } - - @Test - public void itDontUpdateSettingsIfCheckedVersionIsEqualToCurrentVersion() { - ServerSettings serverSettings = - ServerSettings.builder().version(3L).settings(Map.of("key", "value")).build(); - storage.save(serverSettings); - - settingsManager.checkVersion(3); - - verifyZeroInteractions(settingsClient); - } - - @Test - public void itCanUpdateSettingsIfCheckedVersionIsGreaterThanCurrentVersion() { - ServerSettings serverSettings = - ServerSettings.builder().version(3L).settings(Map.of("key", "value")).build(); - storage.save(serverSettings); - - ServerSettings newSettingsFromServer = - ServerSettings.builder().version(4L).settings(Map.of("new-key", "new-value")).build(); - doReturn(SettingsResponse.builder().code(200).settings(newSettingsFromServer).build()) - .when(settingsClient) - .getSettings(3); - - settingsManager.checkVersion(4); - - assertThat(storage.peek().getValue()).isEqualTo(newSettingsFromServer); - } - - @Test - public void itWillLetOnlyOneThreadUpdateSettingsAtATime() - throws ExecutionException, InterruptedException { - ServerSettings newSettingsFromServer = - ServerSettings.builder().version(4L).settings(Map.of("new-key", "new-value")).build(); - doAnswer( - invocation -> { - Thread.sleep(1000); - return SettingsResponse.builder().code(200).settings(newSettingsFromServer).build(); - }) - .when(settingsClient) - .getSettings(anyLong()); - - Future future = - Executors.newSingleThreadExecutor().submit(() -> settingsManager.updateSettings()); - settingsManager.updateSettings(); - future.get(); - - verify(settingsClient, times(1)).getSettings(anyLong()); - } - - @Test - public void itWillNotSaveSettingsIfSettingsAreNotModified() { - doReturn(SettingsResponse.builder().code(304).body("test-message").build()) - .when(settingsClient) - .getSettings(anyLong()); - - settingsManager.updateSettings(); - - assertThat(storage.peek()).isNull(); - } - - @Test - public void itWillNotSaveSettingsIfUnableToGetSettingsFromServer() { - doReturn(SettingsResponse.builder().code(400).body("test-message").build()) - .when(settingsClient) - .getSettings(anyLong()); - - settingsManager.updateSettings(); - - assertThat(storage.peek()).isNull(); - } - - @Test - public void itWillNotSaveSettingsIfNoSettingsReturnedByTheServer() { - doReturn(SettingsResponse.builder().code(200).body("test-message").build()) - .when(settingsClient) - .getSettings(anyLong()); - - settingsManager.updateSettings(); - - assertThat(storage.peek()).isNull(); - } - - @Test - public void itCanHandleSettingsClientException() { - doReturn(SettingsResponse.builder().exception(new RuntimeException("test")).build()) - .when(settingsClient) - .getSettings(anyLong()); - - settingsManager.updateSettings(); - } - - @Test - public void itCanSuccessfullyUpdateSettingsAndNotifyListeners() { - ServerSettings newSettingsFromServer = - ServerSettings.builder().version(4L).settings(Map.of("new-key", "new-value")).build(); - doReturn(SettingsResponse.builder().code(200).settings(newSettingsFromServer).build()) - .when(settingsClient) - .getSettings(anyLong()); - - settingsManager.addPropertyChangeListener(listener); - settingsManager.updateSettings(); - - assertThat(storage.peek().getValue()).isEqualTo(newSettingsFromServer); - verify(listener, times(1)) - .propertyChange( - argThat( - event -> - event.getPropertyName().equals("settings") - && ((ServerSettings) event.getOldValue()).getVersion() == 0 - && ((ServerSettings) event.getNewValue()).getVersion() == 4)); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorageProviderTest.java b/src/test/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorageProviderTest.java deleted file mode 100644 index daa3f71..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorageProviderTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.exceptionless.exceptionlessclient.storage; - -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.settings.ServerSettings; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class InMemoryStorageProviderTest { - @Test - public void itCanSetStorageCorrectly() { - InMemoryStorageProvider storageProvider = - InMemoryStorageProvider.builder().maxQueueItems(2).build(); - - storageProvider.getQueue().save(Event.builder().referenceId("123-abcdef").build()); - storageProvider.getQueue().save(Event.builder().referenceId("456-abcdef").build()); - storageProvider.getQueue().save(Event.builder().referenceId("789-abcdef").build()); - - storageProvider.getSettings().save(ServerSettings.builder().version(1L).build()); - storageProvider.getSettings().save(ServerSettings.builder().version(2L).build()); - - assertThat(storageProvider.getSettings().peek().getValue().getVersion()).isEqualTo(2); - assertThat(storageProvider.getQueue().peek().getValue().getReferenceId()) - .isEqualTo("456-abcdef"); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorageTest.java b/src/test/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorageTest.java deleted file mode 100644 index 682b1b2..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorageTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.exceptionless.exceptionlessclient.storage; - -import com.exceptionless.exceptionlessclient.models.storage.StorageItem; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.stream.Collectors; - -import static org.assertj.core.api.Assertions.assertThat; - -public class InMemoryStorageTest { - private InMemoryStorage storage; - - @BeforeEach - public void setup() { - storage = InMemoryStorage.builder().maxItems(2).build(); - } - - @Test - public void itCanSave() { - storage.save(123); - - assertThat(storage.peek().getValue()).isEqualTo(123); - } - - @Test - public void itCanSaveAndRemoveIfExceedTheMaxItemsSize() { - storage.save(123); - storage.save(456); - storage.save(789); - - assertThat(storage.peek().getValue()).isEqualTo(456); - } - - @Test - public void itCanGetItemsForALimitLessThanCurrentItems() { - storage.save(123); - storage.save(456); - - List> items = storage.get(1); - assertThat(items.stream().map(StorageItem::getValue).collect(Collectors.toList())) - .isEqualTo(List.of(123)); - } - - @Test - public void itCanGetItemsForALimitEqualToCurrentItems() { - storage.save(123); - storage.save(456); - - List> items = storage.get(2); - assertThat(items.stream().map(StorageItem::getValue).collect(Collectors.toList())) - .isEqualTo(List.of(123, 456)); - } - - @Test - public void itCanGetItemsForALimitMoreThanCurrentItems() { - storage.save(123); - storage.save(456); - - List> items = storage.get(3); - assertThat(items.stream().map(StorageItem::getValue).collect(Collectors.toList())) - .isEqualTo(List.of(123, 456)); - } - - @Test - public void itCanRemoveByTimestamp() { - storage.save(123); - StorageItem item = storage.peek(); - storage.remove(item.getTimestamp()); - - assertThat(storage.peek()).isNull(); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/submission/DefaultSubmissionClientTest.java b/src/test/java/com/exceptionless/exceptionlessclient/submission/DefaultSubmissionClientTest.java deleted file mode 100644 index 630d92f..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/submission/DefaultSubmissionClientTest.java +++ /dev/null @@ -1,215 +0,0 @@ -package com.exceptionless.exceptionlessclient.submission; - -import com.exceptionless.exceptionlessclient.configuration.ValueProvider; -import com.exceptionless.exceptionlessclient.models.Event; -import com.exceptionless.exceptionlessclient.models.UserDescription; -import com.exceptionless.exceptionlessclient.settings.SettingsManager; -import okhttp3.*; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.io.IOException; -import java.time.Duration; -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -public class DefaultSubmissionClientTest { - @Mock private SettingsManager settingsManager; - @Mock private OkHttpClient httpClient; - @Mock private Call call; - private DefaultSubmissionClient submissionClient; - private Response.Builder responseBuilder; - - @BeforeEach - public void setup() { - submissionClient = - new DefaultSubmissionClient( - httpClient, - settingsManager, - ValueProvider.of(10), - ValueProvider.of("http://test-server-url"), - ValueProvider.of("test-api-key"), - ValueProvider.of("http://test-heartbeat-server-url")); - responseBuilder = - new Response.Builder() - .request(new Request.Builder().url("http://test-url").build()) - .protocol(Protocol.HTTP_2) - .body(ResponseBody.create("test-body", MediaType.get("text/plain"))) - .message("test-message") - .code(200); - OkHttpClient.Builder mockBuilder = Mockito.mock(OkHttpClient.Builder.class); - doReturn(mockBuilder).when(httpClient).newBuilder(); - doReturn(mockBuilder).when(mockBuilder).connectTimeout(Duration.ofMillis(10)); - doReturn(httpClient).when(mockBuilder).build(); - } - - @Test - public void itCanPostEventsSuccessfully() throws IOException { - Response response = - responseBuilder.headers(Headers.of(Map.of("x-exceptionless-configversion", "3"))).build(); - doReturn(response).when(call).execute(); - doReturn(call) - .when(httpClient) - .newCall( - argThat( - request -> - request - .url() - .toString() - .equals( - "http://test-server-url/api/v2/events?access_token=test-api-key") - && request.method().equals("POST"))); - - SubmissionResponse submissionResponse = - submissionClient.postEvents(List.of(Event.builder().build())); - - assertThat(submissionResponse.getBody()).isEqualTo("test-body"); - assertThat(submissionResponse.getCode()).isEqualTo(200); - verify(settingsManager, times(1)).checkVersion(3); - } - - @Test - public void itCanDetectRateLimitingFromHeaders() throws IOException { - Response response = - responseBuilder - .headers( - Headers.of( - Map.of("x-exceptionless-configversion", "3", "x-ratelimit-remaining", "0"))) - .build(); - doReturn(response).when(call).execute(); - doReturn(call) - .when(httpClient) - .newCall( - argThat( - request -> - request - .url() - .toString() - .equals( - "http://test-server-url/api/v2/events?access_token=test-api-key") - && request.method().equals("POST"))); - - SubmissionResponse submissionResponse = - submissionClient.postEvents(List.of(Event.builder().build())); - - assertThat(submissionResponse.getBody()).isEqualTo("test-body"); - assertThat(submissionResponse.getCode()).isEqualTo(200); - verify(settingsManager, times(1)).checkVersion(3); - assertThat(submissionResponse.isRateLimited()).isTrue(); - } - - @Test - public void itCanPostEventsSuccessfullyWhenNoSettingHeaderIsReturned() throws IOException { - doReturn(responseBuilder.build()).when(call).execute(); - doReturn(call).when(httpClient).newCall(any()); - - SubmissionResponse submissionResponse = - submissionClient.postEvents(List.of(Event.builder().build())); - - assertThat(submissionResponse.getBody()).isEqualTo("test-body"); - assertThat(submissionResponse.getCode()).isEqualTo(200); - verifyZeroInteractions(settingsManager); - } - - @Test - public void itCanHandleAllExceptionsWhilePostingEvents() { - Exception exception = new RuntimeException("test"); - doThrow(exception).when(httpClient).newCall(any()); - - SubmissionResponse response = submissionClient.postEvents(List.of(Event.builder().build())); - - assertThat(response.hasException()).isTrue(); - assertThat(response.getException()).isSameAs(exception); - } - - @Test - public void itCanPostUserDescriptionSuccessfully() throws IOException { - Response response = - responseBuilder.headers(Headers.of(Map.of("x-exceptionless-configversion", "3"))).build(); - doReturn(response).when(call).execute(); - doReturn(call) - .when(httpClient) - .newCall( - argThat( - request -> - request - .url() - .toString() - .equals( - "http://test-server-url/api/v2/events/by-ref/test-reference-id/user-description?access_token=test-api-key") - && request.method().equals("POST"))); - - SubmissionResponse submissionResponse = - submissionClient.postUserDescription( - "test-reference-id", UserDescription.builder().build()); - - assertThat(submissionResponse.getBody()).isEqualTo("test-body"); - assertThat(submissionResponse.getCode()).isEqualTo(200); - verify(settingsManager, times(1)).checkVersion(3); - } - - @Test - public void itCanPostUserDescriptionSuccessfullyWhenNoSettingHeaderIsReturned() - throws IOException { - doReturn(responseBuilder.build()).when(call).execute(); - doReturn(call).when(httpClient).newCall(any()); - - SubmissionResponse submissionResponse = - submissionClient.postUserDescription( - "test-reference-id", UserDescription.builder().build()); - - assertThat(submissionResponse.getBody()).isEqualTo("test-body"); - assertThat(submissionResponse.getCode()).isEqualTo(200); - verifyZeroInteractions(settingsManager); - } - - @Test - public void itCanHandleAllExceptionsWhilePostingUserDescription() { - Exception exception = new RuntimeException("test"); - doThrow(exception).when(httpClient).newCall(any()); - - SubmissionResponse response = - submissionClient.postUserDescription( - "test-reference-id", UserDescription.builder().build()); - - assertThat(response.hasException()).isTrue(); - assertThat(response.getException()).isSameAs(exception); - } - - @Test - public void itCanSendHeartbeatSuccessfully() throws IOException { - doReturn(responseBuilder.build()).when(call).execute(); - doReturn(call) - .when(httpClient) - .newCall( - argThat( - request -> - request - .url() - .toString() - .equals( - "http://test-heartbeat-server-url/api/v2/events/session/heartbeat?id=test-user-id&close=true&access_token=test-api-key") - && request.method().equals("GET"))); - - submissionClient.sendHeartBeat("test-user-id", true); - - verify(call, times(1)).execute(); - } - - @Test - public void itCanHandleAllExceptionsWhileSendingHeartbeat() { - doThrow(new RuntimeException("test")).when(httpClient).newCall(any()); - - submissionClient.sendHeartBeat("test-user-id", true); - } -} diff --git a/src/test/java/com/exceptionless/exceptionlessclient/utils/UtilsTest.java b/src/test/java/com/exceptionless/exceptionlessclient/utils/UtilsTest.java deleted file mode 100644 index bf1fc82..0000000 --- a/src/test/java/com/exceptionless/exceptionlessclient/utils/UtilsTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.exceptionless.exceptionlessclient.utils; - -import com.fasterxml.jackson.core.JsonProcessingException; -import org.junit.jupiter.api.Test; - -import java.net.URI; -import java.net.http.HttpRequest; -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -public class UtilsTest { - @Test - public void itCanSafelyGetANull() { - //noinspection ConstantConditions - assertThat(Utils.safeGetAs(null, String.class)).isNull(); - } - - @Test - public void itCanSafelyGetANotCastableValue() { - assertThat(Utils.safeGetAs(1L, String.class)).isNull(); - } - - @Test - public void itCanSafelyGetAValue() { - assertThat(Utils.safeGetAs("abc", String.class)).isEqualTo("abc"); - } - - @Test - public void itCanGetEmptyCookiesIfCookieHeaderIsNotPresent() { - assertThat( - Utils.getCookies( - HttpRequest.newBuilder().uri(URI.create("http://localhost:5000")).build())) - .isEmpty(); - } - - @Test - public void itCanGetCookiesFromTheHeader() { - assertThat( - Utils.getCookies( - HttpRequest.newBuilder() - .uri(URI.create("http://localhost:5000")) - .header("Cookie", "cookie1=value1;cookie2=value2") - .build())) - .isEqualTo(Map.of("cookie1", "value1", "cookie2", "value2")); - } - - @Test - public void itCanGetEmptyQueryParams() { - assertThat(Utils.getQueryParams(URI.create("http://localhost:5000"))).isEmpty(); - assertThat(Utils.getQueryParams(URI.create("http://localhost:5000?"))).isEmpty(); - } - - @Test - public void itCanGetQueryParams() { - assertThat( - Utils.getQueryParams(URI.create("http://localhost:5000?param1=value1¶m2=value2"))) - .isEqualTo(Map.of("param1", List.of("value1"), "param2", List.of("value2"))); - } - - @Test - public void itCanGetArrayQueryParams() { - assertThat( - Utils.getQueryParams(URI.create("http://localhost:5000?param1=value1¶m1=value2"))) - .isEqualTo(Map.of("param1", List.of("value1", "value2"))); - } - - @Test - public void itDontMatchForNullValue() { - assertThat(Utils.match(null, "abc")).isFalse(); - } - - @Test - public void itDontMatchForNullPattern() { - assertThat(Utils.match("abc", null)).isFalse(); - } - - @Test - public void itCanMatchAValueToAPattern() { - assertThat(Utils.match("abc", "def")).isFalse(); - assertThat(Utils.match("ABC", "abc")).isTrue(); - } - - @Test - public void itCanSerializeEmptyBeans() throws JsonProcessingException { - class EmptyBean {} - - assertThat(Utils.JSON_MAPPER.writeValueAsString(new EmptyBean())).isEqualTo("{}"); - } -} diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker deleted file mode 100644 index ca6ee9c..0000000 --- a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker +++ /dev/null @@ -1 +0,0 @@ -mock-maker-inline \ No newline at end of file