From f80879f3ea0fe88c0bcfb3d8796cadae587aab93 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 19 Oct 2026 16:34:55 -0500 Subject: [PATCH 1/9] Modernize to a first-class Exceptionless client (2.0) Rewrite the client as a multi-module Java 17 library that matches the current Exceptionless server contract, with integrations for Spring Boot, Servlet, Logback, Log4j 2 and java.util.logging. - exceptionless-core: zero runtime dependencies, snake_case wire format, full cause/suppressed chains, server settings (@@log, @@error, @@DataExclusions, bot patterns), sessions and heartbeats, status-aware queue with optional file persistence, shutdown flush and uncaught exception capture on a single daemon thread - logging appenders, servlet filter and Spring Boot 3/4 starter - BOM, Maven wrapper, Spotless, JaCoCo, Central Portal release workflow - 368 unit/integration tests plus a Testcontainers e2e suite against exceptionless/exceptionless:8.10.0 - new README, MIGRATION.md, CONTRIBUTING.md, samples and agent skill Closes #91 Co-Authored-By: Claude Opus 5.5 --- .agents/skills/exceptionless-java/SKILL.md | 87 ++ .../exceptionless-java/agents/openai.yaml | 4 + .github/dependabot.yml | 22 + .github/workflows/build.yml | 76 ++ .github/workflows/greetings.yml | 13 - .github/workflows/maven-publish.yml | 31 - .github/workflows/maven.yml | 25 - .github/workflows/release.yml | 42 + .github/workflows/stale.yml | 22 +- .gitignore | 5 +- .mvn/wrapper/maven-wrapper.properties | 3 + .vscode/extensions.json | 5 +- .vscode/settings.json | 9 +- .vscode/tasks.json | 10 +- CONTRIBUTING.md | 43 +- MIGRATION.md | 41 + README.md | 335 ++++-- exceptionless-bom/pom.xml | 66 ++ exceptionless-core/pom.xml | 65 ++ .../java/com/exceptionless/EventBuilder.java | 327 ++++++ .../java/com/exceptionless/Exceptionless.java | 187 ++++ .../exceptionless/ExceptionlessClient.java | 774 ++++++++++++++ .../ExceptionlessConfiguration.java | 980 ++++++++++++++++++ .../main/java/com/exceptionless/LogLevel.java | 60 ++ .../com/exceptionless/SubmittingEvent.java | 41 + .../exceptionless/internal/ClientInfo.java | 48 + .../internal/DaemonThreadFactory.java | 21 + .../internal/DataNormalizer.java | 313 ++++++ .../internal/EnvironmentCollector.java | 164 +++ .../internal/ErrorConverter.java | 221 ++++ .../exceptionless/internal/Exclusions.java | 19 + .../java/com/exceptionless/internal/Ids.java | 34 + .../java/com/exceptionless/internal/Log.java | 57 + .../internal/ModuleCollector.java | 169 +++ .../com/exceptionless/internal/Strings.java | 27 + .../com/exceptionless/internal/Wildcard.java | 52 + .../exceptionless/internal/package-info.java | 5 + .../com/exceptionless/json/JsonParser.java | 261 +++++ .../exceptionless/json/JsonSerializable.java | 10 + .../com/exceptionless/json/JsonWriter.java | 308 ++++++ .../com/exceptionless/json/package-info.java | 5 + .../exceptionless/models/EnvironmentInfo.java | 199 ++++ .../com/exceptionless/models/ErrorInfo.java | 100 ++ .../java/com/exceptionless/models/Event.java | 275 +++++ .../com/exceptionless/models/EventTags.java | 9 + .../com/exceptionless/models/EventTypes.java | 14 + .../exceptionless/models/KnownDataKeys.java | 24 + .../models/ManualStackingInfo.java | 30 + .../com/exceptionless/models/ModuleInfo.java | 73 ++ .../com/exceptionless/models/RequestInfo.java | 159 +++ .../com/exceptionless/models/StackFrame.java | 120 +++ .../exceptionless/models/UserDescription.java | 26 + .../com/exceptionless/models/UserInfo.java | 30 + .../exceptionless/models/package-info.java | 5 + .../java/com/exceptionless/package-info.java | 10 + .../CancelSessionsWithNoUserPlugin.java | 31 + .../plugins/ConfigurationDefaultsPlugin.java | 45 + .../exceptionless/plugins/ContextData.java | 71 ++ .../plugins/DuplicateCheckerPlugin.java | 142 +++ .../plugins/EnvironmentInfoPlugin.java | 29 + .../exceptionless/plugins/ErrorPlugin.java | 78 ++ .../plugins/EventExclusionPlugin.java | 92 ++ .../exceptionless/plugins/EventPlugin.java | 29 + .../plugins/EventPluginContext.java | 48 + .../plugins/HeartbeatPlugin.java | 85 ++ .../plugins/IgnoreUserAgentPlugin.java | 29 + .../plugins/ReferenceIdPlugin.java | 28 + .../plugins/RequestInfoPlugin.java | 125 +++ .../plugins/SessionIdManagementPlugin.java | 40 + .../plugins/SubmissionMethodPlugin.java | 19 + .../exceptionless/plugins/VersionPlugin.java | 50 + .../exceptionless/plugins/package-info.java | 8 + .../queue/DefaultEventQueue.java | 294 ++++++ .../com/exceptionless/queue/EventQueue.java | 34 + .../com/exceptionless/queue/package-info.java | 5 + .../settings/ClientSettings.java | 178 ++++ .../settings/SettingsManager.java | 103 ++ .../exceptionless/settings/package-info.java | 5 + .../storage/FileKeyValueStorage.java | 59 ++ .../storage/FileQueueStorage.java | 164 +++ .../storage/InMemoryKeyValueStorage.java | 20 + .../storage/InMemoryQueueStorage.java | 92 ++ .../storage/KeyValueStorage.java | 10 + .../com/exceptionless/storage/QueueEntry.java | 13 + .../exceptionless/storage/QueueStorage.java | 23 + .../exceptionless/storage/package-info.java | 5 + .../submission/DefaultSubmissionClient.java | 196 ++++ .../submission/SettingsResponse.java | 33 + .../submission/SubmissionClient.java | 21 + .../submission/SubmissionResponse.java | 55 + .../submission/package-info.java | 5 + .../src/main/java/module-info.java | 16 + .../com/exceptionless/client.properties | 1 + .../com/exceptionless/ClientSmokeTest.java | 41 + .../ExceptionlessClientTest.java | 512 +++++++++ .../ExceptionlessConfigurationTest.java | 190 ++++ .../com/exceptionless/ExceptionlessTest.java | 84 ++ .../java/com/exceptionless/ExitProbe.java | 20 + .../java/com/exceptionless/FakeServer.java | 169 +++ .../java/com/exceptionless/JvmExitTest.java | 64 ++ .../java/com/exceptionless/TestClients.java | 93 ++ .../internal/ClientInfoTest.java | 29 + .../internal/CollectorsTest.java | 48 + .../internal/DataNormalizerTest.java | 255 +++++ .../internal/ErrorConverterTest.java | 247 +++++ .../com/exceptionless/internal/IdsTest.java | 37 + .../exceptionless/internal/WildcardTest.java | 45 + .../exceptionless/json/JsonParserTest.java | 81 ++ .../exceptionless/json/JsonWriterTest.java | 111 ++ .../models/WireContractTest.java | 168 +++ .../plugins/DefaultPluginsTest.java | 350 +++++++ .../plugins/DuplicateCheckerPluginTest.java | 118 +++ .../plugins/EventExclusionPluginTest.java | 121 +++ .../plugins/PluginTestSupport.java | 27 + .../queue/DefaultEventQueueTest.java | 281 +++++ .../settings/ClientSettingsTest.java | 165 +++ .../settings/SettingsManagerTest.java | 107 ++ .../storage/QueueStorageTest.java | 137 +++ .../DefaultSubmissionClientTest.java | 157 +++ .../test/resources/exceptionless.properties | 4 + exceptionless-e2e-tests/README.md | 55 + exceptionless-e2e-tests/pom.xml | 103 ++ .../e2e/ErrorResponseE2ETest.java | 101 ++ .../e2e/EventWireFormatE2ETest.java | 498 +++++++++ .../com/exceptionless/e2e/SessionE2ETest.java | 123 +++ .../exceptionless/e2e/SettingsE2ETest.java | 141 +++ .../e2e/StackingSignatureE2ETest.java | 118 +++ .../com/exceptionless/e2e/support/Await.java | 59 ++ .../e2e/support/ExceptionlessServer.java | 112 ++ .../com/exceptionless/e2e/support/Json.java | 86 ++ .../exceptionless/e2e/support/JsonDiff.java | 88 ++ .../support/RecordingSubmissionClient.java | 94 ++ .../exceptionless/e2e/support/ServerApi.java | 216 ++++ .../e2e/support/TestProject.java | 137 +++ exceptionless-jul/pom.xml | 49 + .../jul/ExceptionlessHandler.java | 392 +++++++ .../com/exceptionless/jul/package-info.java | 5 + .../jul/CapturingSubmissionClient.java | 77 ++ .../jul/ExceptionlessHandlerTest.java | 481 +++++++++ .../com/exceptionless/jul/FakeServer.java | 169 +++ .../exceptionless-logging-test.properties | 11 + exceptionless-log4j2/pom.xml | 81 ++ .../log4j2/ExceptionlessAppender.java | 393 +++++++ .../exceptionless/log4j2/package-info.java | 8 + .../log4j2/CapturingSubmissionClient.java | 77 ++ .../log4j2/ExceptionlessAppenderTest.java | 354 +++++++ .../com/exceptionless/log4j2/FakeServer.java | 169 +++ .../resources/exceptionless-log4j2-test.xml | 15 + exceptionless-logback/pom.xml | 56 + .../logback/ExceptionlessAppender.java | 323 ++++++ .../exceptionless/logback/package-info.java | 5 + .../logback/CapturingSubmissionClient.java | 77 ++ .../logback/ExceptionlessAppenderTest.java | 362 +++++++ .../com/exceptionless/logback/FakeServer.java | 169 +++ .../resources/exceptionless-logback-test.xml | 12 + exceptionless-servlet/pom.xml | 69 ++ .../servlet/ExceptionlessFilter.java | 283 +++++ .../servlet/ServletRequestInfoFactory.java | 175 ++++ .../servlet/ServletRequestInfoPlugin.java | 100 ++ .../exceptionless/servlet/package-info.java | 9 + .../servlet/ExceptionlessFilterTest.java | 456 ++++++++ .../exceptionless/servlet/JettyServer.java | 89 ++ .../servlet/RecordingSubmissionClient.java | 95 ++ .../ServletRequestInfoFactoryTest.java | 40 + exceptionless-spring-boot-starter/pom.xml | 138 +++ .../boot/ExceptionlessAutoConfiguration.java | 187 ++++ .../ExceptionlessConfigurationCustomizer.java | 22 + .../spring/boot/ExceptionlessProperties.java | 211 ++++ .../spring/boot/ExceptionlessWebFilter.java | 128 +++ .../boot/LogbackAppenderRegistration.java | 77 ++ .../boot/OrderedExceptionlessFilter.java | 23 + .../boot/ServerHttpRequestInfoFactory.java | 113 ++ .../spring/boot/package-info.java | 8 + ...itional-spring-configuration-metadata.json | 16 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../ExceptionlessAutoConfigurationTest.java | 315 ++++++ .../boot/ExceptionlessWebFilterTest.java | 198 ++++ .../exceptionless/spring/boot/FakeServer.java | 169 +++ .../boot/RecordingSubmissionClient.java | 95 ++ .../spring/boot/mvc/MvcIntegrationTest.java | 164 +++ .../spring/boot/mvc/MvcTestApplication.java | 32 + mvnw | 295 ++++++ mvnw.cmd | 189 ++++ pom.xml | 527 ++++++---- samples/console/pom.xml | 33 + .../java/com/example/console/ConsoleApp.java | 101 ++ .../src/main/resources/logging.properties | 5 + samples/example-app/pom.xml | 32 - .../exceptionless/example/app/ExampleApp.java | 37 - samples/example-spring-boot-app/pom.xml | 47 - .../example/spring/boot/app/ExampleAPI.java | 20 - .../spring/boot/app/api/ExampleResource.java | 26 - samples/spring-boot/pom.xml | 72 ++ .../example/springboot/OrderController.java | 55 + .../example/springboot/SampleApplication.java | 21 + .../src/main/resources/application.yml | 7 + .../ExceptionlessClient.java | 221 ---- .../configuration/Configuration.java | 360 ------- .../PrivateInformationInclusions.java | 94 -- .../configuration/ValueProvider.java | 20 - .../enums/EnvironmentInfoPropertyKey.java | 18 - .../enums/EventPropertyKey.java | 25 - .../exceptionlessclient/enums/EventTag.java | 15 - .../exceptionlessclient/enums/EventType.java | 19 - .../enums/ServerSettingKey.java | 16 - .../exceptions/BadEventDataException.java | 7 - .../exceptions/InvalidApiKeyException.java | 7 - .../logging/LogCapturerAppender.java | 42 - .../logging/LogCapturerIF.java | 15 - .../logging/NullLogCapturer.java | 26 - .../models/EnvironmentInfo.java | 27 - .../exceptionlessclient/models/Event.java | 263 ----- .../models/EventPluginContext.java | 18 - .../models/ManualStackingInfo.java | 13 - .../exceptionlessclient/models/Module.java | 20 - .../models/PluginContext.java | 36 - .../models/RequestInfo.java | 33 - .../models/UserDescription.java | 14 - .../exceptionlessclient/models/UserInfo.java | 14 - .../models/base/Model.java | 17 - .../models/error/Error.java | 17 - .../models/error/InnerError.java | 22 - .../models/error/Method.java | 23 - .../models/error/Parameter.java | 18 - .../models/error/StackFrame.java | 14 - .../models/storage/StorageItem.java | 11 - .../plugins/EventPluginIF.java | 14 - .../plugins/EventPluginRunner.java | 52 - .../plugins/MergedEvent.java | 36 - .../plugins/PluginManager.java | 90 -- .../ConfigurationDefaultsPlugin.java | 30 - .../preconfigured/DuplicateCheckerPlugin.java | 131 --- .../preconfigured/EnvironmentInfoPlugin.java | 124 --- .../plugins/preconfigured/ErrorPlugin.java | 67 -- .../preconfigured/EventExclusionPlugin.java | 127 --- .../preconfigured/HeartbeatPlugin.java | 72 -- .../preconfigured/ModuleInfoPlugin.java | 47 - .../preconfigured/RequestInfoPlugin.java | 128 --- .../preconfigured/SubmissionMethodPlugin.java | 29 - .../args/EnvironmentInfoGetArgs.java | 19 - .../args/RequestInfoGetArgs.java | 29 - .../queue/DefaultEventQueue.java | 271 ----- .../queue/EventDataFilter.java | 65 -- .../queue/EventQueueIF.java | 18 - .../queue/EventValidator.java | 37 - .../DefaultLastReferenceIdManager.java | 25 - .../services/LastReferenceIdManagerIF.java | 7 - .../settings/DefaultSettingsClient.java | 85 -- .../settings/ServerSettings.java | 75 -- .../settings/SettingsClientIF.java | 6 - .../settings/SettingsManager.java | 114 -- .../settings/SettingsResponse.java | 25 - .../storage/InMemoryStorage.java | 59 -- .../storage/InMemoryStorageProvider.java | 31 - .../storage/StorageIF.java | 17 - .../storage/StorageProviderIF.java | 10 - .../submission/DefaultSubmissionClient.java | 157 --- .../submission/EventRequest.java | 23 - .../submission/SubmissionClientIF.java | 17 - .../submission/SubmissionMapper.java | 31 - .../submission/SubmissionResponse.java | 49 - .../submission/UserDescriptionRequest.java | 14 - .../exceptionlessclient/utils/Utils.java | 91 -- .../utils/VisibleForTesting.java | 3 - .../ExceptionlessClientTest.java | 304 ------ .../exceptionlessclient/TestFixtures.java | 17 - .../configuration/ConfigurationTest.java | 220 ---- .../configuration/PluginManagerTest.java | 103 -- .../PrivateInformationInclusionsTest.java | 53 - .../plugins/EventPluginRunnerTest.java | 72 -- .../plugins/MergedEventTest.java | 67 -- .../ConfigurationDefaultsPluginTest.java | 53 - .../DuplicateCheckerPluginTest.java | 81 -- .../EnvironmentInfoPluginTest.java | 58 -- .../preconfigured/ErrorPluginTest.java | 56 - .../EventExclusionPluginTest.java | 289 ------ .../preconfigured/HeartbeatPluginTest.java | 61 -- .../preconfigured/ModuleInfoPluginTest.java | 64 -- .../preconfigured/RequestInfoPluginTest.java | 230 ---- .../SubmissionMethodPluginTest.java | 46 - .../queue/DefaultEventQueueTest.java | 359 ------- .../queue/EventDataFilterTest.java | 62 -- .../queue/EventValidatorTest.java | 66 -- .../DefaultLastReferenceIdManagerTest.java | 18 - .../settings/DefaultSettingsClientTest.java | 116 --- .../settings/ServerSettingsTest.java | 139 --- .../settings/SettingsManagerTest.java | 181 ---- .../storage/InMemoryStorageProviderTest.java | 26 - .../storage/InMemoryStorageTest.java | 74 -- .../DefaultSubmissionClientTest.java | 215 ---- .../exceptionlessclient/utils/UtilsTest.java | 91 -- .../org.mockito.plugins.MockMaker | 1 - 292 files changed, 21351 insertions(+), 7340 deletions(-) create mode 100644 .agents/skills/exceptionless-java/SKILL.md create mode 100644 .agents/skills/exceptionless-java/agents/openai.yaml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build.yml delete mode 100644 .github/workflows/greetings.yml delete mode 100644 .github/workflows/maven-publish.yml delete mode 100644 .github/workflows/maven.yml create mode 100644 .github/workflows/release.yml create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 MIGRATION.md create mode 100644 exceptionless-bom/pom.xml create mode 100644 exceptionless-core/pom.xml create mode 100644 exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/Exceptionless.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/LogLevel.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/SubmittingEvent.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/ClientInfo.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/DaemonThreadFactory.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/DataNormalizer.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/EnvironmentCollector.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/ErrorConverter.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/Exclusions.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/Ids.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/Log.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/ModuleCollector.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/Strings.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/Wildcard.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/package-info.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/json/JsonParser.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/json/JsonSerializable.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/json/JsonWriter.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/json/package-info.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/models/EnvironmentInfo.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/models/ErrorInfo.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/models/Event.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/models/EventTags.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/models/EventTypes.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/models/KnownDataKeys.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/models/ManualStackingInfo.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/models/ModuleInfo.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/models/RequestInfo.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/models/StackFrame.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/models/UserDescription.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/models/UserInfo.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/models/package-info.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/package-info.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/CancelSessionsWithNoUserPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/ConfigurationDefaultsPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/ContextData.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/DuplicateCheckerPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/EnvironmentInfoPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/ErrorPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/EventExclusionPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/EventPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/EventPluginContext.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/HeartbeatPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/IgnoreUserAgentPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/ReferenceIdPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/RequestInfoPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/SessionIdManagementPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/SubmissionMethodPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/VersionPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/package-info.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/queue/EventQueue.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/queue/package-info.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/settings/ClientSettings.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/settings/SettingsManager.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/settings/package-info.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/storage/FileKeyValueStorage.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/storage/FileQueueStorage.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/storage/InMemoryKeyValueStorage.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/storage/InMemoryQueueStorage.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/storage/KeyValueStorage.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/storage/QueueEntry.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/storage/QueueStorage.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/storage/package-info.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/submission/DefaultSubmissionClient.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/submission/SettingsResponse.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionClient.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionResponse.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/submission/package-info.java create mode 100644 exceptionless-core/src/main/java/module-info.java create mode 100644 exceptionless-core/src/main/resources/com/exceptionless/client.properties create mode 100644 exceptionless-core/src/test/java/com/exceptionless/ClientSmokeTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/ExceptionlessClientTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/ExceptionlessTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/ExitProbe.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/FakeServer.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/JvmExitTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/TestClients.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/internal/ClientInfoTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/internal/CollectorsTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/internal/DataNormalizerTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/internal/ErrorConverterTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/internal/IdsTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/internal/WildcardTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/json/JsonParserTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/json/JsonWriterTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/plugins/DefaultPluginsTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/plugins/DuplicateCheckerPluginTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/plugins/EventExclusionPluginTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/plugins/PluginTestSupport.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/queue/DefaultEventQueueTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/settings/ClientSettingsTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/settings/SettingsManagerTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/storage/QueueStorageTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/submission/DefaultSubmissionClientTest.java create mode 100644 exceptionless-core/src/test/resources/exceptionless.properties create mode 100644 exceptionless-e2e-tests/README.md create mode 100644 exceptionless-e2e-tests/pom.xml create mode 100644 exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/ErrorResponseE2ETest.java create mode 100644 exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/EventWireFormatE2ETest.java create mode 100644 exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/SessionE2ETest.java create mode 100644 exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/SettingsE2ETest.java create mode 100644 exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/StackingSignatureE2ETest.java create mode 100644 exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/Await.java create mode 100644 exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/ExceptionlessServer.java create mode 100644 exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/Json.java create mode 100644 exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/JsonDiff.java create mode 100644 exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/RecordingSubmissionClient.java create mode 100644 exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/ServerApi.java create mode 100644 exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/TestProject.java create mode 100644 exceptionless-jul/pom.xml create mode 100644 exceptionless-jul/src/main/java/com/exceptionless/jul/ExceptionlessHandler.java create mode 100644 exceptionless-jul/src/main/java/com/exceptionless/jul/package-info.java create mode 100644 exceptionless-jul/src/test/java/com/exceptionless/jul/CapturingSubmissionClient.java create mode 100644 exceptionless-jul/src/test/java/com/exceptionless/jul/ExceptionlessHandlerTest.java create mode 100644 exceptionless-jul/src/test/java/com/exceptionless/jul/FakeServer.java create mode 100644 exceptionless-jul/src/test/resources/exceptionless-logging-test.properties create mode 100644 exceptionless-log4j2/pom.xml create mode 100644 exceptionless-log4j2/src/main/java/com/exceptionless/log4j2/ExceptionlessAppender.java create mode 100644 exceptionless-log4j2/src/main/java/com/exceptionless/log4j2/package-info.java create mode 100644 exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/CapturingSubmissionClient.java create mode 100644 exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/ExceptionlessAppenderTest.java create mode 100644 exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/FakeServer.java create mode 100644 exceptionless-log4j2/src/test/resources/exceptionless-log4j2-test.xml create mode 100644 exceptionless-logback/pom.xml create mode 100644 exceptionless-logback/src/main/java/com/exceptionless/logback/ExceptionlessAppender.java create mode 100644 exceptionless-logback/src/main/java/com/exceptionless/logback/package-info.java create mode 100644 exceptionless-logback/src/test/java/com/exceptionless/logback/CapturingSubmissionClient.java create mode 100644 exceptionless-logback/src/test/java/com/exceptionless/logback/ExceptionlessAppenderTest.java create mode 100644 exceptionless-logback/src/test/java/com/exceptionless/logback/FakeServer.java create mode 100644 exceptionless-logback/src/test/resources/exceptionless-logback-test.xml create mode 100644 exceptionless-servlet/pom.xml create mode 100644 exceptionless-servlet/src/main/java/com/exceptionless/servlet/ExceptionlessFilter.java create mode 100644 exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoFactory.java create mode 100644 exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoPlugin.java create mode 100644 exceptionless-servlet/src/main/java/com/exceptionless/servlet/package-info.java create mode 100644 exceptionless-servlet/src/test/java/com/exceptionless/servlet/ExceptionlessFilterTest.java create mode 100644 exceptionless-servlet/src/test/java/com/exceptionless/servlet/JettyServer.java create mode 100644 exceptionless-servlet/src/test/java/com/exceptionless/servlet/RecordingSubmissionClient.java create mode 100644 exceptionless-servlet/src/test/java/com/exceptionless/servlet/ServletRequestInfoFactoryTest.java create mode 100644 exceptionless-spring-boot-starter/pom.xml create mode 100644 exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessAutoConfiguration.java create mode 100644 exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessConfigurationCustomizer.java create mode 100644 exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessProperties.java create mode 100644 exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessWebFilter.java create mode 100644 exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/LogbackAppenderRegistration.java create mode 100644 exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/OrderedExceptionlessFilter.java create mode 100644 exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ServerHttpRequestInfoFactory.java create mode 100644 exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/package-info.java create mode 100644 exceptionless-spring-boot-starter/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 exceptionless-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/ExceptionlessAutoConfigurationTest.java create mode 100644 exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/ExceptionlessWebFilterTest.java create mode 100644 exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/FakeServer.java create mode 100644 exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/RecordingSubmissionClient.java create mode 100644 exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/mvc/MvcIntegrationTest.java create mode 100644 exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/mvc/MvcTestApplication.java create mode 100755 mvnw create mode 100644 mvnw.cmd create mode 100644 samples/console/pom.xml create mode 100644 samples/console/src/main/java/com/example/console/ConsoleApp.java create mode 100644 samples/console/src/main/resources/logging.properties delete mode 100644 samples/example-app/pom.xml delete mode 100644 samples/example-app/src/main/java/com/exceptionless/example/app/ExampleApp.java delete mode 100644 samples/example-spring-boot-app/pom.xml delete mode 100644 samples/example-spring-boot-app/src/main/java/com/exceptionless/example/spring/boot/app/ExampleAPI.java delete mode 100644 samples/example-spring-boot-app/src/main/java/com/exceptionless/example/spring/boot/app/api/ExampleResource.java create mode 100644 samples/spring-boot/pom.xml create mode 100644 samples/spring-boot/src/main/java/com/example/springboot/OrderController.java create mode 100644 samples/spring-boot/src/main/java/com/example/springboot/SampleApplication.java create mode 100644 samples/spring-boot/src/main/resources/application.yml delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/ExceptionlessClient.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/configuration/Configuration.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/configuration/PrivateInformationInclusions.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/configuration/ValueProvider.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/enums/EnvironmentInfoPropertyKey.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/enums/EventPropertyKey.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/enums/EventTag.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/enums/EventType.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/enums/ServerSettingKey.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/exceptions/BadEventDataException.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/exceptions/InvalidApiKeyException.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/logging/LogCapturerAppender.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/logging/LogCapturerIF.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/logging/NullLogCapturer.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/EnvironmentInfo.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/Event.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/EventPluginContext.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/ManualStackingInfo.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/Module.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/PluginContext.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/RequestInfo.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/UserDescription.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/UserInfo.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/base/Model.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/error/Error.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/error/InnerError.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/error/Method.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/error/Parameter.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/error/StackFrame.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/models/storage/StorageItem.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/EventPluginIF.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/EventPluginRunner.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/MergedEvent.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/PluginManager.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ConfigurationDefaultsPlugin.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/DuplicateCheckerPlugin.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EnvironmentInfoPlugin.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ErrorPlugin.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EventExclusionPlugin.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/HeartbeatPlugin.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ModuleInfoPlugin.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/RequestInfoPlugin.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/SubmissionMethodPlugin.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/args/EnvironmentInfoGetArgs.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/args/RequestInfoGetArgs.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/queue/DefaultEventQueue.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/queue/EventDataFilter.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/queue/EventQueueIF.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/queue/EventValidator.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/services/DefaultLastReferenceIdManager.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/services/LastReferenceIdManagerIF.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/settings/DefaultSettingsClient.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/settings/ServerSettings.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/settings/SettingsClientIF.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/settings/SettingsManager.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/settings/SettingsResponse.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorage.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorageProvider.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/storage/StorageIF.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/storage/StorageProviderIF.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/submission/DefaultSubmissionClient.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/submission/EventRequest.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/submission/SubmissionClientIF.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/submission/SubmissionMapper.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/submission/SubmissionResponse.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/submission/UserDescriptionRequest.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/utils/Utils.java delete mode 100644 src/main/java/com/exceptionless/exceptionlessclient/utils/VisibleForTesting.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/ExceptionlessClientTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/TestFixtures.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/configuration/ConfigurationTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/configuration/PluginManagerTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/configuration/PrivateInformationInclusionsTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/plugins/EventPluginRunnerTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/plugins/MergedEventTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ConfigurationDefaultsPluginTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/DuplicateCheckerPluginTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EnvironmentInfoPluginTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ErrorPluginTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/EventExclusionPluginTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/HeartbeatPluginTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/ModuleInfoPluginTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/RequestInfoPluginTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/plugins/preconfigured/SubmissionMethodPluginTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/queue/DefaultEventQueueTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/queue/EventDataFilterTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/queue/EventValidatorTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/services/DefaultLastReferenceIdManagerTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/settings/DefaultSettingsClientTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/settings/ServerSettingsTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/settings/SettingsManagerTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorageProviderTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/storage/InMemoryStorageTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/submission/DefaultSubmissionClientTest.java delete mode 100644 src/test/java/com/exceptionless/exceptionlessclient/utils/UtilsTest.java delete mode 100644 src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/.agents/skills/exceptionless-java/SKILL.md b/.agents/skills/exceptionless-java/SKILL.md new file mode 100644 index 0000000..d85f782 --- /dev/null +++ b/.agents/skills/exceptionless-java/SKILL.md @@ -0,0 +1,87 @@ +--- +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 2.x, 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(...)` | +| 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. +- `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. + +## 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` +- 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..2f496c2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: maven + directory: / + schedule: + interval: weekly + groups: + test-dependencies: + patterns: ["org.junit*", "org.assertj*", "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, servlet, Spring Boot) stay at the oldest supported + # version on purpose; consumers bring their own. + 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..0ac3445 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,76 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: JDK ${{ matrix.java }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + 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@v7 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: ${{ matrix.java }} + cache: maven + + - name: Build and test + run: ./mvnw -B -ntp verify + + - name: Upload test reports + if: failure() + uses: actions/upload-artifact@v7 + with: + name: test-reports-${{ matrix.os }}-jdk${{ matrix.java }} + path: '**/target/surefire-reports/' + + spring-boot-4: + name: Spring Boot 4 compatibility + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + cache: maven + - name: Test the starter against Spring Boot 4 + run: ./mvnw -B -ntp -pl exceptionless-spring-boot-starter -am verify -Pspring-boot-4 + + e2e: + name: End-to-end (real Exceptionless server) + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'e2e') + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + cache: maven + - name: Run end-to-end tests + run: ./mvnw -B -ntp -pl exceptionless-e2e-tests -am verify -De2e=true 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/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d7cfaac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release + +# Publishes to Maven Central when a version tag (v2.0.0, v2.1.0-beta.1, ...) is pushed. +# Required secrets: CENTRAL_USERNAME and CENTRAL_PASSWORD (a Central Portal user token), +# GPG_PRIVATE_KEY and GPG_PASSPHRASE. +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up JDK + uses: actions/setup-java@v6 + 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 -B -ntp -Prelease -Drevision="${GITHUB_REF_NAME#v}" deploy + env: + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + + - name: Create GitHub release + run: gh release create "$GITHUB_REF_NAME" --generate-notes --verify-tag $([[ "$GITHUB_REF_NAME" == *-* ]] && echo --prerelease) + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 6725e75..0beb0e4 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -2,18 +2,20 @@ name: Mark stale issues and pull requests on: schedule: - - cron: "30 1 * * *" + - cron: "30 1 * * *" + +permissions: + issues: write + pull-requests: write jobs: stale: - runs-on: ubuntu-latest - 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@v11 + 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/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..71999b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,40 @@ # 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`. + +## 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. + +## Releasing + +Push a tag like `v2.0.0`. The release workflow builds, signs and publishes every module to Maven Central +and creates a GitHub release. diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..d68de22 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,41 @@ +# Migrating from 0.1 to 2.0 + +2.0 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 | 2.0 | +| --- | --- | +| `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 | 2.0 | +| --- | --- | +| `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` | +| `EventQueueIF`, `SubmissionClientIF`, `SettingsClientIF`, `StorageProviderIF` | `EventQueue`, `SubmissionClient`, `QueueStorage` / `KeyValueStorage` | +| `PrivateInformationInclusions` | `config.includePrivateInformation(...)`, `includeHeaders(...)`, `includeCookies(...)`, ... | +| `LogCapturerIF` | Use a logging integration (`exceptionless-logback`, `-log4j2`, `-jul`). | + +## 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..fa2ad1e 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,279 @@ # 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 + 2.0.0 + 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:2.0.0")) +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 +``` -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). +### Spring Boot -_Example: Customizing your Event Queue implementation_ +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 + # logging: + # minimum-level: warn # log messages sent from Logback ``` -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(); + +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. + +## 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"); ``` -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. +Every `create*` method returns a fluent builder for adding context: -## Getting Started (Development) +```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(); +``` -### 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` +Show users a reference id they can quote to support, and attach their description later: -### 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` +```java +String id = Exceptionless.getLastReferenceId(); +Exceptionless.getDefault().updateUserEmailAndDescription(id, "ann@example.com", "It broke"); +``` +## Configuration -## Spring Boot Users +```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") + .setUserIdentity("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()); +``` + +| 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. | + +### Project settings + +These settings in your project's **Settings → Client Configuration** apply to running applications +within a couple of minutes: -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`. +| 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 + +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()`. + +**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 ``` -## VSCode Users +MDC / ThreadContext values become event properties, markers become tags, and logged exceptions +become error events. + +## Servlet containers -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 +// Programmatically, or declare it in web.xml +servletContext.addFilter("exceptionless", new ExceptionlessFilter()).addMappingForUrlPatterns(null, false, "/*"); +``` + +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. + +## Plugins and hooks + +```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. +2.0 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..9ac7869 --- /dev/null +++ b/exceptionless-core/pom.xml @@ -0,0 +1,65 @@ + + + 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. + + + + org.jspecify + jspecify + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-testCompile + + + --add-modules=jdk.httpserver + --add-reads=com.exceptionless.core=jdk.httpserver + + + + + + + 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..8e6fc2a --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java @@ -0,0 +1,327 @@ +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.internal.Wildcard; +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.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +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; + } + + /** 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.getTags().remove(EventTags.CRITICAL); + } + return this; + } + + /** Sets the application version ({@code @version}) for this event. */ + public EventBuilder setVersion(@Nullable String version) { + event.setVersion(Strings.trimToNull(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; + } + List exclusions = new ArrayList<>(client.getConfiguration().getDataExclusions()); + exclusions.addAll(List.of(excludedPropertyNames)); + if (Wildcard.matchesAny(name, exclusions)) { + return this; + } + Object normalized; + if (value instanceof JsonSerializable) { + normalized = value; + } else { + Predicate excluded = Exclusions.of(exclusions); + normalized = 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..045c95f --- /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() {} + + /** 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..3f004f5 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java @@ -0,0 +1,774 @@ +package com.exceptionless; + +import com.exceptionless.internal.DaemonThreadFactory; +import com.exceptionless.internal.Ids; +import com.exceptionless.internal.Log; +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.settings.SettingsManager; +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.Duration; +import java.time.OffsetDateTime; +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 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) { + this.config = configuration; + } + + /** + * 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.trim(); + + 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("exceptionless")); + 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(); + }, + "exceptionless-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; + } + + /** True once the client has started and until it is 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 (installedHandler != null + && Thread.getDefaultUncaughtExceptionHandler() == installedHandler) { + 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() { + return new EventBuilder(this, new Event().setDate(OffsetDateTime.now()), 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(Boolean.TRUE); + try { + if (event.getDate() == null) { + event.setDate(OffsetDateTime.now()); + } + 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; + } + + /** 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( + new SubmissionResponse(-1, "No session or user identity", null)); + } + 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( + new SubmissionResponse(-1, "No session or user identity", null)); + } + 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 new SubmissionResponse(-1, "Exceptionless is disabled", null); + } + return client.sendHeartbeat(identifier, close, config); + } + + /** + * 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 new SubmissionResponse(-1, "Exceptionless is disabled", null); + } + SubmissionResponse response = + client.postUserDescription(referenceId, userDescription, config); + SettingsManager settings = settingsManager; + if (settings != null) { + settings.checkVersion(response.configVersion()); + } + return response; + }); + } + + // --------------------------------------------------------------------------------------------- + // Queue and background work + + /** 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); + } + + /** 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); + } + + private void scheduleWithFixedDelay(Runnable task, Duration initialDelay, Duration delay) { + requireScheduler() + .scheduleWithFixedDelay( + safe(task), + initialDelay.toMillis(), + Math.max(1, delay.toMillis()), + TimeUnit.MILLISECONDS); + } + + 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..21f256d --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java @@ -0,0 +1,980 @@ +package com.exceptionless; + +import com.exceptionless.internal.Log; +import com.exceptionless.internal.Strings; +import com.exceptionless.models.Event; +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.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 = Collections.synchronizedSet(new LinkedHashSet<>()); + private final Map defaultData = + Collections.synchronizedMap(new LinkedHashMap<>()); + 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(); + + 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 useReferenceIds = 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 proxySelector; + 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) { + String value; + if ((value = trimmed(values.get(keyMapper.apply("apiKey")))) != null) { + apiKey(value); + } + if ((value = trimmed(values.get(keyMapper.apply("serverUrl")))) != null) { + serverUrl(value); + } + if ((value = trimmed(values.get(keyMapper.apply("configServerUrl")))) != null) { + configServerUrl(value); + } + if ((value = trimmed(values.get(keyMapper.apply("heartbeatServerUrl")))) != null) { + heartbeatServerUrl(value); + } + if ((value = trimmed(values.get(keyMapper.apply("enabled")))) != null) { + enabled(Boolean.parseBoolean(value)); + } + if ((value = trimmed(values.get(keyMapper.apply("version")))) != null) { + version(value); + } + if ((value = trimmed(values.get(keyMapper.apply("storagePath")))) != null) { + useFileStorage(Path.of(value)); + } + if ((value = trimmed(values.get(keyMapper.apply("includePrivateInformation")))) != null) { + includePrivateInformation(Boolean.parseBoolean(value)); + } + if ((value = trimmed(values.get(keyMapper.apply("defaultTags")))) != null) { + addDefaultTags(ClientSettings.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(); + } + + private static @Nullable String trimmed(@Nullable String value) { + return Strings.trimToNull(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.trimToNull(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 trimmed = Strings.trimToNull(url); + if (trimmed == null) { + throw new IllegalArgumentException("A server URL is required"); + } + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + throw new IllegalArgumentException("Server URL must start with http:// or https://: " + url); + } + return trimmed; + } + + /** 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.trimToNull(version); + return this; + } + + /** A snapshot of the tags added to every event. */ + public Set getDefaultTags() { + synchronized (defaultTags) { + return Set.copyOf(defaultTags); + } + } + + public ExceptionlessConfiguration addDefaultTags(String... tags) { + for (String tag : tags) { + String trimmed = Strings.trimToNull(tag); + if (trimmed != null) { + defaultTags.add(trimmed); + } + } + return this; + } + + public ExceptionlessConfiguration removeDefaultTags(String... tags) { + for (String tag : tags) { + defaultTags.remove(tag); + } + return this; + } + + /** A snapshot of the data added to every event. */ + public Map getDefaultData() { + synchronized (defaultData) { + return new LinkedHashMap<>(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 setUserIdentity(@Nullable String identity) { + return setUserIdentity(identity, null); + } + + public ExceptionlessConfiguration setUserIdentity( + @Nullable String identity, @Nullable String name) { + if (Strings.isBlank(identity) && Strings.isBlank(name)) { + return addDefaultData(com.exceptionless.models.KnownDataKeys.USER_INFO, null); + } + return addDefaultData( + com.exceptionless.models.KnownDataKeys.USER_INFO, new UserInfo(identity, name)); + } + + // --------------------------------------------------------------------------------------------- + // Settings, exclusions and privacy + + /** The effective configuration values: local settings overlaid with the project's settings. */ + public ClientSettings getSettings() { + return effectiveSettings; + } + + /** 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. */ + public void applyServerSettings(Map values) { + this.serverSettings = ClientSettings.of(values); + recomputeSettings(); + } + + private void recomputeSettings() { + effectiveSettings = ClientSettings.of(Map.copyOf(settings), serverSettings.asMap()); + } + + /** + * 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 trimmed = Strings.trimToNull(pattern); + if (trimmed != null) { + dataExclusions.add(trimmed); + } + } + 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 all; + } + + /** Ignores requests whose user agent matches (wildcards supported, e.g. {@code *bot*}). */ + public ExceptionlessConfiguration addUserAgentBotPatterns(String... patterns) { + for (String pattern : patterns) { + String trimmed = Strings.trimToNull(pattern); + if (trimmed != null) { + userAgentBotPatterns.add(trimmed); + } + } + 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 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 + + public boolean isUseReferenceIds() { + return useReferenceIds; + } + + /** Whether error events get a random reference id (on by default). */ + public ExceptionlessConfiguration useReferenceIds(boolean useReferenceIds) { + this.useReferenceIds = useReferenceIds; + 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 getProxySelector() { + return proxySelector; + } + + /** Sends requests through a proxy, e.g. {@code ProxySelector.of(new InetSocketAddress(...))}. */ + public ExceptionlessConfiguration proxy(@Nullable ProxySelector proxySelector) { + ensureUnlocked("proxy"); + this.proxySelector = proxySelector; + 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 + + /** The plugins in the order they run (ascending priority). */ + public List getPlugins() { + List sorted = new ArrayList<>(plugins); + sorted.sort(Comparator.comparingInt(EventPlugin::getPriority)); + return sorted; + } + + /** 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. */ + public ExceptionlessConfiguration addPlugin( + String name, int priority, Consumer plugin) { + return addPlugin( + new EventPlugin() { + @Override + public String getName() { + return name; + } + + @Override + public int getPriority() { + return priority; + } + + @Override + public void run(EventPluginContext context) { + plugin.accept(context); + } + }); + } + + 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..6ff4b5e --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/LogLevel.java @@ -0,0 +1,60 @@ +package com.exceptionless; + +import java.util.Locale; +import org.jspecify.annotations.Nullable; + +/** Log levels understood by Exceptionless, in increasing order of severity. */ +public enum LogLevel { + TRACE("trace"), + DEBUG("debug"), + INFO("info"), + WARN("warn"), + ERROR("error"), + FATAL("fatal"), + /** Disables logging. Only meaningful as a minimum level. */ + OFF("off"); + + private final String value; + + LogLevel(String value) { + this.value = value; + } + + /** The value sent to the server in {@code @level}. */ + 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.trim().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; + }; + } + + @Override + public String toString() { + return value; + } +} 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..acab582 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/SubmittingEvent.java @@ -0,0 +1,41 @@ +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; + } + + public ExceptionlessClient getClient() { + return client; + } + + public Event getEvent() { + return event; + } + + public ContextData getContextData() { + return contextData; + } + + public boolean isCancelled() { + return cancelled; + } + + 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..3a6af44 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/ClientInfo.java @@ -0,0 +1,48 @@ +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 2.0.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"); + if (version != null && !version.isBlank() && !version.startsWith("${")) { + return version.trim(); + } + } + } catch (Exception e) { + // Fall through to the default. + } + return "2.0.0"; + } + + static String numericVersion(String version) { + Matcher matcher = Pattern.compile("^(\\d+)(\\.\\d+)?(\\.\\d+)?(\\.\\d+)?").matcher(version); + if (!matcher.find()) { + return "2.0.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..7a8e011 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/DaemonThreadFactory.java @@ -0,0 +1,21 @@ +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 { + 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..2f66976 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/DataNormalizer.java @@ -0,0 +1,313 @@ +package com.exceptionless.internal; + +import com.exceptionless.json.JsonSerializable; +import java.io.File; +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.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()}. + */ +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); + + 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 serialized as-is (no nested properties to normalize). */ + 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; + } + + 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 (value instanceof Class type) { + return type.getName(); + } + if (value instanceof Enum enumValue) { + return enumValue.name(); + } + if (isSimpleValue(value)) { + return value instanceof Date date ? date.toInstant().toString() : value.toString(); + } + 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(); + Map result = new LinkedHashMap<>(); + boolean anyProperty = false; + for (Method method : type.getMethods()) { + String name = propertyName(method); + if (name == null) { + continue; + } + anyProperty = true; + if (excluded.test(name) || result.containsKey(name)) { + continue; + } + if (!accessible(method, value)) { + continue; + } + try { + put(result, name, method.invoke(value), depth); + } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { + LOG.trace(() -> "Unable to read property " + name + " of " + type.getName() + ": " + e); + } + } + for (Field field : type.getFields()) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + anyProperty = true; + String name = field.getName(); + if (excluded.test(name) || result.containsKey(name)) { + continue; + } + if (!accessible(field, value)) { + continue; + } + try { + put(result, name, field.get(value), depth); + } catch (ReflectiveOperationException | RuntimeException e) { + LOG.trace(() -> "Unable to read field " + name + " of " + type.getName() + ": " + e); + } + } + return anyProperty ? result : safeToString(value); + } + + /** + * 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(java.lang.reflect.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..a8bddbf --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/EnvironmentCollector.java @@ -0,0 +1,164 @@ +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. Static values are computed once. */ +public final class EnvironmentCollector { + private static final Log LOG = Log.get(EnvironmentCollector.class); + + private static volatile @Nullable String machineName; + private static volatile @Nullable String ipAddresses; + private static final @Nullable String PROCESS_NAME = processName(); + private static final String RUNTIME_VERSION = runtimeVersion(); + + 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); + 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() { + String name = machineName; + if (name == null) { + name = Strings.trimToNull(System.getenv("HOSTNAME")); + if (name == null) { + name = Strings.trimToNull(System.getenv("COMPUTERNAME")); + } + if (name == null) { + try { + name = InetAddress.getLocalHost().getHostName(); + } catch (Exception e) { + name = ""; + } + } + machineName = name; + } + return name.isEmpty() ? null : name; + } + + static @Nullable String ipAddresses() { + String addresses = ipAddresses; + if (addresses == null) { + 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); + } + addresses = String.join(",", list); + ipAddresses = addresses; + } + return addresses.isEmpty() ? null : addresses; + } + + private static @Nullable String processName() { + String command = System.getProperty("sun.java.command"); + if (command != null && !command.isBlank()) { + String first = command.trim().split("\\s+", 2)[0]; + int slash = Math.max(first.lastIndexOf('/'), first.lastIndexOf('\\')); + return slash >= 0 ? first.substring(slash + 1) : first; + } + 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..0655375 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/ErrorConverter.java @@ -0,0 +1,221 @@ +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. */ + 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]; + } + } + + private 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..c46f5c0 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Exclusions.java @@ -0,0 +1,19 @@ +package com.exceptionless.internal; + +import java.util.List; +import java.util.function.Predicate; + +/** Builds name predicates from data exclusion patterns. */ +public final class Exclusions { + private static final Predicate NONE = name -> false; + + private Exclusions() {} + + public static Predicate of(List patterns) { + if (patterns.isEmpty()) { + return NONE; + } + List copy = List.copyOf(patterns); + return name -> Wildcard.matchesAny(name, copy); + } +} 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/Log.java b/exceptionless-core/src/main/java/com/exceptionless/internal/Log.java new file mode 100644 index 0000000..6123d8d --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Log.java @@ -0,0 +1,57 @@ +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, @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..dd5a507 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/ModuleCollector.java @@ -0,0 +1,169 @@ +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 + * 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 static volatile @Nullable List cached; + + private ModuleCollector() {} + + /** The modules on the class path, sorted by name. Never throws. */ + public static List getModules() { + List modules = cached; + if (modules == null) { + synchronized (ModuleCollector.class) { + modules = cached; + if (modules == null) { + modules = List.copyOf(collect()); + cached = modules; + } + } + } + return modules; + } + + static List collect() { + 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 = entryPoint(); + Enumeration manifests = loader.getResources("META-INF/MANIFEST.MF"); + while (manifests.hasMoreElements() && byName.size() < MAX_MODULES) { + URL url = manifests.nextElement(); + ModuleInfo module = read(url, entryPoint); + if (module != null) { + String key = module.getName() + "@" + module.getVersion(); + ModuleInfo existing = byName.putIfAbsent(key, module); + if (existing != null && module.isEntry()) { + existing.setEntry(true); + } + } + } + } catch (IOException | RuntimeException | LinkageError e) { + LOG.debug(() -> "Unable to collect modules: " + e); + } + List modules = new ArrayList<>(byName.values()); + modules.sort( + Comparator.comparing( + (ModuleInfo m) -> String.valueOf(m.getName()), String.CASE_INSENSITIVE_ORDER)); + for (int i = 0; i < modules.size(); i++) { + modules.get(i).setModuleId(i); + } + return modules; + } + + private static @Nullable ModuleInfo 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); + + ModuleInfo module = new ModuleInfo().setName(name).setVersion(version); + String mainClass = attributes.getValue("Main-Class"); + String startClass = attributes.getValue("Start-Class"); + if (entryPoint != null + && (entryPoint.equals(mainClass) + || entryPoint.equals(startClass) + || (entryPoint.endsWith(".jar") && location.contains(fileNameOf(entryPoint))))) { + module.setEntry(true); + } + return module; + } + + private static @Nullable String entryPoint() { + String command = System.getProperty("sun.java.command"); + if (command == null || command.isBlank()) { + return null; + } + int space = command.indexOf(' '); + return space > 0 ? command.substring(0, space) : command; + } + + private static String fileNameOf(String path) { + int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return slash >= 0 ? path.substring(slash + 1) : path; + } + + private static @Nullable String symbolicName(@Nullable String value) { + if (value == null) { + return null; + } + int semicolon = value.indexOf(';'); + return semicolon > 0 ? value.substring(0, semicolon).trim() : value.trim(); + } + + private static @Nullable String firstNonBlank(@Nullable String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return null; + } +} 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..a239ada --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Strings.java @@ -0,0 +1,27 @@ +package com.exceptionless.internal; + +import org.jspecify.annotations.Nullable; + +/** String helpers. */ +public final class Strings { + private Strings() {} + + public static boolean isBlank(@Nullable String value) { + return value == null || value.isBlank(); + } + + public static @Nullable String trimToNull(@Nullable String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + public static @Nullable String truncate(@Nullable String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return 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..7d8a57a --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Wildcard.java @@ -0,0 +1,52 @@ +package com.exceptionless.internal; + +import java.util.Collection; +import java.util.Locale; +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. + */ +public final class Wildcard { + private Wildcard() {} + + public static boolean matches(@Nullable String value, @Nullable String pattern) { + if (value == null || pattern == null) { + return false; + } + String p = pattern.trim().toLowerCase(Locale.ROOT); + if (p.isEmpty()) { + return false; + } + String v = value.trim().toLowerCase(Locale.ROOT); + if (p.equals("*")) { + return true; + } + boolean startsWithWildcard = p.charAt(0) == '*'; + boolean endsWithWildcard = p.length() > 1 && p.charAt(p.length() - 1) == '*'; + if (startsWithWildcard && endsWithWildcard) { + return v.contains(p.substring(1, p.length() - 1)); + } + if (startsWithWildcard) { + return v.endsWith(p.substring(1)); + } + if (endsWithWildcard) { + return v.startsWith(p.substring(0, p.length() - 1)); + } + return v.equals(p); + } + + 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..4f6818c --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/json/JsonParser.java @@ -0,0 +1,261 @@ +package com.exceptionless.json; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * A small, strict JSON parser. Objects become {@link LinkedHashMap}, arrays become {@link + * ArrayList}, numbers become {@link Long} or {@link BigDecimal}. + */ +public final class JsonParser { + private static final int MAX_DEPTH = 256; + + 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); + 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", Boolean.TRUE); + case 'f' -> readLiteral("false", Boolean.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"); + } + try { + sb.append((char) Integer.parseInt(input.substring(pos, pos + 4), 16)); + } catch (NumberFormatException e) { + throw error("Invalid unicode escape"); + } + 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); + } + } + } + + private Number readNumber() { + int start = pos; + if (peek() == '-') { + pos++; + } + boolean decimal = false; + while (pos < input.length()) { + char c = input.charAt(pos); + if (c >= '0' && c <= '9') { + pos++; + } else if (c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') { + decimal = true; + pos++; + } else { + break; + } + } + String text = input.substring(start, pos); + try { + if (!decimal) { + try { + return Long.parseLong(text); + } catch (NumberFormatException overflow) { + return new BigDecimal(text); + } + } + return new BigDecimal(text); + } catch (NumberFormatException e) { + throw error("Invalid number '" + text + "'"); + } + } + + 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' || c == '') { + 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..5e1876e --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/json/JsonWriter.java @@ -0,0 +1,308 @@ +package com.exceptionless.json; + +import java.lang.reflect.Array; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.util.Date; +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. + */ +public final class JsonWriter { + private static final int MAX_DEPTH = 64; + + 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; + + public JsonWriter() { + this(new StringBuilder(256)); + } + + 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(); + } + + public JsonWriter beginObject() { + beforeValue(); + out.append('{'); + push(); + return this; + } + + public JsonWriter endObject() { + pop(); + out.append('}'); + return this; + } + + public JsonWriter beginArray() { + beforeValue(); + out.append('['); + push(); + return this; + } + + 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 java.util.Collection collection && collection.isEmpty()) { + return this; + } + name(name); + return value(value); + } + + 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 (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(); + } + if (value instanceof Enum enumValue) { + return string(enumValue.name()); + } + if (value instanceof OffsetDateTime dateTime) { + return string(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(dateTime)); + } + if (value instanceof Date date) { + return string(date.toInstant().toString()); + } + if (value instanceof Instant || value instanceof TemporalAccessor) { + return string(value.toString()); + } + return string(String.valueOf(value)); + } + + public JsonWriter string(String value) { + beforeValue(); + writeString(value); + return this; + } + + 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; + } + + @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) { + boolean[] grown = new boolean[needsComma.length * 2]; + System.arraycopy(needsComma, 0, grown, 0, needsComma.length); + needsComma = grown; + } + 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(String.format("\\u%04x", (int) 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/models/EnvironmentInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/EnvironmentInfo.java new file mode 100644 index 0000000..93e2fdd --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/EnvironmentInfo.java @@ -0,0 +1,199 @@ +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 EnvironmentInfo setProcessorCount(@Nullable Integer processorCount) { + this.processorCount = processorCount; + return this; + } + + public @Nullable Long getTotalPhysicalMemory() { + return totalPhysicalMemory; + } + + public EnvironmentInfo setTotalPhysicalMemory(@Nullable Long totalPhysicalMemory) { + this.totalPhysicalMemory = totalPhysicalMemory; + return this; + } + + public @Nullable Long getAvailablePhysicalMemory() { + return availablePhysicalMemory; + } + + public EnvironmentInfo setAvailablePhysicalMemory(@Nullable Long availablePhysicalMemory) { + this.availablePhysicalMemory = availablePhysicalMemory; + return this; + } + + public @Nullable String getCommandLine() { + return commandLine; + } + + public EnvironmentInfo setCommandLine(@Nullable String commandLine) { + this.commandLine = commandLine; + return this; + } + + public @Nullable String getProcessName() { + return processName; + } + + public EnvironmentInfo setProcessName(@Nullable String processName) { + this.processName = processName; + return this; + } + + public @Nullable String getProcessId() { + return processId; + } + + public EnvironmentInfo setProcessId(@Nullable String processId) { + this.processId = processId; + return this; + } + + public @Nullable Long getProcessMemorySize() { + return processMemorySize; + } + + public EnvironmentInfo setProcessMemorySize(@Nullable Long processMemorySize) { + this.processMemorySize = processMemorySize; + return this; + } + + public @Nullable String getThreadName() { + return threadName; + } + + public EnvironmentInfo setThreadName(@Nullable String threadName) { + this.threadName = threadName; + return this; + } + + public @Nullable String getThreadId() { + return threadId; + } + + public EnvironmentInfo setThreadId(@Nullable String threadId) { + this.threadId = threadId; + return this; + } + + public @Nullable String getArchitecture() { + return architecture; + } + + public EnvironmentInfo setArchitecture(@Nullable String architecture) { + this.architecture = architecture; + return this; + } + + public @Nullable String getOsName() { + return osName; + } + + public EnvironmentInfo setOsName(@Nullable String osName) { + this.osName = osName; + return this; + } + + public @Nullable String getOsVersion() { + return osVersion; + } + + public EnvironmentInfo setOsVersion(@Nullable String osVersion) { + this.osVersion = osVersion; + return this; + } + + public @Nullable String getIpAddress() { + return ipAddress; + } + + public EnvironmentInfo setIpAddress(@Nullable String ipAddress) { + this.ipAddress = ipAddress; + return this; + } + + public @Nullable String getMachineName() { + return machineName; + } + + public EnvironmentInfo setMachineName(@Nullable String machineName) { + this.machineName = machineName; + return this; + } + + public @Nullable String getInstallId() { + return installId; + } + + public EnvironmentInfo setInstallId(@Nullable String installId) { + this.installId = installId; + return this; + } + + public @Nullable String getRuntimeVersion() { + return runtimeVersion; + } + + public EnvironmentInfo setRuntimeVersion(@Nullable String runtimeVersion) { + this.runtimeVersion = runtimeVersion; + return this; + } + + 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..955f7c5 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/ErrorInfo.java @@ -0,0 +1,100 @@ +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 ErrorInfo setMessage(@Nullable String message) { + this.message = message; + return this; + } + + /** The fully qualified exception class name. */ + public @Nullable String getType() { + return type; + } + + public ErrorInfo setType(@Nullable String type) { + this.type = type; + return this; + } + + public @Nullable String getCode() { + return code; + } + + public ErrorInfo setCode(@Nullable String code) { + this.code = code; + return this; + } + + /** The cause of this error, if any. */ + public @Nullable ErrorInfo getInner() { + return inner; + } + + public ErrorInfo setInner(@Nullable ErrorInfo inner) { + this.inner = inner; + return this; + } + + public List getStackTrace() { + return stackTrace; + } + + public List getModules() { + return modules; + } + + public Map getData() { + return data; + } + + /** 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() { + return type + (message != null ? ": " + message : ""); + } +} 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..5a9584a --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/Event.java @@ -0,0 +1,275 @@ +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.Collection; +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 Event setType(@Nullable String type) { + this.type = type; + return this; + } + + 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 Event setSource(@Nullable String source) { + this.source = source; + return this; + } + + public @Nullable OffsetDateTime getDate() { + return date; + } + + public Event setDate(@Nullable OffsetDateTime date) { + this.date = date; + return this; + } + + /** The event's tags. The set is live and may be modified. */ + public Set getTags() { + return tags; + } + + public Event addTags(String... tags) { + for (String tag : tags) { + addTag(tag); + } + return this; + } + + public Event addTags(Collection tags) { + for (String tag : tags) { + addTag(tag); + } + return this; + } + + private void addTag(@Nullable String tag) { + if (tag == null) { + return; + } + String trimmed = tag.trim(); + if (!trimmed.isEmpty()) { + tags.add(trimmed); + } + } + + public @Nullable String getMessage() { + return message; + } + + public Event setMessage(@Nullable String message) { + this.message = message; + return this; + } + + /** The location as {@code "latitude,longitude"}. */ + public @Nullable String getGeo() { + return geo; + } + + public Event setGeo(@Nullable String geo) { + this.geo = geo; + return this; + } + + public @Nullable BigDecimal getValue() { + return value; + } + + public Event setValue(@Nullable BigDecimal value) { + this.value = value; + return this; + } + + public @Nullable Integer getCount() { + return count; + } + + public Event setCount(@Nullable Integer count) { + this.count = count; + return this; + } + + /** Extended data. The map is live and may be modified. */ + public Map getData() { + return data; + } + + public @Nullable String getReferenceId() { + return referenceId; + } + + public Event setReferenceId(@Nullable String referenceId) { + this.referenceId = referenceId; + return this; + } + + public @Nullable ErrorInfo getError() { + return data.get(KnownDataKeys.ERROR) instanceof ErrorInfo error ? error : null; + } + + public Event setError(@Nullable ErrorInfo error) { + return putOrRemove(KnownDataKeys.ERROR, error); + } + + public @Nullable RequestInfo getRequestInfo() { + return data.get(KnownDataKeys.REQUEST_INFO) instanceof RequestInfo request ? request : null; + } + + public Event setRequestInfo(@Nullable RequestInfo requestInfo) { + return putOrRemove(KnownDataKeys.REQUEST_INFO, requestInfo); + } + + public @Nullable EnvironmentInfo getEnvironmentInfo() { + return data.get(KnownDataKeys.ENVIRONMENT_INFO) instanceof EnvironmentInfo env ? env : null; + } + + public Event setEnvironmentInfo(@Nullable EnvironmentInfo environmentInfo) { + return putOrRemove(KnownDataKeys.ENVIRONMENT_INFO, environmentInfo); + } + + public @Nullable UserInfo getUserInfo() { + return data.get(KnownDataKeys.USER_INFO) instanceof UserInfo user ? user : null; + } + + public Event setUserInfo(@Nullable UserInfo userInfo) { + return putOrRemove(KnownDataKeys.USER_INFO, userInfo); + } + + public @Nullable UserDescription getUserDescription() { + return data.get(KnownDataKeys.USER_DESCRIPTION) instanceof UserDescription d ? d : null; + } + + public Event setUserDescription(@Nullable UserDescription userDescription) { + return putOrRemove(KnownDataKeys.USER_DESCRIPTION, userDescription); + } + + public @Nullable ManualStackingInfo getManualStackingInfo() { + return data.get(KnownDataKeys.MANUAL_STACKING_INFO) instanceof ManualStackingInfo info + ? info + : null; + } + + public Event setManualStackingInfo(@Nullable ManualStackingInfo info) { + return 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 Event setLevel(@Nullable String level) { + return putOrRemove(KnownDataKeys.LEVEL, level); + } + + public @Nullable String getVersion() { + return data.get(KnownDataKeys.VERSION) instanceof String version ? version : null; + } + + public Event setVersion(@Nullable String version) { + return putOrRemove(KnownDataKeys.VERSION, version); + } + + public @Nullable String getSubmissionMethod() { + return data.get(KnownDataKeys.SUBMISSION_METHOD) instanceof String method ? method : null; + } + + public Event setSubmissionMethod(@Nullable String submissionMethod) { + return putOrRemove(KnownDataKeys.SUBMISSION_METHOD, submissionMethod); + } + + /** 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 Event setEventReference(String name, @Nullable String id) { + return putOrRemove(KnownDataKeys.REFERENCE_PREFIX + name, id); + } + + private Event putOrRemove(String key, @Nullable Object value) { + if (value == null) { + data.remove(key); + } else { + data.put(key, value); + } + return this; + } + + @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..334a848 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/ManualStackingInfo.java @@ -0,0 +1,30 @@ +package com.exceptionless.models; + +import com.exceptionless.json.JsonSerializable; +import com.exceptionless.json.JsonWriter; +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"; + + 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..c5a6403 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/ModuleInfo.java @@ -0,0 +1,73 @@ +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; + +/** A loaded library (jar) and its version. */ +public final class ModuleInfo implements JsonSerializable { + private int moduleId; + private @Nullable String name; + private @Nullable String version; + private boolean entry; + private final Map data = new LinkedHashMap<>(); + + public int getModuleId() { + return moduleId; + } + + public ModuleInfo setModuleId(int moduleId) { + this.moduleId = moduleId; + return this; + } + + public @Nullable String getName() { + return name; + } + + public ModuleInfo setName(@Nullable String name) { + this.name = name; + return this; + } + + public @Nullable String getVersion() { + return version; + } + + public ModuleInfo setVersion(@Nullable String version) { + this.version = version; + return this; + } + + public boolean isEntry() { + return entry; + } + + public ModuleInfo setEntry(boolean entry) { + this.entry = entry; + return this; + } + + public Map getData() { + return data; + } + + @Override + public void toJson(JsonWriter writer) { + writer + .beginObject() + .field("module_id", moduleId) + .fieldIfNotEmpty("name", name) + .fieldIfNotEmpty("version", version) + .field("is_entry", entry) + .fieldIfNotEmpty("data", data) + .endObject(); + } + + @Override + public String toString() { + return name + (version != null ? " " + version : ""); + } +} 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..a632b00 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/RequestInfo.java @@ -0,0 +1,159 @@ +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 RequestInfo setUserAgent(@Nullable String userAgent) { + this.userAgent = userAgent; + return this; + } + + public @Nullable String getHttpMethod() { + return httpMethod; + } + + public RequestInfo setHttpMethod(@Nullable String httpMethod) { + this.httpMethod = httpMethod; + return this; + } + + /** True when the request used HTTPS. */ + public @Nullable Boolean getSecure() { + return secure; + } + + public RequestInfo setSecure(@Nullable Boolean secure) { + this.secure = secure; + return this; + } + + public @Nullable String getHost() { + return host; + } + + public RequestInfo setHost(@Nullable String host) { + this.host = host; + return this; + } + + public @Nullable Integer getPort() { + return port; + } + + public RequestInfo setPort(@Nullable Integer port) { + this.port = port; + return this; + } + + public @Nullable String getPath() { + return path; + } + + public RequestInfo setPath(@Nullable String path) { + this.path = path; + return this; + } + + public @Nullable String getReferrer() { + return referrer; + } + + public RequestInfo setReferrer(@Nullable String referrer) { + this.referrer = referrer; + return this; + } + + public @Nullable String getClientIpAddress() { + return clientIpAddress; + } + + public RequestInfo setClientIpAddress(@Nullable String clientIpAddress) { + this.clientIpAddress = clientIpAddress; + return this; + } + + /** Request headers. Each header maps to all of its values. */ + public @Nullable Map> getHeaders() { + return headers; + } + + public RequestInfo setHeaders(@Nullable Map> headers) { + this.headers = headers; + return this; + } + + public @Nullable Map getCookies() { + return cookies; + } + + public RequestInfo setCookies(@Nullable Map cookies) { + this.cookies = cookies; + return this; + } + + /** Form fields (a map) or the raw request body (a string). */ + public @Nullable Object getPostData() { + return postData; + } + + public RequestInfo setPostData(@Nullable Object postData) { + this.postData = postData; + return this; + } + + public @Nullable Map getQueryString() { + return queryString; + } + + public RequestInfo setQueryString(@Nullable Map queryString) { + this.queryString = queryString; + return this; + } + + 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..de331ac --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/StackFrame.java @@ -0,0 +1,120 @@ +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 StackFrame setName(@Nullable String name) { + this.name = name; + return this; + } + + public @Nullable String getDeclaringNamespace() { + return declaringNamespace; + } + + public StackFrame setDeclaringNamespace(@Nullable String declaringNamespace) { + this.declaringNamespace = declaringNamespace; + return this; + } + + public @Nullable String getDeclaringType() { + return declaringType; + } + + public StackFrame setDeclaringType(@Nullable String declaringType) { + this.declaringType = declaringType; + return this; + } + + public @Nullable String getFileName() { + return fileName; + } + + public StackFrame setFileName(@Nullable String fileName) { + this.fileName = fileName; + return this; + } + + public @Nullable Integer getLineNumber() { + return lineNumber; + } + + public StackFrame setLineNumber(@Nullable Integer lineNumber) { + this.lineNumber = lineNumber; + return this; + } + + public @Nullable Integer getColumn() { + return column; + } + + public StackFrame setColumn(@Nullable Integer column) { + this.column = column; + return this; + } + + public @Nullable Integer getModuleId() { + return moduleId; + } + + public StackFrame setModuleId(@Nullable Integer moduleId) { + this.moduleId = moduleId; + return this; + } + + 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() { + return getFullName() + (fileName != null ? "(" + fileName + ":" + 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..6a0185c --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/UserDescription.java @@ -0,0 +1,26 @@ +package com.exceptionless.models; + +import com.exceptionless.json.JsonSerializable; +import com.exceptionless.json.JsonWriter; +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 { + + 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..e7e9c4f --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/models/UserInfo.java @@ -0,0 +1,30 @@ +package com.exceptionless.models; + +import com.exceptionless.json.JsonSerializable; +import com.exceptionless.json.JsonWriter; +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 { + + 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..3b6d361 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/ConfigurationDefaultsPlugin.java @@ -0,0 +1,45 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.internal.DataNormalizer; +import com.exceptionless.internal.Exclusions; +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 = Exclusions.of(config.getDataExclusions()); + 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..e523ef0 --- /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, Boolean.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..cbe073c --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/DuplicateCheckerPlugin.java @@ -0,0 +1,142 @@ +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.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 java.util.function.LongSupplier; +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 LongSupplier clock; + private final Deque processed = new ArrayDeque<>(); // {hash, timestampMillis} + private final Map merged = new LinkedHashMap<>(); + private @Nullable ScheduledFuture timer; + private @Nullable ExceptionlessClient client; + + public DuplicateCheckerPlugin() { + this(Duration.ofSeconds(30), System::currentTimeMillis); + } + + DuplicateCheckerPlugin(Duration window, LongSupplier 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); + long now = clock.getAsLong(); + synchronized (this) { + Merged existing = merged.get(hash); + if (existing != null) { + existing.count += countOf(event); + context.cancel(); + return; + } + for (Iterator it = processed.iterator(); it.hasNext(); ) { + long[] entry = it.next(); + if (now - entry[1] >= window.toMillis()) { + it.remove(); + } else if (entry[0] == hash) { + merged.put(hash, new Merged(event, countOf(event))); + context.cancel(); + return; + } + } + processed.addLast(new long[] {hash, now}); + while (processed.size() > MAX_HASHES) { + processed.removeFirst(); + } + } + } + + @Override + public void suspend(ExceptionlessClient client) { + flush(); + } + + /** Sends the counted duplicates. */ + public void flush() { + List toSend; + synchronized (this) { + if (merged.isEmpty()) { + return; + } + toSend = new ArrayList<>(merged.values()); + merged.clear(); + } + ExceptionlessClient target = client; + for (Merged item : toSend) { + item.event.setCount(item.count); + if (target != null) { + 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..76c597b --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/ErrorPlugin.java @@ -0,0 +1,78 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.internal.ErrorConverter; +import com.exceptionless.internal.Exclusions; +import com.exceptionless.internal.ModuleCollector; +import com.exceptionless.models.Event; +import com.exceptionless.models.EventTypes; +import com.exceptionless.models.ModuleInfo; +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; + + private final Set processed = + Collections.newSetFromMap(Collections.synchronizedMap(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, Exclusions.of(config.getDataExclusions()), 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 java.util.ArrayList<>(); + for (Throwable t = exception; + t != null && chain.size() < 20 && !chain.contains(t); + t = t.getCause()) { + 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() { + 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..5e3aee3 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventExclusionPlugin.java @@ -0,0 +1,92 @@ +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.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("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..62383da --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPlugin.java @@ -0,0 +1,29 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessClient; + +/** + * 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 { + /** 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..082b4a8 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPluginContext.java @@ -0,0 +1,48 @@ +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 volatile boolean cancelled; + + public EventPluginContext(ExceptionlessClient client, Event event, ContextData contextData) { + this.client = client; + this.event = event; + this.contextData = contextData; + } + + public ExceptionlessClient getClient() { + return client; + } + + public ExceptionlessConfiguration getConfiguration() { + return client.getConfiguration(); + } + + public Event getEvent() { + return event; + } + + public ContextData getContextData() { + return contextData; + } + + public boolean isCancelled() { + return cancelled; + } + + /** Discards the event; no further plugins run. */ + public void cancel() { + this.cancelled = true; + } + + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } +} 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..43f717e --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/HeartbeatPlugin.java @@ -0,0 +1,85 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +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) { + return user != null && user.identity() != null && !user.identity().isBlank() + ? user.identity() + : null; + } + + 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( + () -> client.submitSessionHeartbeat(id), + config.getHeartbeatInterval(), + config.getHeartbeatInterval()); + } + + /** True while 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/ReferenceIdPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/ReferenceIdPlugin.java new file mode 100644 index 0000000..c11585e --- /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().isUseReferenceIds() + && 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..31c9290 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/RequestInfoPlugin.java @@ -0,0 +1,125 @@ +package com.exceptionless.plugins; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.internal.Exclusions; +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.Set; +import java.util.function.Predicate; + +/** + * 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. */ + public 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. */ + public static void sanitize(RequestInfo request, ExceptionlessConfiguration config) { + Predicate excluded = Exclusions.of(config.getDataExclusions()); + + 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, truncate(entry.getValue())); + } + } + request.setPostData(filtered.isEmpty() ? null : filtered); + } + if (!config.isIncludeHeaders()) { + request.setHeaders(null); + } else if (request.getHeaders() != null) { + Map> filtered = new LinkedHashMap<>(); + for (Map.Entry> entry : request.getHeaders().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().map(v -> (String) truncate(v)).toList(); + filtered.put(name, values); + } + request.setHeaders(filtered.isEmpty() ? null : filtered); + } + } + + private static Map filter( + 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(), (String) truncate(entry.getValue())); + } + } + return filtered.isEmpty() ? null : filtered; + } + + private static Object truncate(Object value) { + if (value instanceof String s && s.length() > MAX_VALUE_LENGTH) { + return s.substring(0, MAX_VALUE_LENGTH); + } + return 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..4a45479 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/VersionPlugin.java @@ -0,0 +1,50 @@ +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; + + private volatile @Nullable String detected; + private volatile boolean detectionAttempted; + + @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 = detect(); + } + if (version != null) { + event.setVersion(version); + } + } + + private @Nullable String detect() { + if (!detectionAttempted) { + for (ModuleInfo module : ModuleCollector.getModules()) { + if (module.isEntry() && module.getVersion() != null) { + detected = module.getVersion(); + break; + } + } + detectionAttempted = true; + } + return detected; + } +} 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..f3a5878 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java @@ -0,0 +1,294 @@ +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.Clock; +import java.time.Duration; +import java.time.Instant; +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.function.BiConsumer; + +/** + * 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 Clock clock; + private final ReentrantLock processLock = new ReentrantLock(); + private final List> handlers = + new CopyOnWriteArrayList<>(); + + private volatile Instant suspendedUntil = Instant.MIN; + private volatile Instant discardUntil = Instant.MIN; + private volatile int currentBatchSize; + private volatile int consecutiveFailures; + + public DefaultEventQueue( + ExceptionlessConfiguration config, SubmissionClient submissionClient, QueueStorage storage) { + this(config, submissionClient, storage, Clock.systemUTC()); + } + + DefaultEventQueue( + ExceptionlessConfiguration config, + SubmissionClient submissionClient, + QueueStorage storage, + Clock 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), false, false); + } 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) { + StringBuilder json = new StringBuilder("["); + for (int i = 0; i < batch.size(); i++) { + if (i > 0) { + json.append(','); + } + json.append(batch.get(i).json()); + } + json.append(']'); + + SubmissionResponse response; + try { + response = submissionClient.postEvents(json.toString(), config); + } catch (RuntimeException e) { + response = SubmissionResponse.failed(e); + } + boolean keepGoing = handleResponse(batch, response); + for (BiConsumer handler : handlers) { + try { + handler.accept(batch.size(), response); + } catch (RuntimeException e) { + LOG.warn("EventsPosted handler failed", e); + } + } + return keepGoing; + } + + private boolean handleResponse(List batch, SubmissionResponse response) { + int status = response.statusCode(); + if (response.isSuccess()) { + LOG.debug(() -> "Sent " + batch.size() + " events"); + storage.remove(batch); + consecutiveFailures = 0; + return true; + } + if (response.isTooManyRequests()) { + // 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), false, false); + return false; + } + if (response.isServiceUnavailable()) { + LOG.warn("Server unavailable (503); pausing event submission for 5 minutes"); + suspendProcessing(Duration.ofMinutes(5), false, false); + return false; + } + if (response.isPaymentRequired()) { + LOG.warn("Event limit reached for this billing period; discarding events for an hour"); + suspendProcessing(Duration.ofHours(1), true, true); + return false; + } + if (response.isUnauthorized() || response.isForbidden()) { + LOG.error( + "Unable to send events: the API key is invalid or not authorized (" + status + ")", null); + storage.remove(batch); + suspendProcessing(Duration.ofMinutes(15), false, false); + return false; + } + if (response.isBadRequest() || response.isNotFound()) { + LOG.error("Unable to send events (HTTP " + status + "): " + response.message(), null); + storage.remove(batch); + suspendProcessing(Duration.ofHours(4), false, false); + return false; + } + if (response.isRequestEntityTooLarge()) { + 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", null); + storage.remove(batch); + return true; + } + + // Network errors, timeouts and 5xx: retry with backoff. + storage.incrementAttempts(batch); + int failures = ++consecutiveFailures; + long seconds = Math.min(300, 15L << Math.min(failures - 1, 5)); + LOG.warn( + "Unable to send events (" + + (status < 0 ? response.message() : "HTTP " + status) + + "); retrying in " + + seconds + + " seconds"); + suspendProcessing(Duration.ofSeconds(seconds), false, false); + 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(BiConsumer handler) { + handlers.add(handler); + } + + public QueueStorage getStorage() { + return storage; + } +} 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..6d40cc1 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/queue/EventQueue.java @@ -0,0 +1,34 @@ +package com.exceptionless.queue; + +import com.exceptionless.models.Event; +import com.exceptionless.submission.SubmissionResponse; +import java.time.Duration; +import java.util.function.BiConsumer; + +/** 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}. + * + * @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(); + + boolean isSuspended(); + + /** The number of queued events. */ + int size(); + + /** Called after each batch is posted with the number of events and the response. */ + void onEventsPosted(BiConsumer handler); +} 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..f2f2754 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/settings/ClientSettings.java @@ -0,0 +1,178 @@ +package com.exceptionless.settings; + +import com.exceptionless.LogLevel; +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 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.trim().toLowerCase(Locale.ROOT)) { + case "true", "yes", "1", "on" -> Boolean.TRUE; + case "false", "no", "0", "off" -> Boolean.FALSE; + default -> null; + }; + } + + /** Splits a comma, semicolon or newline separated list, trimming and dropping blanks. */ + public static List splitList(@Nullable String value) { + if (value == null || value.isBlank()) { + return List.of(); + } + List result = new ArrayList<>(); + for (String part : value.split("[,;\\r\\n]")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + result.add(trimmed); + } + } + return List.copyOf(result); + } + + @Override + public String toString() { + return values.toString(); + } +} diff --git a/exceptionless-core/src/main/java/com/exceptionless/settings/SettingsManager.java b/exceptionless-core/src/main/java/com/exceptionless/settings/SettingsManager.java new file mode 100644 index 0000000..8648d1b --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/settings/SettingsManager.java @@ -0,0 +1,103 @@ +package com.exceptionless.settings; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.internal.Log; +import com.exceptionless.json.JsonParser; +import com.exceptionless.json.JsonWriter; +import com.exceptionless.storage.KeyValueStorage; +import com.exceptionless.submission.SettingsResponse; +import com.exceptionless.submission.SubmissionClient; +import java.util.LinkedHashMap; +import java.util.Map; +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; + } + + /** 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 { + Map json = JsonParser.parseObject(saved); + Map settings = new LinkedHashMap<>(); + if (json.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())); + } + } + } + int savedVersion = json.get("version") instanceof Number n ? n.intValue() : 0; + config.applyServerSettings(settings); + version = savedVersion; + 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, config); + if (response.notModified()) { + return; + } + if (!response.success()) { + LOG.debug(() -> String.valueOf(response.message())); + return; + } + config.applyServerSettings(response.settings()); + version = response.version(); + storage.write( + STORAGE_KEY, + JsonWriter.toJson( + Map.of("version", response.version(), "settings", response.settings()))); + LOG.debug(() -> "Applied settings version " + response.version()); + } catch (RuntimeException e) { + LOG.warn("Unable to update settings", e); + } finally { + updateLock.unlock(); + } + } +} 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..696bbc3 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/FileKeyValueStorage.java @@ -0,0 +1,59 @@ +package com.exceptionless.storage; + +import com.exceptionless.internal.Log; +import java.io.IOException; +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 IllegalArgumentException("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..c2e008e --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/FileQueueStorage.java @@ -0,0 +1,164 @@ +package com.exceptionless.storage; + +import com.exceptionless.internal.Log; +import java.io.IOException; +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.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 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. + */ +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 final Path directory; + private final int maxItems; + private final AtomicLong sequence = new AtomicLong(); + + public FileQueueStorage(Path directory) { + this(directory, DEFAULT_MAX_ITEMS); + } + + public FileQueueStorage(Path directory, int maxItems) { + this.directory = directory; + this.maxItems = maxItems; + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new IllegalArgumentException("Unable to create queue directory " + directory, e); + } + } + + public Path getDirectory() { + return directory; + } + + @Override + public synchronized void enqueue(String json) { + List files = listFiles(); + int excess = files.size() - maxItems + 1; + for (int i = 0; i < excess; i++) { + deleteQuietly(files.get(i)); + } + if (excess > 0) { + LOG.warn("Persisted event queue is full; dropped " + excess + " of the oldest events"); + } + String name = + String.format( + "%013d-%06d.0.json", + System.currentTimeMillis(), sequence.incrementAndGet() % 1_000_000); + Path target = directory.resolve(name); + Path temp = directory.resolve(name + ".tmp"); + try { + Files.writeString(temp, json, StandardCharsets.UTF_8); + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + deleteQuietly(temp); + LOG.error("Unable to persist event to " + target, e); + } + } + + @Override + public synchronized List peek(int max) { + List result = new ArrayList<>(); + for (Path file : listFiles()) { + if (result.size() >= max) { + break; + } + Matcher matcher = FILE_NAME.matcher(file.getFileName().toString()); + if (!matcher.matches()) { + continue; + } + try { + String json = Files.readString(file, StandardCharsets.UTF_8); + result.add( + new QueueEntry( + file.getFileName().toString(), + json, + Integer.parseInt(matcher.group(3)), + Instant.ofEpochMilli(Long.parseLong(matcher.group(1))))); + } catch (NoSuchFileException e) { + // Removed concurrently. + } catch (IOException | RuntimeException e) { + LOG.warn("Discarding unreadable queued event " + file, e); + deleteQuietly(file); + } + } + return result; + } + + @Override + public synchronized void remove(Collection entries) { + for (QueueEntry entry : entries) { + deleteQuietly(directory.resolve(entry.id())); + } + } + + @Override + public synchronized void incrementAttempts(Collection entries) { + for (QueueEntry entry : entries) { + Matcher matcher = FILE_NAME.matcher(entry.id()); + if (!matcher.matches()) { + continue; + } + String renamed = + matcher.group(1) + "-" + matcher.group(2) + "." + (entry.attempts() + 1) + ".json"; + try { + Files.move( + directory.resolve(entry.id()), + directory.resolve(renamed), + StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + LOG.debug(() -> "Unable to update attempts for " + entry.id() + ": " + e); + } + } + } + + @Override + public synchronized void clear() { + for (Path file : listFiles()) { + deleteQuietly(file); + } + } + + @Override + public synchronized int size() { + return listFiles().size(); + } + + private List listFiles() { + List files = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(directory, "*.json")) { + for (Path file : stream) { + files.add(file); + } + } catch (IOException e) { + LOG.warn("Unable to list queued events in " + directory, e); + } + files.sort(null); + return files; + } + + 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..0e539e0 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/InMemoryQueueStorage.java @@ -0,0 +1,92 @@ +package com.exceptionless.storage; + +import com.exceptionless.internal.Log; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +/** 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 Map entries = new LinkedHashMap<>(); + private final AtomicLong sequence = new AtomicLong(); + private long dropped; + + public InMemoryQueueStorage() { + this(DEFAULT_MAX_ITEMS); + } + + public InMemoryQueueStorage(int maxItems) { + if (maxItems < 1) { + throw new IllegalArgumentException("maxItems must be at least 1"); + } + this.maxItems = maxItems; + } + + @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.incrementAndGet()); + entries.put(id, new QueueEntry(id, json, 0, Instant.now())); + } + + @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) { + Set ids = new HashSet<>(); + for (QueueEntry entry : toUpdate) { + ids.add(entry.id()); + } + for (Map.Entry item : entries.entrySet()) { + if (ids.contains(item.getKey())) { + QueueEntry e = item.getValue(); + item.setValue(new QueueEntry(e.id(), e.json(), e.attempts() + 1, e.enqueuedAt())); + } + } + } + + @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..7b328a0 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/KeyValueStorage.java @@ -0,0 +1,10 @@ +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 { + @Nullable String read(String key); + + 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..d2fcfa2 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/QueueEntry.java @@ -0,0 +1,13 @@ +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) {} 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..4d3a6ba --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/QueueStorage.java @@ -0,0 +1,23 @@ +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); + + void clear(); + + 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..7d311ca --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/DefaultSubmissionClient.java @@ -0,0 +1,196 @@ +package com.exceptionless.submission; + +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.internal.ClientInfo; +import com.exceptionless.internal.Log; +import com.exceptionless.json.JsonParser; +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.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.LinkedHashMap; +import java.util.Map; +import java.util.zip.GZIPOutputStream; +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 HttpClient httpClient; + + public DefaultSubmissionClient(ExceptionlessConfiguration 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()); + if (config.getProxySelector() != null) { + builder.proxy(config.getProxySelector()); + } + if (config.getSslContext() != null) { + builder.sslContext(config.getSslContext()); + } + return builder.build(); + } + + @Override + public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { + return post(config.getServerUrl() + "/api/v2/events", eventsJson, config); + } + + @Override + public SubmissionResponse postUserDescription( + String referenceId, UserDescription description, ExceptionlessConfiguration config) { + String url = + config.getServerUrl() + + "/api/v2/events/by-ref/" + + URLEncoder.encode(referenceId, StandardCharsets.UTF_8) + + "/user-description"; + return post(url, JsonWriter.toJson(description), config); + } + + @Override + public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { + String url = config.getConfigServerUrl() + "/api/v2/projects/config?v=" + version; + try { + HttpResponse response = + httpClient.send(request(url, config).GET().build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 304) { + return SettingsResponse.notModifiedResponse(); + } + if (response.statusCode() != 200) { + return SettingsResponse.failed("Unable to get settings: HTTP " + response.statusCode()); + } + return parseSettings(response.body()); + } catch (IOException e) { + return SettingsResponse.failed("Unable to get settings: " + e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return SettingsResponse.failed("Interrupted while getting settings"); + } catch (RuntimeException e) { + return SettingsResponse.failed("Unable to get settings: " + e); + } + } + + static SettingsResponse parseSettings(String body) { + Map json; + try { + json = JsonParser.parseObject(body); + } catch (IllegalArgumentException e) { + return SettingsResponse.failed("Invalid settings response: " + e.getMessage()); + } + Object rawVersion = json.get("version"); + if (!(rawVersion instanceof Number version)) { + return SettingsResponse.failed("Invalid settings response: missing version"); + } + Map settings = new LinkedHashMap<>(); + if (json.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())); + } + } + } + return SettingsResponse.of(settings, version.intValue()); + } + + @Override + public SubmissionResponse sendHeartbeat( + String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + String url = + config.getHeartbeatServerUrl() + + "/api/v2/events/session/heartbeat?id=" + + URLEncoder.encode(sessionIdOrUserId, StandardCharsets.UTF_8) + + "&close=" + + closeSession; + return send(request(url, config).GET().build()); + } + + private SubmissionResponse post(String url, String json, ExceptionlessConfiguration config) { + byte[] body = json.getBytes(StandardCharsets.UTF_8); + HttpRequest.Builder builder = + request(url, config).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 static HttpRequest.Builder request(String url, ExceptionlessConfiguration config) { + 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.trim()); + } 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..5498a9b --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/SettingsResponse.java @@ -0,0 +1,33 @@ +package com.exceptionless.submission; + +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * The result of fetching project settings. + * + * @param success whether new settings were received + * @param notModified true when the server's settings version matches ours (HTTP 304) + * @param settings the settings, when successful + * @param version the settings version, when successful + * @param message an error message, when not successful + */ +public record SettingsResponse( + boolean success, + boolean notModified, + Map settings, + int version, + @Nullable String message) { + + public static SettingsResponse notModifiedResponse() { + return new SettingsResponse(false, true, Map.of(), -1, null); + } + + public static SettingsResponse failed(String message) { + return new SettingsResponse(false, false, Map.of(), -1, message); + } + + public static SettingsResponse of(Map settings, int version) { + return new SettingsResponse(true, false, Map.copyOf(settings), version, null); + } +} 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..2db1c0d --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionClient.java @@ -0,0 +1,21 @@ +package com.exceptionless.submission; + +import com.exceptionless.ExceptionlessConfiguration; +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, ExceptionlessConfiguration config); + + /** Attaches a user's email address and description to the event with the reference id. */ + SubmissionResponse postUserDescription( + String referenceId, UserDescription description, ExceptionlessConfiguration config); + + /** Fetches the project settings if they are newer than {@code version}. */ + SettingsResponse getSettings(int version, ExceptionlessConfiguration config); + + /** Sends a session heartbeat, or ends the session when {@code closeSession} is true. */ + SubmissionResponse sendHeartbeat( + String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config); +} 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..ed411d6 --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionResponse.java @@ -0,0 +1,55 @@ +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) { + + public static SubmissionResponse failed(Throwable error) { + return new SubmissionResponse( + -1, error.getClass().getSimpleName() + ": " + error.getMessage(), 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..5dadf60 --- /dev/null +++ b/exceptionless-core/src/main/java/module-info.java @@ -0,0 +1,16 @@ +/** 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.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..cfd8399 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/ClientSmokeTest.java @@ -0,0 +1,41 @@ +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"); + @SuppressWarnings("unchecked") + Map data = (Map) event.get("data"); + @SuppressWarnings("unchecked") + Map error = (Map) 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..554c30b --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessClientTest.java @@ -0,0 +1,512 @@ +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.submission.SettingsResponse; +import com.exceptionless.submission.SubmissionResponse; +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.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 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 { + config.queueProcessInterval(Duration.ofMillis(100)); + client.submitLog("timer"); + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (submission.posts.isEmpty() && System.nanoTime() < deadline) { + Thread.sleep(20); + } + assertThat(submission.posts).hasSize(1); + } + + @Test + void configVersionHeaderTriggersSettingsRefresh() { + submission.eventsResponse = json -> new SubmissionResponse(202, null, 5); + submission.settingsResponse = SettingsResponse.of(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 = SettingsResponse.of(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.setUserIdentity("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.setUserIdentity("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).setUserIdentity("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 -> {}); + try (ExceptionlessClient hbClient = new ExceptionlessClient(hbConfig)) { + hbClient.scheduleAtFixedRate( + () -> hbClient.submitSessionHeartbeat(null), + Duration.ofMillis(10), + Duration.ofMillis(50)); + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (submission.heartbeats.size() < 2 && System.nanoTime() < deadline) { + Thread.sleep(20); + } + } + assertThat(submission.heartbeats).contains("hb-user"); + } + + @Test + void sessionEndStopsHeartbeats() throws Exception { + config.setUserIdentity("user-2").useSessions(true, Duration.ofMinutes(1), false); + client.submitLog("starts the heartbeat"); + com.exceptionless.plugins.HeartbeatPlugin heartbeat = + (com.exceptionless.plugins.HeartbeatPlugin) + config.getPlugins().stream() + .filter(p -> p instanceof com.exceptionless.plugins.HeartbeatPlugin) + .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 = SettingsResponse.of(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().setType("custom").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..1be6a3b --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java @@ -0,0 +1,190 @@ +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.isUseReferenceIds()).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 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 setUserIdentityAddsDefaultUser() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration().setUserIdentity("id-1", "Ann"); + assertThat(config.getDefaultData().get("@user")) + .isEqualTo(new com.exceptionless.models.UserInfo("id-1", "Ann")); + config.setUserIdentity(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/FakeServer.java b/exceptionless-core/src/test/java/com/exceptionless/FakeServer.java new file mode 100644 index 0000000..eec15ea --- /dev/null +++ b/exceptionless-core/src/test/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/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/TestClients.java b/exceptionless-core/src/test/java/com/exceptionless/TestClients.java new file mode 100644 index 0000000..b098094 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/TestClients.java @@ -0,0 +1,93 @@ +package com.exceptionless; + +import com.exceptionless.json.JsonParser; +import com.exceptionless.models.UserDescription; +import com.exceptionless.submission.SettingsResponse; +import com.exceptionless.submission.SubmissionClient; +import com.exceptionless.submission.SubmissionResponse; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; + +/** Test helpers for building clients that never touch the network. */ +public final class TestClients { + public static final String API_KEY = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; + + 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 = SettingsResponse.notModifiedResponse(); + + @Override + public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { + posts.add(eventsJson); + return eventsResponse.apply(eventsJson); + } + + @Override + public SubmissionResponse postUserDescription( + String referenceId, UserDescription description, ExceptionlessConfiguration config) { + userDescriptions.add( + referenceId + "=" + com.exceptionless.json.JsonWriter.toJson(description)); + return new SubmissionResponse(202, null, null); + } + + @Override + public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { + settingsRequests.add(version); + return settingsResponse; + } + + @Override + public SubmissionResponse sendHeartbeat( + String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + 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; + } + } + + /** 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-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..d80bba1 --- /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, 2.0.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..8a82443 --- /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.getName()).containsIgnoringCase("assertj"); + assertThat(m.getVersion()).isNotBlank(); + }); + assertThat(modules).extracting(ModuleInfo::getModuleId).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..5d02d04 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/DataNormalizerTest.java @@ -0,0 +1,255 @@ +package com.exceptionless.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +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.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); + assertThat(normalized).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map map = (Map) 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((List) DataNormalizer.normalize(big, 5, NONE)).hasSize(1000); + Map bigMap = new HashMap<>(); + for (int i = 0; i < 5000; i++) { + bigMap.put(i, i); + } + assertThat((Map) DataNormalizer.normalize(bigMap, 5, NONE)).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..c1186a8 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/ErrorConverterTest.java @@ -0,0 +1,247 @@ +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.Map; +import java.util.function.Supplier; +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); + @SuppressWarnings("unchecked") + List suppressed = (List) error.getData().get("suppressed"); + assertThat(suppressed) + .singleElement() + .satisfies(s -> assertThat(s.getMessage()).isEqualTo("close failed")); + } + + @Test + void addsCustomPropertiesAsExtendedDataWithExclusions() { + ErrorInfo error = convert(new PaymentException("declined", "order-7", null)); + @SuppressWarnings("unchecked") + Map ext = + (Map) error.getData().get(KnownDataKeys.EXTENDED_DATA); + assertThat(ext) + .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().setName("lib").setVersion("1.0"); + 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..a41031d --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/IdsTest.java @@ -0,0 +1,37 @@ +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; + +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); + } + + @Test + void validatesReferenceIdsLikeTheServer() { + assertThat(Ids.isValidReferenceId("abcd1234")).isTrue(); + assertThat(Ids.isValidReferenceId("ABC-123-xyz")).isTrue(); + assertThat(Ids.isValidReferenceId("a".repeat(100))).isTrue(); + assertThat(Ids.isValidReferenceId("short")).isFalse(); + assertThat(Ids.isValidReferenceId("a".repeat(101))).isFalse(); + assertThat(Ids.isValidReferenceId("has space1")).isFalse(); + assertThat(Ids.isValidReferenceId("under_score")).isFalse(); + assertThat(Ids.isValidReferenceId(null)).isFalse(); + } + + @Test + void guidsHaveNoDashes() { + assertThat(Ids.newGuid()).matches("[0-9a-f]{32}"); + } +} 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..5c16097 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/WildcardTest.java @@ -0,0 +1,45 @@ +package com.exceptionless.internal; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +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); + } + + @ParameterizedTest + @CsvSource( + value = {"null, *", "value, null", "value, ''", "value, ' '"}, + nullValues = "null") + void nullOrBlankNeverMatches(String value, String pattern) { + assertThat(Wildcard.matches(value, pattern)).isFalse(); + } + + @org.junit.jupiter.api.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(); + } +} 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..8e35ec8 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/json/JsonParserTest.java @@ -0,0 +1,81 @@ +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.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +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( + java.util.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")); + } + + @Test + void rejectsInvalidJson() { + for (String invalid : + List.of( + "", + "{", + "[1,]", + "{\"a\" 1}", + "tru", + "\"unterminated", + "{} extra", + "{'a':1}", + "\"\\x\"", + "[\"\u0001\"]")) { + assertThatThrownBy(() -> JsonParser.parse(invalid)) + .as(invalid) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @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..5301537 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/json/JsonWriterTest.java @@ -0,0 +1,111 @@ +package com.exceptionless.json; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +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 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/models/WireContractTest.java b/exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java new file mode 100644 index 0000000..deb3d41 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java @@ -0,0 +1,168 @@ +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 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 + @SuppressWarnings("unchecked") + 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")); + client + .createException(new IllegalStateException("outer", new RuntimeException("inner"))) + .setRequestInfo( + new RequestInfo() + .setHttpMethod("GET") + .setSecure(true) + .setHost("h") + .setPort(443) + .setPath("/p") + .setReferrer("r") + .setUserAgent("ua") + .setClientIpAddress("1.2.3.4") + .setHeaders(headers) + .setCookies(Map.of("c", "v")) + .setQueryString(Map.of("q", "v")) + .setPostData(Map.of("f", "v"))) + .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 = (Map) event.get("data"); + + Map error = (Map) data.get("@error"); + checkError(error); + List> modules = (List>) error.get("modules"); + assertThat(modules).isNotEmpty().allSatisfy(m -> assertThat(m.keySet()).isSubsetOf(MODULE)); + + assertThat(((Map) data.get("@request")).keySet()) + .isSubsetOf(REQUEST) + .contains("is_secure", "client_ip_address", "query_string", "http_method"); + assertThat(((Map) data.get("@environment")).keySet()) + .isSubsetOf(ENVIRONMENT) + .contains("o_s_name", "o_s_version", "process_id"); + assertThat(((Map) data.get("@user")).keySet()).isSubsetOf(USER); + assertThat(((Map) data.get("@user_description")).keySet()) + .isSubsetOf(USER_DESCRIPTION); + assertThat(((Map) data.get("@stack")).keySet()).isSubsetOf(MANUAL_STACKING); + + // Types the server is strict about. + Map request = (Map) data.get("@request"); + 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")); + Map environment = (Map) data.get("@environment"); + assertThat(environment.get("process_id")).isInstanceOf(String.class); + assertThat(environment.get("thread_id")).isInstanceOf(String.class); + assertThat(event.get("count")).isEqualTo(2L); + assertThat((String) event.get("date")) + .matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?([+-]\\d{2}:\\d{2}|Z)"); + } + + @SuppressWarnings("unchecked") + private static void checkError(Map error) { + assertThat(error.keySet()).isSubsetOf(ERROR); + List> frames = (List>) error.get("stack_trace"); + assertThat(frames).isNotEmpty().allSatisfy(f -> assertThat(f.keySet()).isSubsetOf(STACK_FRAME)); + if (error.get("inner") != null) { + checkError((Map) 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..c288128 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/DefaultPluginsTest.java @@ -0,0 +1,350 @@ +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.setUserIdentity("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.getName()).contains("junit")); + } + + @Test + void referenceIdsOnlyForErrors() { + ReferenceIdPlugin plugin = new ReferenceIdPlugin(); + Event error = new Event().setType("error"); + PluginTestSupport.run(plugin, client, error); + assertThat(error.getReferenceId()).hasSize(16); + Event log = new Event().setType("log"); + PluginTestSupport.run(plugin, client, log); + assertThat(log.getReferenceId()).isNull(); + Event explicit = new Event().setType("error").setReferenceId("my-own-id"); + PluginTestSupport.run(plugin, client, explicit); + assertThat(explicit.getReferenceId()).isEqualTo("my-own-id"); + config.useReferenceIds(false); + Event disabled = new Event().setType("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 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"); + return new RequestInfo() + .setHttpMethod("POST") + .setPath("/orders") + .setClientIpAddress("10.0.0.1") + .setUserAgent("Mozilla/5.0") + .setHeaders(headers) + .setCookies(cookies) + .setQueryString(query) + .setPostData(new LinkedHashMap<>(Map.of("name", "Ann", "password", "x"))); + } + + @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 = new Event().setRequestInfo(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 = new Event().setRequestInfo(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, + new Event().setRequestInfo(new RequestInfo().setUserAgent("Googlebot/2.1"))) + .isCancelled()) + .isTrue(); + assertThat( + PluginTestSupport.run( + plugin, + client, + new Event().setRequestInfo(new RequestInfo().setUserAgent("SomeCrawler"))) + .isCancelled()) + .isTrue(); + assertThat( + PluginTestSupport.run( + plugin, + client, + new Event().setRequestInfo(new RequestInfo().setUserAgent("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().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, new Event().setType("session")).isCancelled()) + .isTrue(); + assertThat( + PluginTestSupport.run( + plugin, client, new Event().setType("session").setUserInfo(new UserInfo("u1"))) + .isCancelled()) + .isFalse(); + assertThat( + PluginTestSupport.run( + plugin, + client, + new Event().setType("session").setReferenceId("0123456789abcdef")) + .isCancelled()) + .isFalse(); + assertThat(PluginTestSupport.run(plugin, client, new Event().setType("log")).isCancelled()) + .isFalse(); + } + + @Test + void sessionIdManagement() { + SessionIdManagementPlugin plugin = new SessionIdManagementPlugin(); + Event start = new Event().setType("session"); + PluginTestSupport.run(plugin, client, start); + String sessionId = start.getReferenceId(); + assertThat(sessionId).matches("[0-9a-f]{32}"); + assertThat(config.getCurrentSessionIdentifier()).isEqualTo(sessionId); + + Event log = new Event().setType("log"); + PluginTestSupport.run(plugin, client, log); + assertThat(log.getSessionId()).isEqualTo(sessionId); + assertThat(log.getData()).containsEntry("@ref:session", sessionId); + + Event end = new Event().setType("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..9f6e1c8 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/DuplicateCheckerPluginTest.java @@ -0,0 +1,118 @@ +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.internal.ErrorConverter; +import com.exceptionless.models.Event; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +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 AtomicLong now = new AtomicLong(1_000_000); + private final DuplicateCheckerPlugin plugin = + new DuplicateCheckerPlugin(Duration.ofSeconds(30), now::get); + + @AfterEach + void close() { + client.close(); + } + + private static Event error(Throwable t) { + Event event = new 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 java.util.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)); + now.addAndGet(31_000); + 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() { + Event log = new Event().setType("log").setMessage("x"); + assertThat(PluginTestSupport.run(plugin, client, log).isCancelled()).isFalse(); + assertThat( + PluginTestSupport.run(plugin, client, new Event().setType("log").setMessage("x")) + .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 hashIgnoresDateButUsesStack() { + List events = sameErrors(2); + Event a = events.get(0).setDate(java.time.OffsetDateTime.now()); + Event b = events.get(1).setDate(java.time.OffsetDateTime.now().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..8804b42 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/EventExclusionPluginTest.java @@ -0,0 +1,121 @@ +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.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, String level) { + return new Event().setType("log").setSource(source).setLevel(level); + } + + @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(new Event().setType("log").setSource("com.example.X"))).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 = new Event().setType("error"); + event.setError( + ErrorConverter.convert( + new IllegalStateException("x", new FileNotFoundException("f")), n -> false, List.of())); + assertThat(cancelled(event)).isTrue(); + + Event other = new Event().setType("error"); + 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 = new Event().setType("error"); + com.exceptionless.models.ErrorInfo error = + new com.exceptionless.models.ErrorInfo() + .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(new Event().setType("usage").setSource("Export"))).isTrue(); + assertThat(cancelled(new Event().setType("usage").setSource("Import"))).isFalse(); + assertThat(cancelled(new Event().setType("404").setSource("/favicon.ico"))).isTrue(); + assertThat(cancelled(new Event().setType("404").setSource("/wp-admin"))).isTrue(); + assertThat(cancelled(new Event().setType("404").setSource("/api"))).isFalse(); + } + + @Test + void customExclusions() { + config.addEventExclusion(e -> "secret".equals(e.getMessage())); + config.addEventExclusion( + e -> { + throw new IllegalStateException("failing callbacks are ignored"); + }); + assertThat(cancelled(new Event().setType("log").setMessage("secret"))).isTrue(); + assertThat(cancelled(new Event().setType("log").setMessage("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..e2396f2 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/queue/DefaultEventQueueTest.java @@ -0,0 +1,281 @@ +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.models.Event; +import com.exceptionless.storage.InMemoryQueueStorage; +import com.exceptionless.submission.SubmissionResponse; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +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 { + /** A clock tests can move. */ + static final class MutableClock extends Clock { + Instant now = Instant.parse("2026-05-01T10:07:00Z"); + + @Override + public ZoneOffset getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return now; + } + + void advance(Duration duration) { + now = now.plus(duration); + } + } + + private final ExceptionlessConfiguration config = TestClients.config().submissionBatchSize(10); + private final CapturingSubmissionClient submission = new CapturingSubmissionClient(); + private final InMemoryQueueStorage storage = new InMemoryQueueStorage(1000); + private final MutableClock clock = new MutableClock(); + private final DefaultEventQueue queue = new DefaultEventQueue(config, submission, storage, clock); + + private void enqueue(int count) { + for (int i = 0; i < count; i++) { + queue.enqueue(new Event().setType("log").setMessage("message " + i)); + } + } + + 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); + DefaultEventQueue later = + new DefaultEventQueue( + config, submission, storage, Clock.offset(Clock.systemUTC(), Duration.ofHours(2))); + later.process(); + assertThat(submission.posts).isEmpty(); + assertThat(storage.size()).isZero(); + } + + @Test + void doesNotSendWhileSuspendedOrDisabled() { + enqueue(1); + queue.suspendProcessing(Duration.ofMinutes(1), false, false); + 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(new Event().setType("log").setMessage(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..298cb03 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/settings/ClientSettingsTest.java @@ -0,0 +1,165 @@ +package com.exceptionless.settings; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.LogLevel; +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(ClientSettings.splitList(" *password* , ;secret\n\r token ,")) + .containsExactly("*password*", "secret", "token"); + assertThat(ClientSettings.splitList(null)).isEmpty(); + } + + @Test + void minLogLevelUsesExactSourceFirst() { + ClientSettings settings = + ClientSettings.of( + Map.of( + "@@log:*", + "error", + "@@log:com.example.*", + "info", + "@@log:com.example.Noisy", + "off")); + assertThat(settings.getMinLogLevel("com.example.Noisy")).isEqualTo(LogLevel.OFF); + assertThat(settings.getMinLogLevel("com.example.Service")).isEqualTo(LogLevel.INFO); + assertThat(settings.getMinLogLevel("org.other.Thing")).isEqualTo(LogLevel.ERROR); + } + + @Test + void longestWildcardWins() { + ClientSettings settings = + ClientSettings.of( + Map.of( + "@@log:com.*", + "warn", + "@@log:com.example.api.*", + "trace", + "@@log:com.example.*", + "info")); + assertThat(settings.getMinLogLevel("com.example.api.Users")).isEqualTo(LogLevel.TRACE); + assertThat(settings.getMinLogLevel("com.example.db.Pool")).isEqualTo(LogLevel.INFO); + assertThat(settings.getMinLogLevel("com.zzz")).isEqualTo(LogLevel.WARN); + } + + @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 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/settings/SettingsManagerTest.java b/exceptionless-core/src/test/java/com/exceptionless/settings/SettingsManagerTest.java new file mode 100644 index 0000000..00f83aa --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/settings/SettingsManagerTest.java @@ -0,0 +1,107 @@ +package com.exceptionless.settings; + +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 = SettingsResponse.of(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 = SettingsResponse.of(Map.of("a", "1"), 2); + manager.updateSettings(); + submission.settingsResponse = SettingsResponse.notModifiedResponse(); + manager.updateSettings(); + assertThat(submission.settingsRequests).containsExactly(0, 2); + assertThat(config.getSettings().getString("a")).isEqualTo("1"); + } + + @Test + void removedServerSettingsAreDropped() { + submission.settingsResponse = SettingsResponse.of(Map.of("a", "1", "b", "2"), 1); + manager.updateSettings(); + submission.settingsResponse = SettingsResponse.of(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 = SettingsResponse.of(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 = SettingsResponse.of(Map.of("a", "1"), 1); + manager.updateSettings(); + submission.settingsResponse = 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 = SettingsResponse.of(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/storage/QueueStorageTest.java b/exceptionless-core/src/test/java/com/exceptionless/storage/QueueStorageTest.java new file mode 100644 index 0000000..2438936 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/storage/QueueStorageTest.java @@ -0,0 +1,137 @@ +package com.exceptionless.storage; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Files; +import java.nio.file.Path; +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.TimeUnit; +import java.util.stream.Stream; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class QueueStorageTest { + @TempDir static Path tempDir; + + static Stream storages() throws Exception { + return Stream.of( + new InMemoryQueueStorage(5), + new FileQueueStorage(Files.createTempDirectory(tempDir, "q"), 5)); + } + + @ParameterizedTest + @MethodSource("storages") + void peeksOldestFirstWithoutRemoving(QueueStorage storage) { + 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}"); + } + + @ParameterizedTest + @MethodSource("storages") + void tracksAttempts(QueueStorage storage) { + storage.enqueue("{}"); + storage.incrementAttempts(storage.peek(1)); + storage.incrementAttempts(storage.peek(1)); + assertThat(storage.peek(1).get(0).attempts()).isEqualTo(2); + } + + @ParameterizedTest + @MethodSource("storages") + void dropsOldestWhenFull(QueueStorage storage) { + 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 + @MethodSource("storages") + void clears(QueueStorage storage) { + storage.enqueue("{}"); + storage.clear(); + assertThat(storage.size()).isZero(); + assertThat(storage.peek(10)).isEmpty(); + } + + @ParameterizedTest + @MethodSource("storages") + void isThreadSafe(QueueStorage storage) throws Exception { + QueueStorage big = + storage instanceof InMemoryQueueStorage + ? new InMemoryQueueStorage(10_000) + : new FileQueueStorage(Files.createTempDirectory(tempDir, "mt"), 10_000); + ExecutorService pool = Executors.newFixedThreadPool(8); + CountDownLatch start = new CountDownLatch(1); + int perThread = big instanceof InMemoryQueueStorage ? 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++) { + big.enqueue("{}"); + if (i % 10 == 0) { + big.remove(big.peek(1)); + } + } + return null; + })); + } + start.countDown(); + for (var f : futures) { + f.get(30, TimeUnit.SECONDS); + } + pool.shutdown(); + // Concurrent peek+remove may remove the same entry twice, so only the bounds are exact. + assertThat(big.size()).isBetween(8 * perThread - 8 * (perThread / 10), 8 * perThread); + } + + @org.junit.jupiter.api.Test + void fileStorageSurvivesRestart() throws Exception { + Path dir = Files.createTempDirectory(tempDir, "restart"); + FileQueueStorage first = new FileQueueStorage(dir); + first.enqueue("{\"a\":1}"); + first.enqueue("{\"a\":2}"); + first.incrementAttempts(first.peek(1)); + FileQueueStorage second = new FileQueueStorage(dir); + List entries = second.peek(10); + assertThat(entries).extracting(QueueEntry::json).containsExactly("{\"a\":1}", "{\"a\":2}"); + assertThat(entries.get(0).attempts()).isEqualTo(1); + assertThat(entries.get(0).enqueuedAt()).isBeforeOrEqualTo(java.time.Instant.now()); + } + + @org.junit.jupiter.api.Test + void fileStorageIgnoresForeignAndTempFiles() throws Exception { + Path dir = Files.createTempDirectory(tempDir, "foreign"); + 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}"); + } + + @org.junit.jupiter.api.Test + void keyValueStorage() throws Exception { + Path dir = Files.createTempDirectory(tempDir, "kv"); + 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"); + org.assertj.core.api.Assertions.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..bdf9653 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/submission/DefaultSubmissionClientTest.java @@ -0,0 +1,157 @@ +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\"}]", config); + 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, config); + 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("[]", config).configVersion()).isEqualTo(12); + } + + @Test + void returnsErrorStatusesAndBodies() { + server.onEvents(r -> new FakeServer.Response(400, "{\"title\":\"bad\"}", Map.of())); + SubmissionResponse response = client.postEvents("[]", config); + assertThat(response.isBadRequest()).isTrue(); + assertThat(response.message()).contains("bad"); + } + + @Test + void connectionFailuresBecomeFailedResponses() { + server.close(); + SubmissionResponse response = client.postEvents("[]", config); + 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, config); + assertThat(response.success()).isTrue(); + assertThat(response.version()).isEqualTo(3); + assertThat(response.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)); + SettingsResponse response = client.getSettings(3, config); + assertThat(response.notModified()).isTrue(); + assertThat(response.success()).isFalse(); + } + + @Test + void settingsErrors() { + server.onConfig(r -> new FakeServer.Response(200, "not json", Map.of())); + assertThat(client.getSettings(0, config).success()).isFalse(); + server.onConfig(r -> new FakeServer.Response(200, "{\"settings\":{}}", Map.of())); + assertThat(client.getSettings(0, config).success()).isFalse(); + server.onConfig(r -> FakeServer.Response.of(401)); + assertThat(client.getSettings(0, config).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, split); + splitClient.sendHeartbeat("user 1&x", true, split); + 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"), config); + 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("[]", config); + 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-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..8c8b0bc --- /dev/null +++ b/exceptionless-e2e-tests/pom.xml @@ -0,0 +1,103 @@ + + + 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). + + + + 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.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..6000214 --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/EventWireFormatE2ETest.java @@ -0,0 +1,498 @@ +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() + .setHttpMethod("GET") + .setSecure(true) + .setHost("shop.example.com") + .setPort(443) + .setPath("/products/missing") + .setReferrer("https://www.example.com/search") + .setClientIpAddress("203.0.113.10") + .setUserAgent(CHROME_UA) + .setHeaders(headers) + .setCookies(Map.of("theme", "dark", "visitor", "v-1")) + .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..00d4403 --- /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.setUserIdentity(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..6d345c1 --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/Await.java @@ -0,0 +1,59 @@ +package com.exceptionless.e2e.support; + +import java.time.Duration; +import java.util.function.BooleanSupplier; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** Polls until the server has processed events (it does so asynchronously through queues). */ +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 Await() {} + + public static void until(String description, Duration timeout, BooleanSupplier condition) { + value(description, timeout, () -> condition.getAsBoolean(), Boolean::booleanValue); + } + + /** Returns the first value matching {@code accept}, failing after {@code timeout}. */ + public static T value( + String description, Duration timeout, Supplier supplier, Predicate accept) { + long deadline = System.nanoTime() + timeout.toNanos(); + T last = null; + RuntimeException lastError = null; + long sleep = 250; + while (true) { + try { + last = supplier.get(); + lastError = null; + if (last != null && accept.test(last)) { + return last; + } + } catch (RuntimeException | AssertionError e) { + lastError = e instanceof RuntimeException re ? re : new RuntimeException(e); + } + if (System.nanoTime() > deadline) { + AssertionError error = + new AssertionError( + "Timed out after " + + timeout.toSeconds() + + "s waiting for " + + description + + "; last value: " + + last); + if (lastError != null) { + error.initCause(lastError); + } + throw error; + } + try { + Thread.sleep(sleep); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + sleep = Math.min(2000, sleep * 2); + } + } +} 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..cbde30f --- /dev/null +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/support/RecordingSubmissionClient.java @@ -0,0 +1,94 @@ +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, ExceptionlessConfiguration config) { + SubmissionResponse response = delegate.postEvents(eventsJson, config); + calls.add( + new Call( + "events", + null, + eventsJson, + response.statusCode(), + response.configVersion(), + response.message())); + return response; + } + + @Override + public SubmissionResponse postUserDescription( + String referenceId, UserDescription description, ExceptionlessConfiguration config) { + SubmissionResponse response = delegate.postUserDescription(referenceId, description, config); + 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, ExceptionlessConfiguration config) { + SettingsResponse response = delegate.getSettings(version, config); + int status = response.success() ? 200 : response.notModified() ? 304 : -1; + calls.add( + new Call( + "settings", + "v=" + version, + String.valueOf(response.settings()), + status, + response.version(), + response.message())); + return response; + } + + @Override + public SubmissionResponse sendHeartbeat( + String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + SubmissionResponse response = delegate.sendHeartbeat(sessionIdOrUserId, closeSession, config); + 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..fb6f58d --- /dev/null +++ b/exceptionless-jul/pom.xml @@ -0,0 +1,49 @@ + + + 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 + exceptionless-core + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + com.exceptionless.jul + + + + + + + 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..0de96fb --- /dev/null +++ b/exceptionless-jul/src/main/java/com/exceptionless/jul/ExceptionlessHandler.java @@ -0,0 +1,392 @@ +package com.exceptionless.jul; + +import com.exceptionless.EventBuilder; +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.LogLevel; +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. + * + *

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. + */ +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 String INTERNAL_LOGGER_PREFIX = "com.exceptionless"; + private static final String INTERNAL_THREAD_PREFIX = "exceptionless-"; + private static final ThreadLocal PUBLISHING = new ThreadLocal<>(); + private static final Formatter DEFAULT_FORMATTER = new SimpleFormatter(); + + private final Object clientLock = new Object(); + 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 clientFailed; + private volatile boolean closed; + + /** + * 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()); + } + + /** Creates a handler that sends to the given client, which it doesn't close. */ + public ExceptionlessHandler(ExceptionlessClient client) { + this(); + this.client = client; + } + + private void configure(String prefix) { + LogManager manager = LogManager.getLogManager(); + apiKey = trimToNull(manager.getProperty(prefix + ".apiKey")); + serverUrl = trimToNull(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); + } + } + } + + private @Nullable T newInstance(String className, Class type) { + try { + Class loaded = Class.forName(className, true, ClassLoader.getSystemClassLoader()); + return type.cast(loaded.getDeclaredConstructor().newInstance()); + } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { + reportError( + "Unable to create " + type.getSimpleName() + " " + className, + asException(e), + ErrorManager.GENERIC_FAILURE); + return null; + } + } + + public @Nullable String getApiKey() { + return apiKey; + } + + /** + * 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) { + this.apiKey = trimToNull(apiKey); + resetOwnedClient(); + } + + public @Nullable String getServerUrl() { + return serverUrl; + } + + /** The server URL for self hosted installs. Only used together with {@link #setApiKey}. */ + public void setServerUrl(@Nullable String serverUrl) { + this.serverUrl = trimToNull(serverUrl); + 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 = 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) { + LogLevel level = LogLevel.parse(minimumLevel); + if (level == null) { + reportError( + "Unknown minimumLevel '" + minimumLevel + "'; using " + this.minimumLevel.value(), + null, + ErrorManager.GENERIC_FAILURE); + return; + } + this.minimumLevel = level; + } + + /** + * 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() { + ExceptionlessClient explicit = client; + return explicit != null ? explicit : ownedClient; + } + + /** Sends to the given client. The handler doesn't close it. Takes precedence over the API key. */ + public void setClient(@Nullable ExceptionlessClient client) { + this.client = client; + } + + @Override + public void publish(@Nullable LogRecord record) { + if (record == null || closed || PUBLISHING.get() != null) { + return; + } + if (isInternal(record.getLoggerName(), Thread.currentThread().getName()) + || !isLoggable(record)) { + return; + } + PUBLISHING.set(Boolean.TRUE); + try { + ExceptionlessClient target = resolveClient(); + if (target == null) { + return; + } + String loggerName = record.getLoggerName(); + LogLevel level = toLogLevel(record.getLevel()); + if (!target.isLogLevelEnabled(loggerName, level, minimumLevel) + || !target.getConfiguration().isEnabled()) { + return; + } + createEvent(target, record, level).submit(); + } catch (RuntimeException | LinkageError e) { + reportError( + "Unable to send a log record to Exceptionless", + asException(e), + ErrorManager.WRITE_FAILURE); + } finally { + PUBLISHING.remove(); + } + } + + private EventBuilder createEvent(ExceptionlessClient target, LogRecord record, LogLevel level) { + String loggerName = record.getLoggerName(); + String message = formatMessage(record); + Throwable exception = record.getThrown(); + + EventBuilder builder; + if (exception != null) { + builder = + target + .createException(exception) + .setSource(loggerName) + .setMessage(message) + .setLevel(level); + } else { + builder = target.createLog(loggerName, message, level); + } + return builder + .setSubmissionMethod(SUBMISSION_METHOD) + .setProperty("thread", Thread.currentThread().getName()); + } + + 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 : ""; + } + + private @Nullable ExceptionlessClient resolveClient() { + ExceptionlessClient explicit = client; + if (explicit != null) { + return explicit; + } + String key = apiKey; + if (key == null) { + if (!Exceptionless.hasDefault()) { + return null; + } + ExceptionlessClient defaultClient = Exceptionless.getDefault(); + return defaultClient.isStarted() ? defaultClient : null; + } + ExceptionlessClient owned = ownedClient; + if (owned != null || clientFailed) { + return owned; + } + synchronized (clientLock) { + if (ownedClient == null && !clientFailed && !closed) { + 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) { + clientFailed = true; + reportError("Unable to create the Exceptionless client", e, ErrorManager.OPEN_FAILURE); + } + } + return ownedClient; + } + } + + private void resetOwnedClient() { + ExceptionlessClient owned; + synchronized (clientLock) { + owned = ownedClient; + ownedClient = null; + clientFailed = false; + } + if (owned != null) { + owned.close(); + } + } + + /** Starts sending queued events, without waiting. */ + @Override + public void flush() { + ExceptionlessClient target = getClient(); + if (target != null) { + 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() { + ExceptionlessClient owned; + synchronized (clientLock) { + closed = true; + owned = ownedClient; + ownedClient = null; + } + if (owned != null) { + try { + owned.close(); + } catch (RuntimeException e) { + reportError("Unable to close the Exceptionless client", e, ErrorManager.CLOSE_FAILURE); + } + } + } + + /** Maps a {@code java.util.logging} level to an Exceptionless level. */ + 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; + } + + /** True for the Exceptionless client's own loggers and background threads. */ + static boolean isInternal(@Nullable String loggerName, @Nullable String threadName) { + if (threadName != null && threadName.startsWith(INTERNAL_THREAD_PREFIX)) { + return true; + } + return loggerName != null + && loggerName.startsWith(INTERNAL_LOGGER_PREFIX) + && (loggerName.length() == INTERNAL_LOGGER_PREFIX.length() + || loggerName.charAt(INTERNAL_LOGGER_PREFIX.length()) == '.'); + } + + 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/CapturingSubmissionClient.java b/exceptionless-jul/src/test/java/com/exceptionless/jul/CapturingSubmissionClient.java new file mode 100644 index 0000000..3bf483f --- /dev/null +++ b/exceptionless-jul/src/test/java/com/exceptionless/jul/CapturingSubmissionClient.java @@ -0,0 +1,77 @@ +package com.exceptionless.jul; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.json.JsonParser; +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; + +/** Captures the events a client posts, parsed from the JSON it would send. */ +final class CapturingSubmissionClient implements SubmissionClient { + static final String API_KEY = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; + + private final List> events = new CopyOnWriteArrayList<>(); + + /** A started client that sends to this fake. */ + ExceptionlessClient newClient() { + return new ExceptionlessClient(newConfiguration()).startup(); + } + + ExceptionlessConfiguration newConfiguration() { + return new ExceptionlessConfiguration() + .apiKey(API_KEY) + .submissionClient(this) + .captureUncaughtExceptions(false) + .registerShutdownHook(false) + .includeModules(false); + } + + /** Sends the client's queued events and returns everything posted so far. */ + List> flush(ExceptionlessClient client) { + if (!client.flush(Duration.ofSeconds(10))) { + throw new AssertionError("Queued events were not sent"); + } + return events(); + } + + List> events() { + return new ArrayList<>(events); + } + + @Override + @SuppressWarnings("unchecked") + public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { + events.addAll((List>) JsonParser.parse(eventsJson)); + return new SubmissionResponse(202, null, null); + } + + @Override + public SubmissionResponse postUserDescription( + String referenceId, UserDescription description, ExceptionlessConfiguration config) { + return new SubmissionResponse(202, null, null); + } + + @Override + public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { + return SettingsResponse.notModifiedResponse(); + } + + @Override + public SubmissionResponse sendHeartbeat( + String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + return new SubmissionResponse(200, null, null); + } + + @SuppressWarnings("unchecked") + static Map data(Map event) { + Object data = event.get("data"); + return data == null ? Map.of() : (Map) data; + } +} 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..75665f7 --- /dev/null +++ b/exceptionless-jul/src/test/java/com/exceptionless/jul/ExceptionlessHandlerTest.java @@ -0,0 +1,481 @@ +package com.exceptionless.jul; + +import static com.exceptionless.jul.CapturingSubmissionClient.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.LogLevel; +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.atomic.AtomicInteger; +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; + +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; + } + + @Test + void mapsLevels() { + assertThat(ExceptionlessHandler.toLogLevel(Level.FINEST)).isEqualTo(LogLevel.TRACE); + assertThat(ExceptionlessHandler.toLogLevel(Level.FINER)).isEqualTo(LogLevel.TRACE); + assertThat(ExceptionlessHandler.toLogLevel(Level.FINE)).isEqualTo(LogLevel.DEBUG); + assertThat(ExceptionlessHandler.toLogLevel(Level.CONFIG)).isEqualTo(LogLevel.DEBUG); + assertThat(ExceptionlessHandler.toLogLevel(Level.INFO)).isEqualTo(LogLevel.INFO); + assertThat(ExceptionlessHandler.toLogLevel(Level.WARNING)).isEqualTo(LogLevel.WARN); + assertThat(ExceptionlessHandler.toLogLevel(Level.SEVERE)).isEqualTo(LogLevel.ERROR); + assertThat(ExceptionlessHandler.toLogLevel(Level.ALL)).isEqualTo(LogLevel.TRACE); + assertThat(ExceptionlessHandler.toLogLevel(Level.OFF)).isEqualTo(LogLevel.ERROR); + 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 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(CapturingSubmissionClient.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(CapturingSubmissionClient.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 = " + + CapturingSubmissionClient.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(CapturingSubmissionClient.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/java/com/exceptionless/jul/FakeServer.java b/exceptionless-jul/src/test/java/com/exceptionless/jul/FakeServer.java new file mode 100644 index 0000000..1bea8f3 --- /dev/null +++ b/exceptionless-jul/src/test/java/com/exceptionless/jul/FakeServer.java @@ -0,0 +1,169 @@ +package com.exceptionless.jul; + +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. */ +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-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..d66d4a1 --- /dev/null +++ b/exceptionless-log4j2/pom.xml @@ -0,0 +1,81 @@ + + + 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 + exceptionless-core + + + + org.apache.logging.log4j + log4j-api + ${log4j2.version} + provided + + + org.apache.logging.log4j + log4j-core + ${log4j2.version} + provided + + + + 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.apache.maven.plugins + maven-jar-plugin + + + + com.exceptionless.log4j2 + + + + + + + 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..2750aae --- /dev/null +++ b/exceptionless-log4j2/src/main/java/com/exceptionless/log4j2/ExceptionlessAppender.java @@ -0,0 +1,393 @@ +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 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.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.Message; +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 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. + * + *

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. + */ +@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 static final String INTERNAL_LOGGER_PREFIX = "com.exceptionless"; + private static final String INTERNAL_THREAD_PREFIX = "exceptionless-"; + private static final ThreadLocal APPENDING = new ThreadLocal<>(); + + private final @Nullable String apiKey; + private final @Nullable String serverUrl; + private final LogLevel minimumLevel; + private final boolean includeMdc; + private final @Nullable ExceptionlessClient client; + + private final Object clientLock = new Object(); + private volatile @Nullable ExceptionlessClient ownedClient; + private volatile boolean clientFailed; + + 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); + this.apiKey = apiKey; + this.serverUrl = serverUrl; + this.minimumLevel = minimumLevel; + this.includeMdc = includeMdc; + this.client = client; + } + + /** 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 apiKey; + } + + public @Nullable String getServerUrl() { + return serverUrl; + } + + public LogLevel getMinimumLevel() { + return minimumLevel; + } + + 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() { + ExceptionlessClient explicit = client; + return explicit != null ? explicit : ownedClient; + } + + @Override + public void append(LogEvent event) { + if (APPENDING.get() != null || isInternal(event.getLoggerName(), event.getThreadName())) { + return; + } + APPENDING.set(Boolean.TRUE); + try { + ExceptionlessClient target = resolveClient(); + if (target == null) { + return; + } + LogLevel level = toLogLevel(event.getLevel()); + if (!target.isLogLevelEnabled(event.getLoggerName(), level, minimumLevel) + || !target.getConfiguration().isEnabled()) { + return; + } + createEvent(target, event, level).submit(); + } catch (RuntimeException | LinkageError e) { + error("Unable to send a log event to Exceptionless", event, e); + } finally { + APPENDING.remove(); + } + } + + private EventBuilder createEvent(ExceptionlessClient target, LogEvent event, LogLevel level) { + String loggerName = event.getLoggerName(); + Message logMessage = event.getMessage(); + String message = logMessage != null ? logMessage.getFormattedMessage() : null; + if (message == null) { + message = ""; + } + Throwable exception = event.getThrown(); + + EventBuilder builder; + if (exception != null) { + builder = + target + .createException(exception) + .setSource(loggerName) + .setMessage(message) + .setLevel(level); + } else { + builder = target.createLog(loggerName, message, level); + } + builder.setSubmissionMethod(SUBMISSION_METHOD); + + if (includeMdc) { + ReadOnlyStringMap contextData = event.getContextData(); + if (contextData != null && !contextData.isEmpty()) { + contextData.forEach((key, value) -> builder.setProperty(key, value)); + } + } + String thread = event.getThreadName(); + if (thread != null) { + builder.setProperty("thread", thread); + } + Marker marker = event.getMarker(); + if (marker != null) { + builder.addTags(marker.getName()); + } + return builder; + } + + private @Nullable ExceptionlessClient resolveClient() { + ExceptionlessClient explicit = client; + if (explicit != null) { + return explicit; + } + String key = apiKey; + if (key == null) { + if (!Exceptionless.hasDefault()) { + return null; + } + ExceptionlessClient defaultClient = Exceptionless.getDefault(); + return defaultClient.isStarted() ? defaultClient : null; + } + ExceptionlessClient owned = ownedClient; + if (owned != null || clientFailed) { + return owned; + } + synchronized (clientLock) { + if (ownedClient == null && !clientFailed && isStarted()) { + 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) { + clientFailed = true; + error("Unable to create the Exceptionless client", e); + } + } + return ownedClient; + } + } + + /** Stops the appender and closes the client it created, sending queued events. */ + @Override + public boolean stop(long timeout, TimeUnit timeUnit) { + setStopping(); + boolean stopped = super.stop(timeout, timeUnit, false); + ExceptionlessClient owned; + synchronized (clientLock) { + owned = ownedClient; + ownedClient = null; + } + if (owned != null) { + try { + owned.close(); + } catch (RuntimeException e) { + error("Unable to close the Exceptionless client", e); + } + } + 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 and {@code ALL} to trace. + */ + 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; + } + + /** True for the Exceptionless client's own loggers and background threads. */ + static boolean isInternal(@Nullable String loggerName, @Nullable String threadName) { + if (threadName != null && threadName.startsWith(INTERNAL_THREAD_PREFIX)) { + return true; + } + return loggerName != null + && loggerName.startsWith(INTERNAL_LOGGER_PREFIX) + && (loggerName.length() == INTERNAL_LOGGER_PREFIX.length() + || loggerName.charAt(INTERNAL_LOGGER_PREFIX.length()) == '.'); + } + + private static @Nullable String trimToNull(@Nullable String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + /** + * 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; + String configured = trimToNull(minimumLevel); + if (configured != null) { + LogLevel parsed = LogLevel.parse(configured); + if (parsed == null) { + LOGGER.warn( + "Unknown minimumLevel '{}' for the Exceptionless appender; using warn", configured); + } else { + minimum = parsed; + } + } + return new ExceptionlessAppender( + name, + getFilter(), + isIgnoreExceptions(), + getPropertyArray(), + trimToNull(apiKey), + trimToNull(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/CapturingSubmissionClient.java b/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/CapturingSubmissionClient.java new file mode 100644 index 0000000..6aba7d1 --- /dev/null +++ b/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/CapturingSubmissionClient.java @@ -0,0 +1,77 @@ +package com.exceptionless.log4j2; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.json.JsonParser; +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; + +/** Captures the events a client posts, parsed from the JSON it would send. */ +final class CapturingSubmissionClient implements SubmissionClient { + static final String API_KEY = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; + + private final List> events = new CopyOnWriteArrayList<>(); + + /** A started client that sends to this fake. */ + ExceptionlessClient newClient() { + return new ExceptionlessClient(newConfiguration()).startup(); + } + + ExceptionlessConfiguration newConfiguration() { + return new ExceptionlessConfiguration() + .apiKey(API_KEY) + .submissionClient(this) + .captureUncaughtExceptions(false) + .registerShutdownHook(false) + .includeModules(false); + } + + /** Sends the client's queued events and returns everything posted so far. */ + List> flush(ExceptionlessClient client) { + if (!client.flush(Duration.ofSeconds(10))) { + throw new AssertionError("Queued events were not sent"); + } + return events(); + } + + List> events() { + return new ArrayList<>(events); + } + + @Override + @SuppressWarnings("unchecked") + public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { + events.addAll((List>) JsonParser.parse(eventsJson)); + return new SubmissionResponse(202, null, null); + } + + @Override + public SubmissionResponse postUserDescription( + String referenceId, UserDescription description, ExceptionlessConfiguration config) { + return new SubmissionResponse(202, null, null); + } + + @Override + public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { + return SettingsResponse.notModifiedResponse(); + } + + @Override + public SubmissionResponse sendHeartbeat( + String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + return new SubmissionResponse(200, null, null); + } + + @SuppressWarnings("unchecked") + static Map data(Map event) { + Object data = event.get("data"); + return data == null ? Map.of() : (Map) data; + } +} 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..2577676 --- /dev/null +++ b/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/ExceptionlessAppenderTest.java @@ -0,0 +1,354 @@ +package com.exceptionless.log4j2; + +import static com.exceptionless.log4j2.CapturingSubmissionClient.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.LogLevel; +import java.time.Duration; +import java.util.List; +import java.util.Map; +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.LoggerContext; +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.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +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); + } + + @Test + void mapsLevels() { + assertThat(ExceptionlessAppender.toLogLevel(Level.TRACE)).isEqualTo(LogLevel.TRACE); + assertThat(ExceptionlessAppender.toLogLevel(Level.DEBUG)).isEqualTo(LogLevel.DEBUG); + assertThat(ExceptionlessAppender.toLogLevel(Level.INFO)).isEqualTo(LogLevel.INFO); + assertThat(ExceptionlessAppender.toLogLevel(Level.WARN)).isEqualTo(LogLevel.WARN); + assertThat(ExceptionlessAppender.toLogLevel(Level.ERROR)).isEqualTo(LogLevel.ERROR); + assertThat(ExceptionlessAppender.toLogLevel(Level.FATAL)).isEqualTo(LogLevel.FATAL); + assertThat(ExceptionlessAppender.toLogLevel(Level.OFF)).isEqualTo(LogLevel.FATAL); + assertThat(ExceptionlessAppender.toLogLevel(Level.ALL)).isEqualTo(LogLevel.TRACE); + 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 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 ownsAndClosesTheClientForItsApiKey() throws Exception { + try (FakeServer fake = new FakeServer()) { + ExceptionlessAppender appender = + ExceptionlessAppender.newBuilder() + .setName("Exceptionless") + .setApiKey(CapturingSubmissionClient.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(CapturingSubmissionClient.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/java/com/exceptionless/log4j2/FakeServer.java b/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/FakeServer.java new file mode 100644 index 0000000..729c5ad --- /dev/null +++ b/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/FakeServer.java @@ -0,0 +1,169 @@ +package com.exceptionless.log4j2; + +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. */ +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-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..554c12d --- /dev/null +++ b/exceptionless-logback/pom.xml @@ -0,0 +1,56 @@ + + + 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 + exceptionless-core + + + + ch.qos.logback + logback-classic + ${logback.version} + provided + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + com.exceptionless.logback + + + + + + + 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..a9a7ef7 --- /dev/null +++ b/exceptionless-logback/src/main/java/com/exceptionless/logback/ExceptionlessAppender.java @@ -0,0 +1,323 @@ +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 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. + * + *

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. + */ +public class ExceptionlessAppender extends UnsynchronizedAppenderBase { + /** The submission method recorded on events sent by this appender. */ + public static final String SUBMISSION_METHOD = "logback"; + + private static final String INTERNAL_LOGGER_PREFIX = "com.exceptionless"; + private static final String INTERNAL_THREAD_PREFIX = "exceptionless-"; + private static final ThreadLocal APPENDING = new ThreadLocal<>(); + + private final Object clientLock = new Object(); + private volatile @Nullable String apiKey; + private volatile @Nullable String serverUrl; + private volatile LogLevel minimumLevel = LogLevel.WARN; + private volatile boolean includeMdc = true; + private volatile @Nullable ExceptionlessClient client; + private volatile @Nullable ExceptionlessClient ownedClient; + private volatile boolean clientFailed; + private volatile boolean reportedFailure; + + public @Nullable String getApiKey() { + return apiKey; + } + + /** The project API key. When set, the appender creates and owns its own client. */ + public void setApiKey(@Nullable String apiKey) { + this.apiKey = trimToNull(apiKey); + } + + public @Nullable String getServerUrl() { + return serverUrl; + } + + /** The server URL for self hosted installs. Only used together with {@link #setApiKey}. */ + public void setServerUrl(@Nullable String serverUrl) { + this.serverUrl = trimToNull(serverUrl); + } + + public String getMinimumLevel() { + return minimumLevel.value(); + } + + /** + * 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. + */ + public void setMinimumLevel(@Nullable String minimumLevel) { + LogLevel level = LogLevel.parse(minimumLevel); + if (level == null) { + addWarn("Unknown minimumLevel '" + minimumLevel + "'; using " + this.minimumLevel.value()); + return; + } + this.minimumLevel = level; + } + + 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() { + ExceptionlessClient explicit = client; + return explicit != null ? explicit : ownedClient; + } + + /** + * Sends to the given client. The appender doesn't close it. Takes precedence over the API key. + */ + public void setClient(@Nullable ExceptionlessClient client) { + this.client = client; + } + + @Override + public void start() { + clientFailed = false; + super.start(); + } + + @Override + public void stop() { + super.stop(); + ExceptionlessClient owned; + synchronized (clientLock) { + owned = ownedClient; + ownedClient = null; + } + if (owned != null) { + try { + owned.close(); + } catch (RuntimeException e) { + addError("Unable to close the Exceptionless client", e); + } + } + } + + @Override + protected void append(ILoggingEvent event) { + if (APPENDING.get() != null || isInternal(event.getLoggerName(), event.getThreadName())) { + return; + } + APPENDING.set(Boolean.TRUE); + try { + ExceptionlessClient target = resolveClient(); + if (target == null) { + return; + } + String loggerName = event.getLoggerName(); + LogLevel level = toLogLevel(event.getLevel()); + if (!target.isLogLevelEnabled(loggerName, level, minimumLevel) + || !target.getConfiguration().isEnabled()) { + return; + } + createEvent(target, event, level).submit(); + } catch (RuntimeException | LinkageError e) { + if (!reportedFailure) { + reportedFailure = true; + addError("Unable to send a log event to Exceptionless", e); + } + } finally { + APPENDING.remove(); + } + } + + private EventBuilder createEvent( + ExceptionlessClient target, ILoggingEvent event, LogLevel level) { + String loggerName = event.getLoggerName(); + String message = event.getFormattedMessage(); + if (message == null) { + message = ""; + } + IThrowableProxy proxy = event.getThrowableProxy(); + Throwable exception = proxy instanceof ThrowableProxy p ? p.getThrowable() : null; + + EventBuilder builder; + if (exception != null) { + builder = + target + .createException(exception) + .setSource(loggerName) + .setMessage(message) + .setLevel(level); + } else { + builder = target.createLog(loggerName, message, level); + if (proxy != null) { + // Deserialized events (e.g. from a socket appender) only carry the formatted stack trace. + builder.setProperty("exception", ThrowableProxyUtil.asString(proxy)); + } + } + builder.setSubmissionMethod(SUBMISSION_METHOD); + + 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); + } + } + } + String thread = event.getThreadName(); + if (thread != null) { + builder.setProperty("thread", thread); + } + List markers = event.getMarkerList(); + if (markers != null) { + for (Marker marker : markers) { + if (marker != null) { + builder.addTags(marker.getName()); + } + } + } + return builder; + } + + private @Nullable ExceptionlessClient resolveClient() { + ExceptionlessClient explicit = client; + if (explicit != null) { + return explicit; + } + String key = apiKey; + if (key == null) { + if (!Exceptionless.hasDefault()) { + return null; + } + ExceptionlessClient defaultClient = Exceptionless.getDefault(); + return defaultClient.isStarted() ? defaultClient : null; + } + ExceptionlessClient owned = ownedClient; + if (owned != null || clientFailed || !isStarted()) { + return owned; + } + synchronized (clientLock) { + if (ownedClient == null && !clientFailed && isStarted()) { + 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) { + clientFailed = true; + addError("Unable to create the Exceptionless client", e); + } + } + return ownedClient; + } + } + + /** Maps a Logback level to an Exceptionless level. */ + 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; + } + + /** True for the Exceptionless client's own loggers and background threads. */ + static boolean isInternal(@Nullable String loggerName, @Nullable String threadName) { + if (threadName != null && threadName.startsWith(INTERNAL_THREAD_PREFIX)) { + return true; + } + return loggerName != null + && loggerName.startsWith(INTERNAL_LOGGER_PREFIX) + && (loggerName.length() == INTERNAL_LOGGER_PREFIX.length() + || loggerName.charAt(INTERNAL_LOGGER_PREFIX.length()) == '.'); + } + + 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-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/CapturingSubmissionClient.java b/exceptionless-logback/src/test/java/com/exceptionless/logback/CapturingSubmissionClient.java new file mode 100644 index 0000000..40bb3d2 --- /dev/null +++ b/exceptionless-logback/src/test/java/com/exceptionless/logback/CapturingSubmissionClient.java @@ -0,0 +1,77 @@ +package com.exceptionless.logback; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.json.JsonParser; +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; + +/** Captures the events a client posts, parsed from the JSON it would send. */ +final class CapturingSubmissionClient implements SubmissionClient { + static final String API_KEY = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; + + private final List> events = new CopyOnWriteArrayList<>(); + + /** A started client that sends to this fake. */ + ExceptionlessClient newClient() { + return new ExceptionlessClient(newConfiguration()).startup(); + } + + ExceptionlessConfiguration newConfiguration() { + return new ExceptionlessConfiguration() + .apiKey(API_KEY) + .submissionClient(this) + .captureUncaughtExceptions(false) + .registerShutdownHook(false) + .includeModules(false); + } + + /** Sends the client's queued events and returns everything posted so far. */ + List> flush(ExceptionlessClient client) { + if (!client.flush(Duration.ofSeconds(10))) { + throw new AssertionError("Queued events were not sent"); + } + return events(); + } + + List> events() { + return new ArrayList<>(events); + } + + @Override + @SuppressWarnings("unchecked") + public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { + events.addAll((List>) JsonParser.parse(eventsJson)); + return new SubmissionResponse(202, null, null); + } + + @Override + public SubmissionResponse postUserDescription( + String referenceId, UserDescription description, ExceptionlessConfiguration config) { + return new SubmissionResponse(202, null, null); + } + + @Override + public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { + return SettingsResponse.notModifiedResponse(); + } + + @Override + public SubmissionResponse sendHeartbeat( + String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + return new SubmissionResponse(200, null, null); + } + + @SuppressWarnings("unchecked") + static Map data(Map event) { + Object data = event.get("data"); + return data == null ? Map.of() : (Map) data; + } +} 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..ff1c19f --- /dev/null +++ b/exceptionless-logback/src/test/java/com/exceptionless/logback/ExceptionlessAppenderTest.java @@ -0,0 +1,362 @@ +package com.exceptionless.logback; + +import static com.exceptionless.logback.CapturingSubmissionClient.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 com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.LogLevel; +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.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; + } + + @Test + void mapsLevels() { + assertThat(ExceptionlessAppender.toLogLevel(Level.TRACE)).isEqualTo(LogLevel.TRACE); + assertThat(ExceptionlessAppender.toLogLevel(Level.DEBUG)).isEqualTo(LogLevel.DEBUG); + assertThat(ExceptionlessAppender.toLogLevel(Level.INFO)).isEqualTo(LogLevel.INFO); + assertThat(ExceptionlessAppender.toLogLevel(Level.WARN)).isEqualTo(LogLevel.WARN); + assertThat(ExceptionlessAppender.toLogLevel(Level.ERROR)).isEqualTo(LogLevel.ERROR); + 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("info"); + appender.setMinimumLevel("bogus"); + assertThat(appender.getMinimumLevel()).isEqualTo("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 ownsAndClosesTheClientForItsApiKey() throws Exception { + try (FakeServer fake = new FakeServer()) { + ExceptionlessAppender appender = new ExceptionlessAppender(); + appender.setContext(context); + appender.setApiKey(CapturingSubmissionClient.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(CapturingSubmissionClient.API_KEY); + assertThat(appender.getServerUrl()).isEqualTo(fake.url()); + assertThat(appender.getMinimumLevel()).isEqualTo("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/java/com/exceptionless/logback/FakeServer.java b/exceptionless-logback/src/test/java/com/exceptionless/logback/FakeServer.java new file mode 100644 index 0000000..4748000 --- /dev/null +++ b/exceptionless-logback/src/test/java/com/exceptionless/logback/FakeServer.java @@ -0,0 +1,169 @@ +package com.exceptionless.logback; + +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. */ +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-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..8272e75 --- /dev/null +++ b/exceptionless-servlet/pom.xml @@ -0,0 +1,69 @@ + + + 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. + + + 12.1.14 + + + + + com.exceptionless + exceptionless-core + + + org.jspecify + jspecify + + + jakarta.servlet + jakarta.servlet-api + ${jakarta.servlet.version} + provided + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.eclipse.jetty.ee10 + jetty-ee10-servlet + ${jetty.version} + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + com.exceptionless.servlet + + + + + + + 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..850ea9f --- /dev/null +++ b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ExceptionlessFilter.java @@ -0,0 +1,283 @@ +package com.exceptionless.servlet; + +import com.exceptionless.EventBuilder; +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.models.UserInfo; +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +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()}, looked up on the first + * request. 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 implements Filter { + /** 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 volatile @Nullable ExceptionlessClient client; + private volatile boolean captureNotFound = true; + + /** Uses {@link Exceptionless#getDefault()}. */ + public ExceptionlessFilter() {} + + /** Reports to {@code client}. */ + public ExceptionlessFilter(ExceptionlessClient client) { + this.client = client; + } + + @Override + public void init(FilterConfig filterConfig) { + String value = filterConfig.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 ExceptionlessFilter setCaptureNotFound(boolean captureNotFound) { + this.captureNotFound = captureNotFound; + return this; + } + + /** The client events are reported to. */ + public ExceptionlessClient getClient() { + ExceptionlessClient current = client; + if (current == null) { + current = Exceptionless.getDefault(); + client = current; + } + registerPlugin(current.getConfiguration()); + return current; + } + + private static void registerPlugin(ExceptionlessConfiguration config) { + if (!config.hasPlugin(ServletRequestInfoPlugin.NAME)) { + config.addPlugin(new ServletRequestInfoPlugin()); + } + } + + @Override + public void doFilter( + ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) + throws IOException, ServletException { + if (!(servletRequest instanceof HttpServletRequest request) + || !(servletResponse instanceof HttpServletResponse response)) { + chain.doFilter(servletRequest, servletResponse); + return; + } + + ExceptionlessClient exceptionless = getClient(); + // 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, Boolean.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, Boolean.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 < 10 && current instanceof ServletException servletException; + depth++) { + Throwable cause = servletException.getRootCause(); + if (cause == null || cause == current) { + break; + } + current = cause; + } + return current; + } + + @Override + public void destroy() {} + + 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..c9bdb6c --- /dev/null +++ b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoFactory.java @@ -0,0 +1,175 @@ +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() + .setHttpMethod(request.getMethod()) + .setSecure(request.isSecure()) + .setHost(request.getServerName()) + .setPort(request.getServerPort()) + .setPath(request.getRequestURI()) + .setReferrer(request.getHeader("Referer")) + .setUserAgent(request.getHeader("User-Agent")) + .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("&")) { + 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..028975c --- /dev/null +++ b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/ExceptionlessFilterTest.java @@ -0,0 +1,456 @@ +package com.exceptionless.servlet; + +import static com.exceptionless.servlet.RecordingSubmissionClient.data; +import static com.exceptionless.servlet.RecordingSubmissionClient.error; +import static com.exceptionless.servlet.RecordingSubmissionClient.request; +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +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 RecordingSubmissionClient recorder = new RecordingSubmissionClient(); + 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 = RecordingSubmissionClient.configuration(recorder); + 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 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/RecordingSubmissionClient.java b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/RecordingSubmissionClient.java new file mode 100644 index 0000000..963def6 --- /dev/null +++ b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/RecordingSubmissionClient.java @@ -0,0 +1,95 @@ +package com.exceptionless.servlet; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.json.JsonParser; +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; + +/** Captures the events a client posts, for tests. */ +final class RecordingSubmissionClient implements SubmissionClient { + static final String API_KEY = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; + + private final List> events = new CopyOnWriteArrayList<>(); + + static ExceptionlessConfiguration configuration(RecordingSubmissionClient submissionClient) { + return new ExceptionlessConfiguration() + .apiKey(API_KEY) + .submissionClient(submissionClient) + .captureUncaughtExceptions(false) + .registerShutdownHook(false) + .updateSettingsWhenIdleInterval(Duration.ZERO); + } + + @Override + @SuppressWarnings("unchecked") + public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { + events.addAll((List>) JsonParser.parse(eventsJson)); + return new SubmissionResponse(202, null, null); + } + + @Override + public SubmissionResponse postUserDescription( + String referenceId, UserDescription description, ExceptionlessConfiguration config) { + return new SubmissionResponse(202, null, null); + } + + @Override + public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { + return SettingsResponse.notModifiedResponse(); + } + + @Override + public SubmissionResponse sendHeartbeat( + String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + return new SubmissionResponse(200, null, null); + } + + List> events() { + return new ArrayList<>(events); + } + + /** Flushes the client until at least {@code count} events were posted, or fails. */ + List> awaitEvents(ExceptionlessClient client, int count) + throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + while (events.size() < count) { + if (System.nanoTime() > deadline) { + throw new AssertionError("Expected " + count + " events, got " + events); + } + client.flush(Duration.ofSeconds(2)); + if (events.size() < count) { + Thread.sleep(25); + } + } + return events(); + } + + /** Flushes the client and returns everything posted so far. */ + List> flush(ExceptionlessClient client) { + client.flush(Duration.ofSeconds(5)); + return events(); + } + + @SuppressWarnings("unchecked") + static Map data(Map event) { + return (Map) event.get("data"); + } + + @SuppressWarnings("unchecked") + static Map request(Map event) { + return (Map) data(event).get("@request"); + } + + @SuppressWarnings("unchecked") + static Map error(Map event) { + return (Map) data(event).get("@error"); + } +} 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..27585f0 --- /dev/null +++ b/exceptionless-spring-boot-starter/pom.xml @@ -0,0 +1,138 @@ + + + 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. + + + + + + + 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.springframework.boot + spring-boot-starter-web + test + + + org.springframework.boot + spring-boot-starter-webflux + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.springframework.boot + spring-boot-configuration-processor + ${spring-boot.version} + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + com.exceptionless.spring.boot + + + + + + + + + + + spring-boot-4 + + 4.1.1 + 6.0.3 + + + + 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..cff8270 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessAutoConfiguration.java @@ -0,0 +1,187 @@ +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 java.util.Map; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +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. + *
+ * + *

Set {@code exceptionless.enabled=false} to turn all of it off. + */ +@AutoConfiguration +@ConditionalOnClass(ExceptionlessClient.class) +@ConditionalOnProperty( + prefix = "exceptionless", + name = "enabled", + havingValue = "true", + 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)); + + ExceptionlessClient client = new ExceptionlessClient(config); + Exceptionless.setDefault(client); + return client.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)); + for (Map.Entry entry : properties.getDefaultData().entrySet()) { + config.addDefaultData(entry.getKey(), entry.getValue()); + } + properties.getSettings().forEach(config::setting); + if (properties.getIncludePrivateInformation() != null) { + config.includePrivateInformation(properties.getIncludePrivateInformation()); + } + if (StringUtils.hasText(properties.getStoragePath())) { + config.useFileStorage(Path.of(properties.getStoragePath())); + } + 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" + }) + @ConditionalOnProperty( + prefix = "exceptionless.logging", + name = "enabled", + havingValue = "true", + 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..39a2a86 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessProperties.java @@ -0,0 +1,211 @@ +package com.exceptionless.spring.boot; + +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 String 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 String getStoragePath() { + return storagePath; + } + + public void setStoragePath(@Nullable String 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 String minimumLevel = "warn"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getMinimumLevel() { + return minimumLevel; + } + + public void setMinimumLevel(String 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..52decba --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ExceptionlessWebFilter.java @@ -0,0 +1,128 @@ +package com.exceptionless.spring.boot; + +import com.exceptionless.EventBuilder; +import com.exceptionless.ExceptionlessClient; +import java.security.Principal; +import java.util.Optional; +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; + } + + private Mono withUser( + ServerWebExchange exchange, Function, Mono> action) { + if (!client.getConfiguration().isIncludeUserName()) { + return Mono.defer(() -> action.apply(Optional.empty())); + } + return exchange + .getPrincipal() + .map(Principal::getName) + .map(Optional::of) + .defaultIfEmpty(Optional.empty()) + .onErrorReturn(Optional.empty()) + .flatMap(action); + } + + private static Mono submit( + EventBuilder builder, ServerWebExchange exchange, Optional user, String failure) { + try { + builder.setRequestInfo(ServerHttpRequestInfoFactory.create(exchange.getRequest())); + user.filter(name -> !name.isBlank()).ifPresent(builder::setUserIdentity); + 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..0410877 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/LogbackAppenderRegistration.java @@ -0,0 +1,77 @@ +package com.exceptionless.spring.boot; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.Appender; +import com.exceptionless.ExceptionlessClient; +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.ILoggerFactory; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.DisposableBean; + +/** + * Adds an {@link ExceptionlessAppender} for the auto-configured client to the Logback root logger + * while the application context runs. 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}). + */ +public final class LogbackAppenderRegistration implements DisposableBean { + /** The name of the appender added to the root logger. */ + public 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 @Nullable ExceptionlessAppender appender; + private final @Nullable Logger root; + + public LogbackAppenderRegistration(ExceptionlessClient client, String minimumLevel) { + ILoggerFactory factory = LoggerFactory.getILoggerFactory(); + if (!(factory instanceof LoggerContext context)) { + this.appender = null; + this.root = null; + return; + } + Logger rootLogger = context.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + for (Iterator> it = rootLogger.iteratorForAppenders(); it.hasNext(); ) { + Appender existing = it.next(); + if (existing instanceof ExceptionlessAppender && !REGISTERED.contains(existing)) { + // Configured explicitly; leave it alone. + this.appender = null; + this.root = null; + return; + } + } + ExceptionlessAppender added = new ExceptionlessAppender(); + added.setContext(context); + added.setName(APPENDER_NAME); + added.setClient(client); + added.setMinimumLevel(minimumLevel); + added.start(); + REGISTERED.add(added); + rootLogger.addAppender(added); + this.appender = added; + this.root = rootLogger; + } + + /** The appender added to the root logger, or {@code null} when none was added. */ + public @Nullable ExceptionlessAppender getAppender() { + return appender; + } + + @Override + public void destroy() { + if (appender != null && root != null) { + root.detachAppender(appender); + appender.stop(); + REGISTERED.remove(appender); + } + } +} 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..489cad0 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ServerHttpRequestInfoFactory.java @@ -0,0 +1,113 @@ +package com.exceptionless.spring.boot; + +import com.exceptionless.models.RequestInfo; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; +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() + .setHttpMethod(request.getMethod().name()) + .setSecure(secure) + .setHost(uri.getHost()) + .setPort(port) + .setPath(uri.getRawPath()) + .setReferrer(headers.getFirst("Referer")) + .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); + } + + Map query = parseQueryString(uri.getRawQuery()); + if (!query.isEmpty()) { + info.setQueryString(query); + } + return info; + } + + /** URL-decoded query string names and values; the first value wins when a name repeats. */ + static Map parseQueryString(@Nullable String rawQuery) { + Map values = new LinkedHashMap<>(); + if (rawQuery == null || rawQuery.isEmpty()) { + return values; + } + for (String pair : rawQuery.split("&")) { + int equals = pair.indexOf('='); + String name = decode(equals < 0 ? pair : pair.substring(0, equals)); + if (!name.isEmpty()) { + values.putIfAbsent(name, equals < 0 ? "" : decode(pair.substring(equals + 1))); + } + } + return values; + } + + private static String decode(String value) { + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return value; + } + } +} 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/additional-spring-configuration-metadata.json b/exceptionless-spring-boot-starter/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..ce24fc2 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,16 @@ +{ + "hints": [ + { + "name": "exceptionless.logging.minimum-level", + "values": [ + { "value": "trace" }, + { "value": "debug" }, + { "value": "info" }, + { "value": "warn" }, + { "value": "error" }, + { "value": "fatal" }, + { "value": "off" } + ] + } + ] +} 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..3d59d9d --- /dev/null +++ b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/ExceptionlessAutoConfigurationTest.java @@ -0,0 +1,315 @@ +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.core.Appender; +import com.exceptionless.Exceptionless; +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.logback.ExceptionlessAppender; +import com.exceptionless.servlet.ExceptionlessFilter; +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.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.info.BuildProperties; +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; + +class ExceptionlessAutoConfigurationTest { + private static final AutoConfigurations AUTO_CONFIGURATION = + AutoConfigurations.of(ExceptionlessAutoConfiguration.class); + + private final RecordingSubmissionClient recorder = new RecordingSubmissionClient(); + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(AUTO_CONFIGURATION) + .withPropertyValues( + "exceptionless.api-key=" + RecordingSubmissionClient.API_KEY, + "exceptionless.capture-uncaught-exceptions=false") + .withBean( + "recordingCustomizer", + ExceptionlessConfigurationCustomizer.class, + () -> config -> config.submissionClient(recorder)); + + @AfterEach + void resetDefault() { + Exceptionless.setDefault(null); + } + + @Test + void createsStartedClientFromProperties() { + runner + .withPropertyValues( + "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(RecordingSubmissionClient.API_KEY); + 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=" + RecordingSubmissionClient.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=" + RecordingSubmissionClient.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=" + RecordingSubmissionClient.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=" + RecordingSubmissionClient.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 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("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(RecordingSubmissionClient.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 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..df1c871 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/ExceptionlessWebFilterTest.java @@ -0,0 +1,198 @@ +package com.exceptionless.spring.boot; + +import static com.exceptionless.spring.boot.RecordingSubmissionClient.data; +import static com.exceptionless.spring.boot.RecordingSubmissionClient.error; +import static com.exceptionless.spring.boot.RecordingSubmissionClient.request; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +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; + +class ExceptionlessWebFilterTest { + private final RecordingSubmissionClient recorder = new RecordingSubmissionClient(); + private ExceptionlessClient client; + + @AfterEach + void close() { + if (client != null) { + client.close(); + } + } + + private ExceptionlessWebFilter filter( + boolean captureNotFound, Consumer configure) { + ExceptionlessConfiguration config = RecordingSubmissionClient.configuration(recorder); + 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() throws Exception { + ExceptionlessWebFilter filter = filter(true, c -> {}); + IllegalStateException boom = new IllegalStateException("boom"); + + assertThatThrownBy( + () -> + filter + .filter( + exchange("http://example.com:8080/orders?id=42&name=a%20b"), failing(boom)) + .block()) + .isSameAs(boom); + + 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")) + .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() throws Exception { + ExceptionlessWebFilter filter = filter(true, c -> {}); + + assertThatThrownBy( + () -> + filter + .filter( + exchange("/missing"), + failing(new ResponseStatusException(HttpStatus.NOT_FOUND))) + .block()) + .isInstanceOf(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() throws Exception { + ExceptionlessWebFilter filter = filter(true, c -> {}); + + filter.filter(exchange("/gone"), respondWith(HttpStatus.NOT_FOUND)).block(); + + assertThat(recorder.awaitEvents(client, 1).get(0)) + .containsEntry("type", "404") + .containsEntry("source", "/gone"); + } + + @Test + void ignoresClientErrorsSuccessAndDisabledNotFound() throws Exception { + ExceptionlessWebFilter filter = filter(false, c -> {}); + + assertThatThrownBy( + () -> + filter + .filter( + exchange("/bad"), + failing(new ResponseStatusException(HttpStatus.BAD_REQUEST))) + .block()) + .isInstanceOf(ResponseStatusException.class); + assertThatThrownBy( + () -> + filter + .filter( + exchange("/missing"), + failing(new ResponseStatusException(HttpStatus.NOT_FOUND))) + .block()) + .isInstanceOf(ResponseStatusException.class); + filter.filter(exchange("/gone"), respondWith(HttpStatus.NOT_FOUND)).block(); + filter.filter(exchange("/ok"), respondWith(HttpStatus.OK)).block(); + client.submitLog("marker"); + + assertThat(recorder.awaitEvents(client, 1)) + .extracting(e -> e.get("message")) + .containsExactly("marker"); + } + + @Test + void reportsServerErrorStatusExceptions() throws Exception { + ExceptionlessWebFilter filter = filter(true, c -> {}); + + assertThatThrownBy( + () -> + filter + .filter( + exchange("/x"), + failing( + new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "down"))) + .block()) + .isInstanceOf(ResponseStatusException.class); + + assertThat(recorder.awaitEvents(client, 1).get(0)).containsEntry("type", "error"); + } + + @Test + void includesSignedInUserUnlessUserNamesAreExcluded() throws Exception { + ExceptionlessWebFilter filter = filter(true, c -> {}); + Principal alice = () -> "alice"; + ServerWebExchange exchange = exchange("/x").mutate().principal(Mono.just(alice)).build(); + + assertThatThrownBy( + () -> filter.filter(exchange, failing(new IllegalStateException("a"))).block()) + .isInstanceOf(IllegalStateException.class); + + assertThat(data(recorder.awaitEvents(client, 1).get(0)).get("@user")) + .isEqualTo(Map.of("identity", "alice")); + } + + @Test + void omitsSignedInUserWhenExcluded() throws Exception { + ExceptionlessWebFilter filter = filter(true, c -> c.includeUserName(false)); + Principal alice = () -> "alice"; + ServerWebExchange exchange = exchange("/x").mutate().principal(Mono.just(alice)).build(); + + assertThatThrownBy( + () -> filter.filter(exchange, failing(new IllegalStateException("a"))).block()) + .isInstanceOf(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/FakeServer.java b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/FakeServer.java new file mode 100644 index 0000000..377cc26 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/FakeServer.java @@ -0,0 +1,169 @@ +package com.exceptionless.spring.boot; + +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-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/RecordingSubmissionClient.java b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/RecordingSubmissionClient.java new file mode 100644 index 0000000..6d74053 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/RecordingSubmissionClient.java @@ -0,0 +1,95 @@ +package com.exceptionless.spring.boot; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.ExceptionlessConfiguration; +import com.exceptionless.json.JsonParser; +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; + +/** Captures the events a client posts, for tests. */ +final class RecordingSubmissionClient implements SubmissionClient { + static final String API_KEY = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; + + private final List> events = new CopyOnWriteArrayList<>(); + + static ExceptionlessConfiguration configuration(RecordingSubmissionClient submissionClient) { + return new ExceptionlessConfiguration() + .apiKey(API_KEY) + .submissionClient(submissionClient) + .captureUncaughtExceptions(false) + .registerShutdownHook(false) + .updateSettingsWhenIdleInterval(Duration.ZERO); + } + + @Override + @SuppressWarnings("unchecked") + public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { + events.addAll((List>) JsonParser.parse(eventsJson)); + return new SubmissionResponse(202, null, null); + } + + @Override + public SubmissionResponse postUserDescription( + String referenceId, UserDescription description, ExceptionlessConfiguration config) { + return new SubmissionResponse(202, null, null); + } + + @Override + public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { + return SettingsResponse.notModifiedResponse(); + } + + @Override + public SubmissionResponse sendHeartbeat( + String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + return new SubmissionResponse(200, null, null); + } + + List> events() { + return new ArrayList<>(events); + } + + /** Flushes the client until at least {@code count} events were posted, or fails. */ + List> awaitEvents(ExceptionlessClient client, int count) + throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + while (events.size() < count) { + if (System.nanoTime() > deadline) { + throw new AssertionError("Expected " + count + " events, got " + events); + } + client.flush(Duration.ofSeconds(2)); + if (events.size() < count) { + Thread.sleep(25); + } + } + return events(); + } + + /** Flushes the client and returns everything posted so far. */ + List> flush(ExceptionlessClient client) { + client.flush(Duration.ofSeconds(5)); + return events(); + } + + @SuppressWarnings("unchecked") + static Map data(Map event) { + return (Map) event.get("data"); + } + + @SuppressWarnings("unchecked") + static Map request(Map event) { + return (Map) data(event).get("@request"); + } + + @SuppressWarnings("unchecked") + static Map error(Map event) { + return (Map) data(event).get("@error"); + } +} 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..e8439f5 --- /dev/null +++ b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/mvc/MvcIntegrationTest.java @@ -0,0 +1,164 @@ +package com.exceptionless.spring.boot.mvc; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.ExceptionlessClient; +import com.exceptionless.spring.boot.FakeServer; +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.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", () -> "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"); + 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()); + } + + private Map awaitEvent(Predicate> match) + throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos(); + while (System.nanoTime() < deadline) { + client.flush(Duration.ofSeconds(2)); + for (Map event : events()) { + if (match.test(event)) { + return event; + } + } + Thread.sleep(50); + } + throw new AssertionError("No matching event; got " + events()); + } + + private static List> events() { + return SERVER.requests().stream() + .filter(r -> r.path().equals("/api/v2/events")) + .flatMap(r -> r.events().stream()) + .toList(); + } + + @SuppressWarnings("unchecked") + private static Map data(Map event) { + Object data = event.get("data"); + return data instanceof Map map ? (Map) map : Map.of(); + } + + @SuppressWarnings("unchecked") + private static Map error(Map event) { + Object error = data(event).get("@error"); + return error instanceof Map map ? (Map) map : Map.of(); + } + + @SuppressWarnings("unchecked") + private static Map request(Map event) { + Object request = data(event).get("@request"); + return request instanceof Map map ? (Map) map : Map.of(); + } + + 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..ffb3f2d 100644 --- a/pom.xml +++ b/pom.xml @@ -1,259 +1,382 @@ - + 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 + + + + 2.0.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 + + + 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.13 + 3.10.4 + 1.35.0 + 0.9.0 + + + + + + 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.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + ${maven.compiler.release} + + -Xlint:all,-processing,-serial,-options,-missing-explicit-ctor + -parameters + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + false + @{argLine} -XX:+EnableDynamicAgentLoading + + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-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.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} + + + + ${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,14 +385,32 @@ 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 + + + - \ No newline at end of file + diff --git a/samples/console/pom.xml b/samples/console/pom.xml new file mode 100644 index 0000000..e3796b8 --- /dev/null +++ b/samples/console/pom.xml @@ -0,0 +1,33 @@ + + + 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. + + + true + true + true + com.example.console.ConsoleApp + + + + + com.exceptionless + exceptionless-core + + + com.exceptionless + exceptionless-jul + + + 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..9105b27 --- /dev/null +++ b/samples/console/src/main/java/com/example/console/ConsoleApp.java @@ -0,0 +1,101 @@ +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.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): + * + *

+ * EXCEPTIONLESS_API_KEY=your-key ./mvnw -pl samples/console -am -q exec:java
+ * EXCEPTIONLESS_API_KEY=your-key EXCEPTIONLESS_SERVER_URL=http://localhost:5200 ./mvnw ...
+ * 
+ */ +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 = ConsoleApp.class.getResourceAsStream("/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") + .setUserIdentity("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"); + } + + // 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"); + } +} 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..b2a17ea --- /dev/null +++ b/samples/spring-boot/pom.xml @@ -0,0 +1,72 @@ + + + 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. + + + 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..d62b4bb --- /dev/null +++ b/samples/spring-boot/src/main/java/com/example/springboot/OrderController.java @@ -0,0 +1,55 @@ +package com.example.springboot; + +import com.exceptionless.ExceptionlessClient; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +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) { + MDC.put("orderId", Long.toString(id)); + try { + 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, with the MDC values as properties. + LOG.warn("Order {} is archived", id); + } + exceptionless.createFeatureUsage("View Order").setProperty("orderId", id).submit(); + return Map.of("id", id, "status", "shipped"); + } finally { + MDC.remove("orderId"); + } + } + + @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 From a7175a31fdb15509a6c6e0a6ab0a81d861bcbb25 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 19 Oct 2026 17:53:31 -0500 Subject: [PATCH 2/9] Make the core public API idiomatic - Models are plain JavaBeans (void setters); Event tags go through add/remove/hasTag - ModuleInfo is an immutable record; record map components are copied - SettingsResponse is a sealed interface; SubmissionResponse.notSent factory - SubmissionClient no longer takes the configuration per call - EventPlugin.of factory, EventsPostedListener, suspendProcessing(Duration) - Configuration naming fixes (userIdentity, isReferenceIdsEnabled, getProxy) - SettingsManager and settings parsing moved to internal Co-Authored-By: Claude Opus 5.5 --- MIGRATION.md | 2 +- README.md | 2 +- .../java/com/exceptionless/EventBuilder.java | 2 +- .../exceptionless/ExceptionlessClient.java | 19 +-- .../ExceptionlessConfiguration.java | 139 ++++++++---------- .../com/exceptionless/SubmittingEvent.java | 5 + .../internal/ModuleCollector.java | 45 +++--- .../exceptionless/internal/SettingsJson.java | 46 ++++++ .../SettingsManager.java | 45 ++---- .../com/exceptionless/internal/Strings.java | 17 +++ .../exceptionless/models/EnvironmentInfo.java | 48 ++---- .../com/exceptionless/models/ErrorInfo.java | 15 +- .../java/com/exceptionless/models/Event.java | 113 +++++++------- .../models/ManualStackingInfo.java | 7 + .../com/exceptionless/models/ModuleInfo.java | 62 ++------ .../com/exceptionless/models/RequestInfo.java | 36 ++--- .../com/exceptionless/models/StackFrame.java | 26 ++-- .../exceptionless/models/UserDescription.java | 7 + .../com/exceptionless/models/UserInfo.java | 7 + .../exceptionless/plugins/EventPlugin.java | 16 ++ .../plugins/EventPluginContext.java | 11 +- .../plugins/LambdaEventPlugin.java | 23 +++ .../plugins/ReferenceIdPlugin.java | 2 +- .../plugins/RequestInfoPlugin.java | 14 +- .../exceptionless/plugins/VersionPlugin.java | 5 +- .../queue/DefaultEventQueue.java | 32 ++-- .../com/exceptionless/queue/EventQueue.java | 11 +- .../queue/EventsPostedListener.java | 15 ++ .../settings/ClientSettings.java | 18 +-- .../submission/DefaultSubmissionClient.java | 74 +++++----- .../submission/SettingsResponse.java | 48 +++--- .../submission/SubmissionClient.java | 11 +- .../submission/SubmissionResponse.java | 6 + .../ExceptionlessClientTest.java | 18 ++- .../ExceptionlessConfigurationTest.java | 8 +- .../java/com/exceptionless/TestClients.java | 12 +- .../internal/CollectorsTest.java | 6 +- .../internal/ErrorConverterTest.java | 2 +- .../SettingsManagerTest.java | 20 +-- .../com/exceptionless/models/ModelsTest.java | 79 ++++++++++ .../models/WireContractTest.java | 28 ++-- .../plugins/DefaultPluginsTest.java | 108 +++++++------- .../plugins/DuplicateCheckerPluginTest.java | 21 +-- .../plugins/EventExclusionPluginTest.java | 47 ++++-- .../queue/DefaultEventQueueTest.java | 13 +- .../settings/ClientSettingsTest.java | 5 +- .../DefaultSubmissionClientTest.java | 39 ++--- .../e2e/EventWireFormatE2ETest.java | 25 ++-- .../com/exceptionless/e2e/SessionE2ETest.java | 2 +- .../support/RecordingSubmissionClient.java | 42 +++--- .../jul/CapturingSubmissionClient.java | 12 +- .../log4j2/CapturingSubmissionClient.java | 12 +- .../logback/CapturingSubmissionClient.java | 12 +- .../servlet/ServletRequestInfoFactory.java | 19 ++- .../servlet/RecordingSubmissionClient.java | 12 +- .../boot/ServerHttpRequestInfoFactory.java | 17 +-- .../boot/RecordingSubmissionClient.java | 12 +- .../java/com/example/console/ConsoleApp.java | 2 +- 58 files changed, 812 insertions(+), 690 deletions(-) create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/SettingsJson.java rename exceptionless-core/src/main/java/com/exceptionless/{settings => internal}/SettingsManager.java (63%) create mode 100644 exceptionless-core/src/main/java/com/exceptionless/plugins/LambdaEventPlugin.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/queue/EventsPostedListener.java rename exceptionless-core/src/test/java/com/exceptionless/{settings => internal}/SettingsManagerTest.java (80%) create mode 100644 exceptionless-core/src/test/java/com/exceptionless/models/ModelsTest.java diff --git a/MIGRATION.md b/MIGRATION.md index d68de22..6d42ee4 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -23,7 +23,7 @@ Remove any OkHttp version pins you added for the old client. | `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` | +| `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`). | diff --git a/README.md b/README.md index fa2ad1e..f101889 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ Exceptionless.startup(config -> config .version("1.4.2") // or read from your jar's manifest .addDefaultTags("backend") .addDefaultData("region", "us-east-1") - .setUserIdentity("service-account") // desktop/CLI apps + .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 diff --git a/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java b/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java index 8e6fc2a..b752a5a 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java +++ b/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java @@ -183,7 +183,7 @@ public EventBuilder markAsCritical(boolean critical) { if (critical) { event.addTags(EventTags.CRITICAL); } else { - event.getTags().remove(EventTags.CRITICAL); + event.removeTags(EventTags.CRITICAL); } return this; } diff --git a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java index 3f004f5..baf1d6d 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java +++ b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java @@ -3,6 +3,7 @@ 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; @@ -15,7 +16,6 @@ import com.exceptionless.plugins.HeartbeatPlugin; import com.exceptionless.queue.DefaultEventQueue; import com.exceptionless.queue.EventQueue; -import com.exceptionless.settings.SettingsManager; import com.exceptionless.storage.FileKeyValueStorage; import com.exceptionless.storage.FileQueueStorage; import com.exceptionless.storage.InMemoryKeyValueStorage; @@ -362,7 +362,9 @@ private boolean ensureStarted() { /** Creates an event of any type. */ public EventBuilder createEvent() { - return new EventBuilder(this, new Event().setDate(OffsetDateTime.now()), new ContextData()); + Event event = new Event(); + event.setDate(OffsetDateTime.now()); + return new EventBuilder(this, event, new ContextData()); } /** Creates an error event for an exception. */ @@ -573,7 +575,7 @@ public CompletableFuture submitSessionHeartbeat( String identifier = sessionIdentifier(sessionIdOrUserId); if (identifier == null) { return CompletableFuture.completedFuture( - new SubmissionResponse(-1, "No session or user identity", null)); + SubmissionResponse.notSent("No session or user identity")); } return supplyAsync(() -> sendHeartbeat(identifier, false)); } @@ -584,7 +586,7 @@ public CompletableFuture submitSessionEnd( String identifier = sessionIdentifier(sessionIdOrUserId); if (identifier == null) { return CompletableFuture.completedFuture( - new SubmissionResponse(-1, "No session or user identity", null)); + SubmissionResponse.notSent("No session or user identity")); } if (identifier.equals(config.getCurrentSessionIdentifier())) { config.currentSessionIdentifier(null); @@ -615,9 +617,9 @@ public CompletableFuture submitSessionEnd( private SubmissionResponse sendHeartbeat(String identifier, boolean close) { SubmissionClient client = submissionClient; if (client == null || !config.isEnabled()) { - return new SubmissionResponse(-1, "Exceptionless is disabled", null); + return SubmissionResponse.notSent("Exceptionless is disabled"); } - return client.sendHeartbeat(identifier, close, config); + return client.sendHeartbeat(identifier, close); } /** @@ -634,10 +636,9 @@ public CompletableFuture updateUserEmailAndDescription( () -> { SubmissionClient client = submissionClient; if (client == null || !config.isEnabled()) { - return new SubmissionResponse(-1, "Exceptionless is disabled", null); + return SubmissionResponse.notSent("Exceptionless is disabled"); } - SubmissionResponse response = - client.postUserDescription(referenceId, userDescription, config); + SubmissionResponse response = client.postUserDescription(referenceId, userDescription); SettingsManager settings = settingsManager; if (settings != null) { settings.checkVersion(response.configVersion()); diff --git a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java index 21f256d..378e74e 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java +++ b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java @@ -3,6 +3,7 @@ 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; @@ -39,6 +40,7 @@ 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; @@ -79,9 +81,8 @@ public final class ExceptionlessConfiguration { private volatile boolean locked; private volatile @Nullable String version; - private final Set defaultTags = Collections.synchronizedSet(new LinkedHashSet<>()); - private final Map defaultData = - Collections.synchronizedMap(new LinkedHashMap<>()); + 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<>(); @@ -101,7 +102,7 @@ public final class ExceptionlessConfiguration { private volatile boolean includeQueryString = true; private volatile boolean includeModules = true; - private volatile boolean useReferenceIds = true; + private volatile boolean referenceIdsEnabled = true; private volatile boolean sessionsEnabled; private volatile boolean sendHeartbeats; private volatile Duration heartbeatInterval = Duration.ofSeconds(60); @@ -119,7 +120,7 @@ public final class ExceptionlessConfiguration { private volatile Duration connectTimeout = Duration.ofSeconds(10); private volatile Duration requestTimeout = Duration.ofSeconds(30); - private volatile @Nullable ProxySelector proxySelector; + private volatile @Nullable ProxySelector proxy; private volatile @Nullable SSLContext sslContext; private volatile @Nullable HttpClient httpClient; private volatile @Nullable SubmissionClient submissionClient; @@ -191,34 +192,23 @@ private void readFromClasspathProperties() { */ public ExceptionlessConfiguration readFromValues( Map values, Function keyMapper) { - String value; - if ((value = trimmed(values.get(keyMapper.apply("apiKey")))) != null) { - apiKey(value); - } - if ((value = trimmed(values.get(keyMapper.apply("serverUrl")))) != null) { - serverUrl(value); - } - if ((value = trimmed(values.get(keyMapper.apply("configServerUrl")))) != null) { - configServerUrl(value); - } - if ((value = trimmed(values.get(keyMapper.apply("heartbeatServerUrl")))) != null) { - heartbeatServerUrl(value); - } - if ((value = trimmed(values.get(keyMapper.apply("enabled")))) != null) { - enabled(Boolean.parseBoolean(value)); - } - if ((value = trimmed(values.get(keyMapper.apply("version")))) != null) { - version(value); - } - if ((value = trimmed(values.get(keyMapper.apply("storagePath")))) != null) { - useFileStorage(Path.of(value)); - } - if ((value = trimmed(values.get(keyMapper.apply("includePrivateInformation")))) != null) { - includePrivateInformation(Boolean.parseBoolean(value)); - } - if ((value = trimmed(values.get(keyMapper.apply("defaultTags")))) != null) { - addDefaultTags(ClientSettings.splitList(value).toArray(String[]::new)); - } + 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) @@ -248,8 +238,16 @@ static String environmentName(String key) { return sb.toString(); } - private static @Nullable String trimmed(@Nullable String value) { - return Strings.trimToNull(value); + /** Passes the trimmed 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.trimToNull(values.get(keyMapper.apply(name))); + if (value != null) { + setter.accept(value); + } } // --------------------------------------------------------------------------------------------- @@ -378,9 +376,7 @@ public ExceptionlessConfiguration version(@Nullable String version) { /** A snapshot of the tags added to every event. */ public Set getDefaultTags() { - synchronized (defaultTags) { - return Set.copyOf(defaultTags); - } + return Collections.unmodifiableSet(new LinkedHashSet<>(defaultTags)); } public ExceptionlessConfiguration addDefaultTags(String... tags) { @@ -402,9 +398,7 @@ public ExceptionlessConfiguration removeDefaultTags(String... tags) { /** A snapshot of the data added to every event. */ public Map getDefaultData() { - synchronized (defaultData) { - return new LinkedHashMap<>(defaultData); - } + return Map.copyOf(defaultData); } /** Adds data to every event. The value is normalized when events are created. */ @@ -418,17 +412,15 @@ public ExceptionlessConfiguration addDefaultData(String key, @Nullable Object va } /** Sets the user attached to every event (for example in a desktop or CLI app). */ - public ExceptionlessConfiguration setUserIdentity(@Nullable String identity) { - return setUserIdentity(identity, null); + public ExceptionlessConfiguration userIdentity(@Nullable String identity) { + return userIdentity(identity, null); } - public ExceptionlessConfiguration setUserIdentity( - @Nullable String identity, @Nullable String name) { + public ExceptionlessConfiguration userIdentity(@Nullable String identity, @Nullable String name) { if (Strings.isBlank(identity) && Strings.isBlank(name)) { - return addDefaultData(com.exceptionless.models.KnownDataKeys.USER_INFO, null); + return addDefaultData(KnownDataKeys.USER_INFO, null); } - return addDefaultData( - com.exceptionless.models.KnownDataKeys.USER_INFO, new UserInfo(identity, name)); + return addDefaultData(KnownDataKeys.USER_INFO, new UserInfo(identity, name)); } // --------------------------------------------------------------------------------------------- @@ -458,7 +450,10 @@ public ExceptionlessConfiguration setting(String key, @Nullable String value) { return this; } - /** Applies settings received from the server. */ + /** + * 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(); @@ -490,7 +485,7 @@ public List getDataExclusions() { } List all = new ArrayList<>(dataExclusions); all.addAll(server); - return all; + return List.copyOf(all); } /** Ignores requests whose user agent matches (wildcards supported, e.g. {@code *bot*}). */ @@ -512,7 +507,7 @@ public List getUserAgentBotPatterns() { } List all = new ArrayList<>(userAgentBotPatterns); all.addAll(server); - return all; + return List.copyOf(all); } /** Adds a callback that returns {@code true} for events that should be discarded. */ @@ -653,13 +648,13 @@ public boolean isLogLevelEnabled( // --------------------------------------------------------------------------------------------- // Reference ids and sessions - public boolean isUseReferenceIds() { - return useReferenceIds; + /** Whether error events get a random reference id (on by default). */ + public boolean isReferenceIdsEnabled() { + return referenceIdsEnabled; } - /** Whether error events get a random reference id (on by default). */ - public ExceptionlessConfiguration useReferenceIds(boolean useReferenceIds) { - this.useReferenceIds = useReferenceIds; + public ExceptionlessConfiguration useReferenceIds(boolean enabled) { + this.referenceIdsEnabled = enabled; return this; } @@ -831,14 +826,14 @@ public ExceptionlessConfiguration requestTimeout(Duration timeout) { return this; } - public @Nullable ProxySelector getProxySelector() { - return proxySelector; + public @Nullable ProxySelector getProxy() { + return proxy; } /** Sends requests through a proxy, e.g. {@code ProxySelector.of(new InetSocketAddress(...))}. */ - public ExceptionlessConfiguration proxy(@Nullable ProxySelector proxySelector) { + public ExceptionlessConfiguration proxy(@Nullable ProxySelector proxy) { ensureUnlocked("proxy"); - this.proxySelector = proxySelector; + this.proxy = proxy; return this; } @@ -913,9 +908,7 @@ public ExceptionlessConfiguration shutdownTimeout(Duration timeout) { /** The plugins in the order they run (ascending priority). */ public List getPlugins() { - List sorted = new ArrayList<>(plugins); - sorted.sort(Comparator.comparingInt(EventPlugin::getPriority)); - return sorted; + return plugins.stream().sorted(Comparator.comparingInt(EventPlugin::getPriority)).toList(); } /** Adds a plugin, replacing any existing plugin with the same name. */ @@ -925,26 +918,10 @@ public ExceptionlessConfiguration addPlugin(EventPlugin plugin) { return this; } - /** Adds a plugin from a lambda. Lower priorities run first. */ + /** 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( - new EventPlugin() { - @Override - public String getName() { - return name; - } - - @Override - public int getPriority() { - return priority; - } - - @Override - public void run(EventPluginContext context) { - plugin.accept(context); - } - }); + return addPlugin(EventPlugin.of(name, priority, plugin)); } public ExceptionlessConfiguration removePlugin(String name) { diff --git a/exceptionless-core/src/main/java/com/exceptionless/SubmittingEvent.java b/exceptionless-core/src/main/java/com/exceptionless/SubmittingEvent.java index acab582..7de14a3 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/SubmittingEvent.java +++ b/exceptionless-core/src/main/java/com/exceptionless/SubmittingEvent.java @@ -19,22 +19,27 @@ public final class SubmittingEvent { this.contextData = contextData; } + /** 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/ModuleCollector.java b/exceptionless-core/src/main/java/com/exceptionless/internal/ModuleCollector.java index dd5a507..5493933 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/ModuleCollector.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/ModuleCollector.java @@ -47,7 +47,8 @@ public static List getModules() { } static List collect() { - Map byName = new LinkedHashMap<>(); + // 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) { @@ -60,29 +61,31 @@ static List collect() { Enumeration manifests = loader.getResources("META-INF/MANIFEST.MF"); while (manifests.hasMoreElements() && byName.size() < MAX_MODULES) { URL url = manifests.nextElement(); - ModuleInfo module = read(url, entryPoint); + Library module = read(url, entryPoint); if (module != null) { - String key = module.getName() + "@" + module.getVersion(); - ModuleInfo existing = byName.putIfAbsent(key, module); - if (existing != null && module.isEntry()) { - existing.setEntry(true); - } + byName.merge(module.name() + "@" + module.version(), module, Library::merge); } } } catch (IOException | RuntimeException | LinkageError e) { LOG.debug(() -> "Unable to collect modules: " + e); } - List modules = new ArrayList<>(byName.values()); - modules.sort( - Comparator.comparing( - (ModuleInfo m) -> String.valueOf(m.getName()), String.CASE_INSENSITIVE_ORDER)); - for (int i = 0; i < modules.size(); i++) { - modules.get(i).setModuleId(i); + 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; } - private static @Nullable ModuleInfo read(URL url, @Nullable String entryPoint) { + /** 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 { @@ -124,16 +127,14 @@ static List collect() { attributes.getValue("Bundle-Version"), fileVersion); - ModuleInfo module = new ModuleInfo().setName(name).setVersion(version); String mainClass = attributes.getValue("Main-Class"); String startClass = attributes.getValue("Start-Class"); - if (entryPoint != null - && (entryPoint.equals(mainClass) - || entryPoint.equals(startClass) - || (entryPoint.endsWith(".jar") && location.contains(fileNameOf(entryPoint))))) { - module.setEntry(true); - } - return module; + boolean entry = + entryPoint != null + && (entryPoint.equals(mainClass) + || entryPoint.equals(startClass) + || (entryPoint.endsWith(".jar") && location.contains(fileNameOf(entryPoint)))); + return new Library(name, version, entry); } private static @Nullable String entryPoint() { 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/settings/SettingsManager.java b/exceptionless-core/src/main/java/com/exceptionless/internal/SettingsManager.java similarity index 63% rename from exceptionless-core/src/main/java/com/exceptionless/settings/SettingsManager.java rename to exceptionless-core/src/main/java/com/exceptionless/internal/SettingsManager.java index 8648d1b..d50c157 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/settings/SettingsManager.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/SettingsManager.java @@ -1,14 +1,10 @@ -package com.exceptionless.settings; +package com.exceptionless.internal; import com.exceptionless.ExceptionlessConfiguration; -import com.exceptionless.internal.Log; -import com.exceptionless.json.JsonParser; -import com.exceptionless.json.JsonWriter; import com.exceptionless.storage.KeyValueStorage; import com.exceptionless.submission.SettingsResponse; import com.exceptionless.submission.SubmissionClient; -import java.util.LinkedHashMap; -import java.util.Map; +import java.util.Objects; import java.util.concurrent.locks.ReentrantLock; import org.jspecify.annotations.Nullable; @@ -47,18 +43,9 @@ public boolean loadSavedSettings() { return false; } try { - Map json = JsonParser.parseObject(saved); - Map settings = new LinkedHashMap<>(); - if (json.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())); - } - } - } - int savedVersion = json.get("version") instanceof Number n ? n.intValue() : 0; - config.applyServerSettings(settings); - version = savedVersion; + 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); @@ -79,21 +66,15 @@ public void updateSettings() { return; } try { - SettingsResponse response = submissionClient.getSettings(version, config); - if (response.notModified()) { - return; - } - if (!response.success()) { - LOG.debug(() -> String.valueOf(response.message())); - return; + 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()); } - config.applyServerSettings(response.settings()); - version = response.version(); - storage.write( - STORAGE_KEY, - JsonWriter.toJson( - Map.of("version", response.version(), "settings", response.settings()))); - LOG.debug(() -> "Applied settings version " + response.version()); } catch (RuntimeException e) { LOG.warn("Unable to update settings", e); } finally { diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/Strings.java b/exceptionless-core/src/main/java/com/exceptionless/internal/Strings.java index a239ada..5850c0f 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/Strings.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Strings.java @@ -1,5 +1,7 @@ package com.exceptionless.internal; +import java.util.ArrayList; +import java.util.List; import org.jspecify.annotations.Nullable; /** String helpers. */ @@ -18,6 +20,21 @@ public static boolean isBlank(@Nullable String value) { return trimmed.isEmpty() ? null : trimmed; } + /** Splits a comma, semicolon or newline separated list, trimming and dropping blanks. */ + public static List splitList(@Nullable String value) { + if (value == null || value.isBlank()) { + return List.of(); + } + List result = new ArrayList<>(); + for (String part : value.split("[,;\\r\\n]")) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + result.add(trimmed); + } + } + return List.copyOf(result); + } + public static @Nullable String truncate(@Nullable String value, int maxLength) { if (value == null || value.length() <= maxLength) { return value; diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/EnvironmentInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/EnvironmentInfo.java index 93e2fdd..ff0cbc6 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/EnvironmentInfo.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/EnvironmentInfo.java @@ -30,144 +30,128 @@ public final class EnvironmentInfo implements JsonSerializable { return processorCount; } - public EnvironmentInfo setProcessorCount(@Nullable Integer processorCount) { + public void setProcessorCount(@Nullable Integer processorCount) { this.processorCount = processorCount; - return this; } public @Nullable Long getTotalPhysicalMemory() { return totalPhysicalMemory; } - public EnvironmentInfo setTotalPhysicalMemory(@Nullable Long totalPhysicalMemory) { + public void setTotalPhysicalMemory(@Nullable Long totalPhysicalMemory) { this.totalPhysicalMemory = totalPhysicalMemory; - return this; } public @Nullable Long getAvailablePhysicalMemory() { return availablePhysicalMemory; } - public EnvironmentInfo setAvailablePhysicalMemory(@Nullable Long availablePhysicalMemory) { + public void setAvailablePhysicalMemory(@Nullable Long availablePhysicalMemory) { this.availablePhysicalMemory = availablePhysicalMemory; - return this; } public @Nullable String getCommandLine() { return commandLine; } - public EnvironmentInfo setCommandLine(@Nullable String commandLine) { + public void setCommandLine(@Nullable String commandLine) { this.commandLine = commandLine; - return this; } public @Nullable String getProcessName() { return processName; } - public EnvironmentInfo setProcessName(@Nullable String processName) { + public void setProcessName(@Nullable String processName) { this.processName = processName; - return this; } public @Nullable String getProcessId() { return processId; } - public EnvironmentInfo setProcessId(@Nullable String processId) { + public void setProcessId(@Nullable String processId) { this.processId = processId; - return this; } public @Nullable Long getProcessMemorySize() { return processMemorySize; } - public EnvironmentInfo setProcessMemorySize(@Nullable Long processMemorySize) { + public void setProcessMemorySize(@Nullable Long processMemorySize) { this.processMemorySize = processMemorySize; - return this; } public @Nullable String getThreadName() { return threadName; } - public EnvironmentInfo setThreadName(@Nullable String threadName) { + public void setThreadName(@Nullable String threadName) { this.threadName = threadName; - return this; } public @Nullable String getThreadId() { return threadId; } - public EnvironmentInfo setThreadId(@Nullable String threadId) { + public void setThreadId(@Nullable String threadId) { this.threadId = threadId; - return this; } public @Nullable String getArchitecture() { return architecture; } - public EnvironmentInfo setArchitecture(@Nullable String architecture) { + public void setArchitecture(@Nullable String architecture) { this.architecture = architecture; - return this; } public @Nullable String getOsName() { return osName; } - public EnvironmentInfo setOsName(@Nullable String osName) { + public void setOsName(@Nullable String osName) { this.osName = osName; - return this; } public @Nullable String getOsVersion() { return osVersion; } - public EnvironmentInfo setOsVersion(@Nullable String osVersion) { + public void setOsVersion(@Nullable String osVersion) { this.osVersion = osVersion; - return this; } public @Nullable String getIpAddress() { return ipAddress; } - public EnvironmentInfo setIpAddress(@Nullable String ipAddress) { + public void setIpAddress(@Nullable String ipAddress) { this.ipAddress = ipAddress; - return this; } public @Nullable String getMachineName() { return machineName; } - public EnvironmentInfo setMachineName(@Nullable String machineName) { + public void setMachineName(@Nullable String machineName) { this.machineName = machineName; - return this; } public @Nullable String getInstallId() { return installId; } - public EnvironmentInfo setInstallId(@Nullable String installId) { + public void setInstallId(@Nullable String installId) { this.installId = installId; - return this; } public @Nullable String getRuntimeVersion() { return runtimeVersion; } - public EnvironmentInfo setRuntimeVersion(@Nullable String runtimeVersion) { + public void setRuntimeVersion(@Nullable String runtimeVersion) { this.runtimeVersion = runtimeVersion; - return this; } public Map getData() { diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/ErrorInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/ErrorInfo.java index 955f7c5..bbae24e 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/ErrorInfo.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/ErrorInfo.java @@ -24,9 +24,8 @@ public final class ErrorInfo implements JsonSerializable { return message; } - public ErrorInfo setMessage(@Nullable String message) { + public void setMessage(@Nullable String message) { this.message = message; - return this; } /** The fully qualified exception class name. */ @@ -34,18 +33,16 @@ public ErrorInfo setMessage(@Nullable String message) { return type; } - public ErrorInfo setType(@Nullable String type) { + public void setType(@Nullable String type) { this.type = type; - return this; } public @Nullable String getCode() { return code; } - public ErrorInfo setCode(@Nullable String code) { + public void setCode(@Nullable String code) { this.code = code; - return this; } /** The cause of this error, if any. */ @@ -53,9 +50,8 @@ public ErrorInfo setCode(@Nullable String code) { return inner; } - public ErrorInfo setInner(@Nullable ErrorInfo inner) { + public void setInner(@Nullable ErrorInfo inner) { this.inner = inner; - return this; } public List getStackTrace() { @@ -95,6 +91,7 @@ public void toJson(JsonWriter writer) { @Override public String toString() { - return type + (message != null ? ": " + message : ""); + 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 index 5a9584a..17141b1 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/Event.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/Event.java @@ -4,7 +4,9 @@ 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; @@ -31,9 +33,8 @@ public final class Event implements JsonSerializable { return type; } - public Event setType(@Nullable String type) { + public void setType(@Nullable String type) { this.type = type; - return this; } public boolean isError() { @@ -60,56 +61,69 @@ public boolean isNotFound() { return source; } - public Event setSource(@Nullable String source) { + public void setSource(@Nullable String source) { this.source = source; - return this; } public @Nullable OffsetDateTime getDate() { return date; } - public Event setDate(@Nullable OffsetDateTime date) { + public void setDate(@Nullable OffsetDateTime date) { this.date = date; - return this; } - /** The event's tags. The set is live and may be modified. */ + /** + * The event's tags, as a read-only view. Use {@link #addTags(String...)} and {@link + * #removeTags(String...)} to change them. + */ public Set getTags() { - return tags; + return Collections.unmodifiableSet(tags); } - public Event addTags(String... 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) { - addTag(tag); + String stripped = normalizeTag(tag); + if (stripped != null) { + this.tags.add(stripped); + } } - return this; } - public Event addTags(Collection tags) { + public void removeTags(String... tags) { for (String tag : tags) { - addTag(tag); + String stripped = normalizeTag(tag); + if (stripped != null) { + this.tags.remove(stripped); + } } - return this; } - private void addTag(@Nullable String tag) { + private static @Nullable String normalizeTag(@Nullable String tag) { if (tag == null) { - return; - } - String trimmed = tag.trim(); - if (!trimmed.isEmpty()) { - tags.add(trimmed); + return null; } + String stripped = tag.strip(); + return stripped.isEmpty() ? null : stripped; } public @Nullable String getMessage() { return message; } - public Event setMessage(@Nullable String message) { + public void setMessage(@Nullable String message) { this.message = message; - return this; } /** The location as {@code "latitude,longitude"}. */ @@ -117,27 +131,24 @@ public Event setMessage(@Nullable String message) { return geo; } - public Event setGeo(@Nullable String geo) { + public void setGeo(@Nullable String geo) { this.geo = geo; - return this; } public @Nullable BigDecimal getValue() { return value; } - public Event setValue(@Nullable BigDecimal value) { + public void setValue(@Nullable BigDecimal value) { this.value = value; - return this; } public @Nullable Integer getCount() { return count; } - public Event setCount(@Nullable Integer count) { + public void setCount(@Nullable Integer count) { this.count = count; - return this; } /** Extended data. The map is live and may be modified. */ @@ -149,49 +160,48 @@ public Map getData() { return referenceId; } - public Event setReferenceId(@Nullable String referenceId) { + public void setReferenceId(@Nullable String referenceId) { this.referenceId = referenceId; - return this; } public @Nullable ErrorInfo getError() { return data.get(KnownDataKeys.ERROR) instanceof ErrorInfo error ? error : null; } - public Event setError(@Nullable ErrorInfo error) { - return putOrRemove(KnownDataKeys.ERROR, error); + 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 Event setRequestInfo(@Nullable RequestInfo requestInfo) { - return putOrRemove(KnownDataKeys.REQUEST_INFO, requestInfo); + 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 Event setEnvironmentInfo(@Nullable EnvironmentInfo environmentInfo) { - return putOrRemove(KnownDataKeys.ENVIRONMENT_INFO, environmentInfo); + 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 Event setUserInfo(@Nullable UserInfo userInfo) { - return putOrRemove(KnownDataKeys.USER_INFO, userInfo); + 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 Event setUserDescription(@Nullable UserDescription userDescription) { - return putOrRemove(KnownDataKeys.USER_DESCRIPTION, userDescription); + public void setUserDescription(@Nullable UserDescription userDescription) { + putOrRemove(KnownDataKeys.USER_DESCRIPTION, userDescription); } public @Nullable ManualStackingInfo getManualStackingInfo() { @@ -200,8 +210,8 @@ public Event setUserDescription(@Nullable UserDescription userDescription) { : null; } - public Event setManualStackingInfo(@Nullable ManualStackingInfo info) { - return putOrRemove(KnownDataKeys.MANUAL_STACKING_INFO, info); + public void setManualStackingInfo(@Nullable ManualStackingInfo info) { + putOrRemove(KnownDataKeys.MANUAL_STACKING_INFO, info); } /** The log level ({@code @level}), e.g. {@code "error"}. */ @@ -209,24 +219,24 @@ public Event setManualStackingInfo(@Nullable ManualStackingInfo info) { return data.get(KnownDataKeys.LEVEL) instanceof String level ? level : null; } - public Event setLevel(@Nullable String level) { - return putOrRemove(KnownDataKeys.LEVEL, level); + 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 Event setVersion(@Nullable String version) { - return putOrRemove(KnownDataKeys.VERSION, version); + 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 Event setSubmissionMethod(@Nullable String submissionMethod) { - return putOrRemove(KnownDataKeys.SUBMISSION_METHOD, submissionMethod); + public void setSubmissionMethod(@Nullable String submissionMethod) { + putOrRemove(KnownDataKeys.SUBMISSION_METHOD, submissionMethod); } /** The id of the session this event belongs to ({@code @ref:session}). */ @@ -238,17 +248,16 @@ public Event setSubmissionMethod(@Nullable String submissionMethod) { return data.get(KnownDataKeys.REFERENCE_PREFIX + name) instanceof String id ? id : null; } - public Event setEventReference(String name, @Nullable String id) { - return putOrRemove(KnownDataKeys.REFERENCE_PREFIX + name, id); + public void setEventReference(String name, @Nullable String id) { + putOrRemove(KnownDataKeys.REFERENCE_PREFIX + name, id); } - private Event putOrRemove(String key, @Nullable Object value) { + private void putOrRemove(String key, @Nullable Object value) { if (value == null) { data.remove(key); } else { data.put(key, value); } - return this; } @Override diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/ManualStackingInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/ManualStackingInfo.java index 334a848..14299c7 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/ManualStackingInfo.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/ManualStackingInfo.java @@ -2,6 +2,8 @@ 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; @@ -15,6 +17,11 @@ public record ManualStackingInfo(@Nullable String title, Map sig /** 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)); } diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/ModuleInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/ModuleInfo.java index c5a6403..4f871c0 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/ModuleInfo.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/ModuleInfo.java @@ -2,57 +2,18 @@ import com.exceptionless.json.JsonSerializable; import com.exceptionless.json.JsonWriter; -import java.util.LinkedHashMap; -import java.util.Map; import org.jspecify.annotations.Nullable; -/** A loaded library (jar) and its version. */ -public final class ModuleInfo implements JsonSerializable { - private int moduleId; - private @Nullable String name; - private @Nullable String version; - private boolean entry; - private final Map data = new LinkedHashMap<>(); - - public int getModuleId() { - return moduleId; - } - - public ModuleInfo setModuleId(int moduleId) { - this.moduleId = moduleId; - return this; - } - - public @Nullable String getName() { - return name; - } - - public ModuleInfo setName(@Nullable String name) { - this.name = name; - return this; - } - - public @Nullable String getVersion() { - return version; - } - - public ModuleInfo setVersion(@Nullable String version) { - this.version = version; - return this; - } - - public boolean isEntry() { - return entry; - } - - public ModuleInfo setEntry(boolean entry) { - this.entry = entry; - return this; - } - - public Map getData() { - return data; - } +/** + * 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) { @@ -62,12 +23,11 @@ public void toJson(JsonWriter writer) { .fieldIfNotEmpty("name", name) .fieldIfNotEmpty("version", version) .field("is_entry", entry) - .fieldIfNotEmpty("data", data) .endObject(); } @Override public String toString() { - return name + (version != null ? " " + version : ""); + 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 index a632b00..b8b8182 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/RequestInfo.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/RequestInfo.java @@ -27,18 +27,16 @@ public final class RequestInfo implements JsonSerializable { return userAgent; } - public RequestInfo setUserAgent(@Nullable String userAgent) { + public void setUserAgent(@Nullable String userAgent) { this.userAgent = userAgent; - return this; } public @Nullable String getHttpMethod() { return httpMethod; } - public RequestInfo setHttpMethod(@Nullable String httpMethod) { + public void setHttpMethod(@Nullable String httpMethod) { this.httpMethod = httpMethod; - return this; } /** True when the request used HTTPS. */ @@ -46,54 +44,48 @@ public RequestInfo setHttpMethod(@Nullable String httpMethod) { return secure; } - public RequestInfo setSecure(@Nullable Boolean secure) { + public void setSecure(@Nullable Boolean secure) { this.secure = secure; - return this; } public @Nullable String getHost() { return host; } - public RequestInfo setHost(@Nullable String host) { + public void setHost(@Nullable String host) { this.host = host; - return this; } public @Nullable Integer getPort() { return port; } - public RequestInfo setPort(@Nullable Integer port) { + public void setPort(@Nullable Integer port) { this.port = port; - return this; } public @Nullable String getPath() { return path; } - public RequestInfo setPath(@Nullable String path) { + public void setPath(@Nullable String path) { this.path = path; - return this; } public @Nullable String getReferrer() { return referrer; } - public RequestInfo setReferrer(@Nullable String referrer) { + public void setReferrer(@Nullable String referrer) { this.referrer = referrer; - return this; } public @Nullable String getClientIpAddress() { return clientIpAddress; } - public RequestInfo setClientIpAddress(@Nullable String clientIpAddress) { + public void setClientIpAddress(@Nullable String clientIpAddress) { this.clientIpAddress = clientIpAddress; - return this; } /** Request headers. Each header maps to all of its values. */ @@ -101,18 +93,16 @@ public RequestInfo setClientIpAddress(@Nullable String clientIpAddress) { return headers; } - public RequestInfo setHeaders(@Nullable Map> headers) { + public void setHeaders(@Nullable Map> headers) { this.headers = headers; - return this; } public @Nullable Map getCookies() { return cookies; } - public RequestInfo setCookies(@Nullable Map cookies) { + public void setCookies(@Nullable Map cookies) { this.cookies = cookies; - return this; } /** Form fields (a map) or the raw request body (a string). */ @@ -120,18 +110,16 @@ public RequestInfo setCookies(@Nullable Map cookies) { return postData; } - public RequestInfo setPostData(@Nullable Object postData) { + public void setPostData(@Nullable Object postData) { this.postData = postData; - return this; } public @Nullable Map getQueryString() { return queryString; } - public RequestInfo setQueryString(@Nullable Map queryString) { + public void setQueryString(@Nullable Map queryString) { this.queryString = queryString; - return this; } public Map getData() { diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/StackFrame.java b/exceptionless-core/src/main/java/com/exceptionless/models/StackFrame.java index de331ac..cbba5db 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/StackFrame.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/StackFrame.java @@ -22,63 +22,56 @@ public final class StackFrame implements JsonSerializable { return name; } - public StackFrame setName(@Nullable String name) { + public void setName(@Nullable String name) { this.name = name; - return this; } public @Nullable String getDeclaringNamespace() { return declaringNamespace; } - public StackFrame setDeclaringNamespace(@Nullable String declaringNamespace) { + public void setDeclaringNamespace(@Nullable String declaringNamespace) { this.declaringNamespace = declaringNamespace; - return this; } public @Nullable String getDeclaringType() { return declaringType; } - public StackFrame setDeclaringType(@Nullable String declaringType) { + public void setDeclaringType(@Nullable String declaringType) { this.declaringType = declaringType; - return this; } public @Nullable String getFileName() { return fileName; } - public StackFrame setFileName(@Nullable String fileName) { + public void setFileName(@Nullable String fileName) { this.fileName = fileName; - return this; } public @Nullable Integer getLineNumber() { return lineNumber; } - public StackFrame setLineNumber(@Nullable Integer lineNumber) { + public void setLineNumber(@Nullable Integer lineNumber) { this.lineNumber = lineNumber; - return this; } public @Nullable Integer getColumn() { return column; } - public StackFrame setColumn(@Nullable Integer column) { + public void setColumn(@Nullable Integer column) { this.column = column; - return this; } public @Nullable Integer getModuleId() { return moduleId; } - public StackFrame setModuleId(@Nullable Integer moduleId) { + public void setModuleId(@Nullable Integer moduleId) { this.moduleId = moduleId; - return this; } public Map getData() { @@ -115,6 +108,9 @@ public void toJson(JsonWriter writer) { @Override public String toString() { - return getFullName() + (fileName != null ? "(" + fileName + ":" + lineNumber + ")" : ""); + 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 index 6a0185c..6473560 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/UserDescription.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/UserDescription.java @@ -2,6 +2,8 @@ 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; @@ -10,6 +12,11 @@ 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); } diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/UserInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/UserInfo.java index e7e9c4f..ab193e3 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/UserInfo.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/UserInfo.java @@ -2,6 +2,8 @@ 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; @@ -10,6 +12,11 @@ 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); } diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPlugin.java index 62383da..018650f 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPlugin.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPlugin.java @@ -1,6 +1,7 @@ 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 @@ -8,6 +9,21 @@ * 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(); diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPluginContext.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPluginContext.java index 082b4a8..6c43fc8 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPluginContext.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPluginContext.java @@ -9,7 +9,7 @@ public final class EventPluginContext { private final ExceptionlessClient client; private final Event event; private final ContextData contextData; - private volatile boolean cancelled; + private boolean cancelled; public EventPluginContext(ExceptionlessClient client, Event event, ContextData contextData) { this.client = client; @@ -17,22 +17,27 @@ public EventPluginContext(ExceptionlessClient client, Event event, ContextData c this.contextData = contextData; } + /** The client processing the event. */ public ExceptionlessClient getClient() { return client; } + /** 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; } @@ -41,8 +46,4 @@ public boolean isCancelled() { public void cancel() { this.cancelled = true; } - - public void setCancelled(boolean cancelled) { - this.cancelled = cancelled; - } } 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 index c11585e..452408f 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/ReferenceIdPlugin.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/ReferenceIdPlugin.java @@ -19,7 +19,7 @@ public int getPriority() { @Override public void run(EventPluginContext context) { Event event = context.getEvent(); - if (context.getConfiguration().isUseReferenceIds() + 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 index 31c9290..e5f2de5 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/RequestInfoPlugin.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/RequestInfoPlugin.java @@ -10,6 +10,7 @@ import java.util.Map; 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 @@ -19,7 +20,7 @@ public final class RequestInfoPlugin implements EventPlugin { public static final int PRIORITY = 60; /** Headers that are never sent: credentials, and values already captured elsewhere. */ - public static final Set IGNORED_HEADERS = + private static final Set IGNORED_HEADERS = Set.of( "authorization", "proxy-authorization", @@ -55,7 +56,7 @@ public void run(EventPluginContext context) { } /** Applies the privacy settings and data exclusions to {@code request} in place. */ - public static void sanitize(RequestInfo request, ExceptionlessConfiguration config) { + private static void sanitize(RequestInfo request, ExceptionlessConfiguration config) { Predicate excluded = Exclusions.of(config.getDataExclusions()); if (!config.isIncludeIpAddress()) { @@ -83,11 +84,12 @@ public static void sanitize(RequestInfo request, ExceptionlessConfiguration conf } request.setPostData(filtered.isEmpty() ? null : filtered); } + Map> headers = request.getHeaders(); if (!config.isIncludeHeaders()) { request.setHeaders(null); - } else if (request.getHeaders() != null) { + } else if (headers != null) { Map> filtered = new LinkedHashMap<>(); - for (Map.Entry> entry : request.getHeaders().entrySet()) { + for (Map.Entry> entry : headers.entrySet()) { String name = entry.getKey(); if (name == null || IGNORED_HEADERS.contains(name.toLowerCase(Locale.ROOT)) @@ -102,8 +104,8 @@ public static void sanitize(RequestInfo request, ExceptionlessConfiguration conf } } - private static Map filter( - Map values, Predicate excluded) { + private static @Nullable Map filter( + @Nullable Map values, Predicate excluded) { if (values == null || values.isEmpty()) { return null; } diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/VersionPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/VersionPlugin.java index 4a45479..febb699 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/VersionPlugin.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/VersionPlugin.java @@ -38,8 +38,9 @@ public void run(EventPluginContext context) { private @Nullable String detect() { if (!detectionAttempted) { for (ModuleInfo module : ModuleCollector.getModules()) { - if (module.isEntry() && module.getVersion() != null) { - detected = module.getVersion(); + String version = module.version(); + if (module.entry() && version != null) { + detected = version; break; } } diff --git a/exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java b/exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java index f3a5878..0100222 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java +++ b/exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java @@ -17,7 +17,6 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.locks.ReentrantLock; -import java.util.function.BiConsumer; /** * The default queue. Events are serialized when queued, so a bad event can never block the queue, @@ -52,8 +51,7 @@ public final class DefaultEventQueue implements EventQueue { private final QueueStorage storage; private final Clock clock; private final ReentrantLock processLock = new ReentrantLock(); - private final List> handlers = - new CopyOnWriteArrayList<>(); + private final List listeners = new CopyOnWriteArrayList<>(); private volatile Instant suspendedUntil = Instant.MIN; private volatile Instant discardUntil = Instant.MIN; @@ -117,7 +115,7 @@ public void process() { } } catch (RuntimeException e) { LOG.error("Error processing the event queue", e); - suspendProcessing(Duration.ofMinutes(1), false, false); + suspendProcessing(Duration.ofMinutes(1)); } finally { processLock.unlock(); } @@ -170,16 +168,16 @@ private boolean send(List batch) { SubmissionResponse response; try { - response = submissionClient.postEvents(json.toString(), config); + response = submissionClient.postEvents(json.toString()); } catch (RuntimeException e) { response = SubmissionResponse.failed(e); } boolean keepGoing = handleResponse(batch, response); - for (BiConsumer handler : handlers) { + for (EventsPostedListener listener : listeners) { try { - handler.accept(batch.size(), response); + listener.eventsPosted(batch.size(), response); } catch (RuntimeException e) { - LOG.warn("EventsPosted handler failed", e); + LOG.warn("EventsPosted listener failed", e); } } return keepGoing; @@ -201,12 +199,12 @@ private boolean handleResponse(List batch, SubmissionResponse respon window = window.plus(15, ChronoUnit.MINUTES); } LOG.warn("Rate limit reached; pausing event submission until " + window); - suspendProcessing(Duration.between(now, window), false, false); + suspendProcessing(Duration.between(now, window)); return false; } if (response.isServiceUnavailable()) { LOG.warn("Server unavailable (503); pausing event submission for 5 minutes"); - suspendProcessing(Duration.ofMinutes(5), false, false); + suspendProcessing(Duration.ofMinutes(5)); return false; } if (response.isPaymentRequired()) { @@ -218,13 +216,13 @@ private boolean handleResponse(List batch, SubmissionResponse respon LOG.error( "Unable to send events: the API key is invalid or not authorized (" + status + ")", null); storage.remove(batch); - suspendProcessing(Duration.ofMinutes(15), false, false); + suspendProcessing(Duration.ofMinutes(15)); return false; } if (response.isBadRequest() || response.isNotFound()) { LOG.error("Unable to send events (HTTP " + status + "): " + response.message(), null); storage.remove(batch); - suspendProcessing(Duration.ofHours(4), false, false); + suspendProcessing(Duration.ofHours(4)); return false; } if (response.isRequestEntityTooLarge()) { @@ -250,7 +248,7 @@ private boolean handleResponse(List batch, SubmissionResponse respon + "); retrying in " + seconds + " seconds"); - suspendProcessing(Duration.ofSeconds(seconds), false, false); + suspendProcessing(Duration.ofSeconds(seconds)); return false; } @@ -284,11 +282,7 @@ public int size() { } @Override - public void onEventsPosted(BiConsumer handler) { - handlers.add(handler); - } - - public QueueStorage getStorage() { - return storage; + 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 index 6d40cc1..74692d7 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/queue/EventQueue.java +++ b/exceptionless-core/src/main/java/com/exceptionless/queue/EventQueue.java @@ -1,9 +1,7 @@ package com.exceptionless.queue; import com.exceptionless.models.Event; -import com.exceptionless.submission.SubmissionResponse; import java.time.Duration; -import java.util.function.BiConsumer; /** Queues events and sends them to the server in batches. */ public interface EventQueue { @@ -13,6 +11,11 @@ public interface EventQueue { /** 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}. * @@ -29,6 +32,6 @@ public interface EventQueue { /** The number of queued events. */ int size(); - /** Called after each batch is posted with the number of events and the response. */ - void onEventsPosted(BiConsumer handler); + /** 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/settings/ClientSettings.java b/exceptionless-core/src/main/java/com/exceptionless/settings/ClientSettings.java index f2f2754..bfd3370 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/settings/ClientSettings.java +++ b/exceptionless-core/src/main/java/com/exceptionless/settings/ClientSettings.java @@ -1,6 +1,7 @@ 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; @@ -82,7 +83,7 @@ public boolean getBoolean(String key, boolean defaultValue) { /** A comma, semicolon or newline separated list setting. */ public List getStringList(String key) { - return splitList(values.get(key)); + return Strings.splitList(values.get(key)); } /** @@ -156,21 +157,6 @@ public boolean getTypeAndSourceSetting( }; } - /** Splits a comma, semicolon or newline separated list, trimming and dropping blanks. */ - public static List splitList(@Nullable String value) { - if (value == null || value.isBlank()) { - return List.of(); - } - List result = new ArrayList<>(); - for (String part : value.split("[,;\\r\\n]")) { - String trimmed = part.trim(); - if (!trimmed.isEmpty()) { - result.add(trimmed); - } - } - return List.copyOf(result); - } - @Override public String toString() { return values.toString(); diff --git a/exceptionless-core/src/main/java/com/exceptionless/submission/DefaultSubmissionClient.java b/exceptionless-core/src/main/java/com/exceptionless/submission/DefaultSubmissionClient.java index 7d311ca..e5e0124 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/submission/DefaultSubmissionClient.java +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/DefaultSubmissionClient.java @@ -3,21 +3,21 @@ import com.exceptionless.ExceptionlessConfiguration; import com.exceptionless.internal.ClientInfo; import com.exceptionless.internal.Log; -import com.exceptionless.json.JsonParser; +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.LinkedHashMap; -import java.util.Map; 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. */ @@ -26,9 +26,11 @@ public final class DefaultSubmissionClient implements SubmissionClient { 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); } @@ -39,92 +41,84 @@ private static HttpClient createHttpClient(ExceptionlessConfiguration config) { .version(HttpClient.Version.HTTP_1_1) .followRedirects(HttpClient.Redirect.NEVER) .connectTimeout(config.getConnectTimeout()); - if (config.getProxySelector() != null) { - builder.proxy(config.getProxySelector()); + ProxySelector proxy = config.getProxy(); + if (proxy != null) { + builder.proxy(proxy); } - if (config.getSslContext() != null) { - builder.sslContext(config.getSslContext()); + SSLContext sslContext = config.getSslContext(); + if (sslContext != null) { + builder.sslContext(sslContext); } return builder.build(); } @Override - public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { - return post(config.getServerUrl() + "/api/v2/events", eventsJson, config); + public SubmissionResponse postEvents(String eventsJson) { + return post(config.getServerUrl() + "/api/v2/events", eventsJson); } @Override - public SubmissionResponse postUserDescription( - String referenceId, UserDescription description, ExceptionlessConfiguration config) { + 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), config); + return post(url, JsonWriter.toJson(description)); } @Override - public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { + public SettingsResponse getSettings(int version) { String url = config.getConfigServerUrl() + "/api/v2/projects/config?v=" + version; try { HttpResponse response = - httpClient.send(request(url, config).GET().build(), HttpResponse.BodyHandlers.ofString()); + httpClient.send(request(url).GET().build(), HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 304) { - return SettingsResponse.notModifiedResponse(); + return new SettingsResponse.NotModified(); } if (response.statusCode() != 200) { - return SettingsResponse.failed("Unable to get settings: HTTP " + response.statusCode()); + return new SettingsResponse.Failed("Unable to get settings: HTTP " + response.statusCode()); } return parseSettings(response.body()); } catch (IOException e) { - return SettingsResponse.failed("Unable to get settings: " + e); + return new SettingsResponse.Failed("Unable to get settings: " + e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - return SettingsResponse.failed("Interrupted while getting settings"); + return new SettingsResponse.Failed("Interrupted while getting settings"); } catch (RuntimeException e) { - return SettingsResponse.failed("Unable to get settings: " + e); + return new SettingsResponse.Failed("Unable to get settings: " + e); } } static SettingsResponse parseSettings(String body) { - Map json; + SettingsJson.Parsed parsed; try { - json = JsonParser.parseObject(body); + parsed = SettingsJson.parse(body); } catch (IllegalArgumentException e) { - return SettingsResponse.failed("Invalid settings response: " + e.getMessage()); + return new SettingsResponse.Failed("Invalid settings response: " + e.getMessage()); } - Object rawVersion = json.get("version"); - if (!(rawVersion instanceof Number version)) { - return SettingsResponse.failed("Invalid settings response: missing version"); + Integer version = parsed.version(); + if (version == null) { + return new SettingsResponse.Failed("Invalid settings response: missing version"); } - Map settings = new LinkedHashMap<>(); - if (json.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())); - } - } - } - return SettingsResponse.of(settings, version.intValue()); + return new SettingsResponse.Updated(parsed.settings(), version); } @Override - public SubmissionResponse sendHeartbeat( - String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + 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, config).GET().build()); + return send(request(url).GET().build()); } - private SubmissionResponse post(String url, String json, ExceptionlessConfiguration config) { + private SubmissionResponse post(String url, String json) { byte[] body = json.getBytes(StandardCharsets.UTF_8); HttpRequest.Builder builder = - request(url, config).header("Content-Type", "application/json; charset=utf-8"); + request(url).header("Content-Type", "application/json; charset=utf-8"); if (body.length > COMPRESSION_THRESHOLD) { body = gzip(body); builder.header("Content-Encoding", "gzip"); @@ -165,7 +159,7 @@ private SubmissionResponse send(HttpRequest request) { } } - private static HttpRequest.Builder request(String url, ExceptionlessConfiguration config) { + private HttpRequest.Builder request(String url) { return HttpRequest.newBuilder(URI.create(url)) .timeout(config.getRequestTimeout()) .header("Authorization", "Bearer " + config.getApiKey()) diff --git a/exceptionless-core/src/main/java/com/exceptionless/submission/SettingsResponse.java b/exceptionless-core/src/main/java/com/exceptionless/submission/SettingsResponse.java index 5498a9b..859a505 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/submission/SettingsResponse.java +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/SettingsResponse.java @@ -1,33 +1,39 @@ package com.exceptionless.submission; import java.util.Map; -import org.jspecify.annotations.Nullable; /** - * The result of fetching project settings. + * The result of fetching project settings: {@link Updated new settings}, {@link NotModified no + * change}, or {@link Failed a failure}. * - * @param success whether new settings were received - * @param notModified true when the server's settings version matches ours (HTTP 304) - * @param settings the settings, when successful - * @param version the settings version, when successful - * @param message an error message, when not successful + *
{@code
+ * if (client.getSettings(version) instanceof SettingsResponse.Updated updated) {
+ *   apply(updated.settings(), updated.version());
+ * }
+ * }
*/ -public record SettingsResponse( - boolean success, - boolean notModified, - Map settings, - int version, - @Nullable String message) { +public sealed interface SettingsResponse + permits SettingsResponse.Updated, SettingsResponse.NotModified, SettingsResponse.Failed { - public static SettingsResponse notModifiedResponse() { - return new SettingsResponse(false, true, Map.of(), -1, null); + /** + * 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); + } } - public static SettingsResponse failed(String message) { - return new SettingsResponse(false, false, Map.of(), -1, message); - } + /** The server's settings version matches ours (HTTP 304). */ + record NotModified() implements SettingsResponse {} - public static SettingsResponse of(Map settings, int version) { - return new SettingsResponse(true, false, Map.copyOf(settings), version, null); - } + /** + * 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 index 2db1c0d..00210f9 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionClient.java +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionClient.java @@ -1,21 +1,18 @@ package com.exceptionless.submission; -import com.exceptionless.ExceptionlessConfiguration; 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, ExceptionlessConfiguration config); + 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, ExceptionlessConfiguration config); + SubmissionResponse postUserDescription(String referenceId, UserDescription description); /** Fetches the project settings if they are newer than {@code version}. */ - SettingsResponse getSettings(int version, ExceptionlessConfiguration config); + SettingsResponse getSettings(int version); /** Sends a session heartbeat, or ends the session when {@code closeSession} is true. */ - SubmissionResponse sendHeartbeat( - String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config); + 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 index ed411d6..0a0e9f3 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionResponse.java +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/SubmissionResponse.java @@ -12,11 +12,17 @@ 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; } diff --git a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessClientTest.java b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessClientTest.java index 554c30b..912f7fd 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessClientTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessClientTest.java @@ -279,7 +279,7 @@ void queueIsProcessedOnTheInterval() throws Exception { @Test void configVersionHeaderTriggersSettingsRefresh() { submission.eventsResponse = json -> new SubmissionResponse(202, null, 5); - submission.settingsResponse = SettingsResponse.of(Map.of("@@log:*", "error"), 5); + submission.settingsResponse = new SettingsResponse.Updated(Map.of("@@log:*", "error"), 5); client.submitLog("x"); client.flush(Duration.ofSeconds(5)); assertThat(submission.settingsRequests).contains(0); @@ -294,7 +294,7 @@ void configVersionHeaderTriggersSettingsRefresh() { @Test void updateSettingsOnDemand() throws Exception { - submission.settingsResponse = SettingsResponse.of(Map.of("feature", "on"), 1); + submission.settingsResponse = new SettingsResponse.Updated(Map.of("feature", "on"), 1); client.updateSettings().get(5, TimeUnit.SECONDS); assertThat(config.getSettings().getString("feature")).isEqualTo("on"); } @@ -317,7 +317,7 @@ void userDescriptions() throws Exception { void sessionHeartbeatsAndEnd() throws Exception { assertThat(client.submitSessionHeartbeat(null).get(5, TimeUnit.SECONDS).statusCode()) .isEqualTo(-1); - config.setUserIdentity("user-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"); @@ -325,7 +325,7 @@ void sessionHeartbeatsAndEnd() throws Exception { @Test void sessionsStartOnStartupAndEndOnClose() { - config.setUserIdentity("user-1", "Ann").useSessions(false, Duration.ofMinutes(1), false); + config.userIdentity("user-1", "Ann").useSessions(false, Duration.ofMinutes(1), false); client.startup(); client.close(); assertThat(submission.events()) @@ -355,7 +355,7 @@ void sessionIdManagementWithoutUser() { @Test void heartbeatsAreSentOnTheInterval() throws Exception { ExceptionlessConfiguration hbConfig = - TestClients.config().submissionClient(submission).setUserIdentity("hb-user"); + 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. @@ -376,7 +376,7 @@ void heartbeatsAreSentOnTheInterval() throws Exception { @Test void sessionEndStopsHeartbeats() throws Exception { - config.setUserIdentity("user-2").useSessions(true, Duration.ofMinutes(1), false); + config.userIdentity("user-2").useSessions(true, Duration.ofMinutes(1), false); client.submitLog("starts the heartbeat"); com.exceptionless.plugins.HeartbeatPlugin heartbeat = (com.exceptionless.plugins.HeartbeatPlugin) @@ -463,7 +463,7 @@ void fileStoragePersistsEventsAcrossRestarts(@TempDir Path dir) throws Exception @Test void savedSettingsApplyBeforeTheFirstRequest(@TempDir Path dir) { - submission.settingsResponse = SettingsResponse.of(Map.of("@@log:*", "off"), 3); + submission.settingsResponse = new SettingsResponse.Updated(Map.of("@@log:*", "off"), 3); try (ExceptionlessClient c = new ExceptionlessClient( TestClients.config().submissionClient(submission).useFileStorage(dir))) { @@ -479,7 +479,9 @@ void savedSettingsApplyBeforeTheFirstRequest(@TempDir Path dir) { @Test void manualEventSubmission() { - Event event = new Event().setType("custom").setMessage("hand built"); + Event event = new Event(); + event.setType("custom"); + event.setMessage("hand built"); EventPluginContext context = client.submitEvent(event, null); assertThat(context.isCancelled()).isFalse(); assertThat(sent()) diff --git a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java index 1be6a3b..fb787d1 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java @@ -19,7 +19,7 @@ void defaultsMatchTheHostedService() { assertThat(config.getConfigServerUrl()).isEqualTo("https://config.exceptionless.io"); assertThat(config.getHeartbeatServerUrl()).isEqualTo("https://heartbeat.exceptionless.io"); assertThat(config.isIncludePrivateInformation()).isTrue(); - assertThat(config.isUseReferenceIds()).isTrue(); + assertThat(config.isReferenceIdsEnabled()).isTrue(); assertThat(config.getSubmissionBatchSize()).isEqualTo(50); assertThat(config.getUpdateSettingsWhenIdleInterval()).isEqualTo(Duration.ofMinutes(2)); assertThat(config.isEnabled()).isFalse(); // no API key @@ -179,12 +179,12 @@ void isLogLevelEnabled() { } @Test - void setUserIdentityAddsDefaultUser() { + void userIdentityAddsDefaultUser() { ExceptionlessConfiguration config = - new ExceptionlessConfiguration().setUserIdentity("id-1", "Ann"); + new ExceptionlessConfiguration().userIdentity("id-1", "Ann"); assertThat(config.getDefaultData().get("@user")) .isEqualTo(new com.exceptionless.models.UserInfo("id-1", "Ann")); - config.setUserIdentity(null); + config.userIdentity(null); assertThat(config.getDefaultData()).doesNotContainKey("@user"); } } diff --git a/exceptionless-core/src/test/java/com/exceptionless/TestClients.java b/exceptionless-core/src/test/java/com/exceptionless/TestClients.java index b098094..47ea405 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/TestClients.java +++ b/exceptionless-core/src/test/java/com/exceptionless/TestClients.java @@ -35,31 +35,29 @@ public static final class CapturingSubmissionClient implements SubmissionClient public final List settingsRequests = new CopyOnWriteArrayList<>(); public volatile Function eventsResponse = json -> new SubmissionResponse(202, null, null); - public volatile SettingsResponse settingsResponse = SettingsResponse.notModifiedResponse(); + public volatile SettingsResponse settingsResponse = new SettingsResponse.NotModified(); @Override - public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { + public SubmissionResponse postEvents(String eventsJson) { posts.add(eventsJson); return eventsResponse.apply(eventsJson); } @Override - public SubmissionResponse postUserDescription( - String referenceId, UserDescription description, ExceptionlessConfiguration config) { + public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { userDescriptions.add( referenceId + "=" + com.exceptionless.json.JsonWriter.toJson(description)); return new SubmissionResponse(202, null, null); } @Override - public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { + public SettingsResponse getSettings(int version) { settingsRequests.add(version); return settingsResponse; } @Override - public SubmissionResponse sendHeartbeat( - String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { heartbeats.add(sessionIdOrUserId + (closeSession ? ":close" : "")); return new SubmissionResponse(200, null, null); } diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/CollectorsTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/CollectorsTest.java index 8a82443..d368183 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/internal/CollectorsTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/CollectorsTest.java @@ -39,10 +39,10 @@ void collectsModulesFromManifests() { assertThat(modules) .anySatisfy( m -> { - assertThat(m.getName()).containsIgnoringCase("assertj"); - assertThat(m.getVersion()).isNotBlank(); + assertThat(m.name()).containsIgnoringCase("assertj"); + assertThat(m.version()).isNotBlank(); }); - assertThat(modules).extracting(ModuleInfo::getModuleId).doesNotHaveDuplicates(); + assertThat(modules).extracting(ModuleInfo::moduleId).doesNotHaveDuplicates(); assertThat(ModuleCollector.getModules()).isSameAs(modules); } } diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/ErrorConverterTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/ErrorConverterTest.java index c1186a8..a00614f 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/internal/ErrorConverterTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/ErrorConverterTest.java @@ -173,7 +173,7 @@ private static StackOverflowError recurse(int depth) { @Test void attachesModulesToTheOutermostErrorOnly() { - ModuleInfo module = new ModuleInfo().setName("lib").setVersion("1.0"); + ModuleInfo module = new ModuleInfo(0, "lib", "1.0", false); ErrorInfo error = ErrorConverter.convert( new RuntimeException("x", new RuntimeException("y")), name -> false, List.of(module)); diff --git a/exceptionless-core/src/test/java/com/exceptionless/settings/SettingsManagerTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/SettingsManagerTest.java similarity index 80% rename from exceptionless-core/src/test/java/com/exceptionless/settings/SettingsManagerTest.java rename to exceptionless-core/src/test/java/com/exceptionless/internal/SettingsManagerTest.java index 00f83aa..2bc9edf 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/settings/SettingsManagerTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/SettingsManagerTest.java @@ -1,4 +1,4 @@ -package com.exceptionless.settings; +package com.exceptionless.internal; import static org.assertj.core.api.Assertions.assertThat; @@ -18,7 +18,7 @@ class SettingsManagerTest { @Test void appliesAndPersistsNewSettings() { - submission.settingsResponse = SettingsResponse.of(Map.of("@@log:*", "info"), 4); + submission.settingsResponse = new SettingsResponse.Updated(Map.of("@@log:*", "info"), 4); manager.updateSettings(); assertThat(config.getSettings().getString("@@log:*")).isEqualTo("info"); assertThat(manager.getVersion()).isEqualTo(4); @@ -30,9 +30,9 @@ void appliesAndPersistsNewSettings() { @Test void sendsCurrentVersionAndKeepsSettingsWhenNotModified() { - submission.settingsResponse = SettingsResponse.of(Map.of("a", "1"), 2); + submission.settingsResponse = new SettingsResponse.Updated(Map.of("a", "1"), 2); manager.updateSettings(); - submission.settingsResponse = SettingsResponse.notModifiedResponse(); + submission.settingsResponse = new SettingsResponse.NotModified(); manager.updateSettings(); assertThat(submission.settingsRequests).containsExactly(0, 2); assertThat(config.getSettings().getString("a")).isEqualTo("1"); @@ -40,9 +40,9 @@ void sendsCurrentVersionAndKeepsSettingsWhenNotModified() { @Test void removedServerSettingsAreDropped() { - submission.settingsResponse = SettingsResponse.of(Map.of("a", "1", "b", "2"), 1); + submission.settingsResponse = new SettingsResponse.Updated(Map.of("a", "1", "b", "2"), 1); manager.updateSettings(); - submission.settingsResponse = SettingsResponse.of(Map.of("a", "1"), 2); + submission.settingsResponse = new SettingsResponse.Updated(Map.of("a", "1"), 2); manager.updateSettings(); assertThat(config.getSettings().getString("b")).isNull(); } @@ -50,7 +50,7 @@ void removedServerSettingsAreDropped() { @Test void localSettingsSurviveButServerWins() { config.setting("@@log:*", "error").setting("local", "yes"); - submission.settingsResponse = SettingsResponse.of(Map.of("@@log:*", "debug"), 1); + 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"); @@ -58,9 +58,9 @@ void localSettingsSurviveButServerWins() { @Test void failuresKeepCurrentSettings() { - submission.settingsResponse = SettingsResponse.of(Map.of("a", "1"), 1); + submission.settingsResponse = new SettingsResponse.Updated(Map.of("a", "1"), 1); manager.updateSettings(); - submission.settingsResponse = SettingsResponse.failed("boom"); + submission.settingsResponse = new SettingsResponse.Failed("boom"); manager.updateSettings(); assertThat(config.getSettings().getString("a")).isEqualTo("1"); assertThat(manager.getVersion()).isEqualTo(1); @@ -88,7 +88,7 @@ void ignoresCorruptSavedSettings() { @Test void checkVersionOnlyUpdatesWhenNewer() { - submission.settingsResponse = SettingsResponse.of(Map.of(), 3); + submission.settingsResponse = new SettingsResponse.Updated(Map.of(), 3); manager.checkVersion(null); manager.checkVersion(0); assertThat(submission.settingsRequests).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 index deb3d41..c559215 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java @@ -95,22 +95,22 @@ void everyPropertyExistsOnTheServerModel() { 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( - new RequestInfo() - .setHttpMethod("GET") - .setSecure(true) - .setHost("h") - .setPort(443) - .setPath("/p") - .setReferrer("r") - .setUserAgent("ua") - .setClientIpAddress("1.2.3.4") - .setHeaders(headers) - .setCookies(Map.of("c", "v")) - .setQueryString(Map.of("q", "v")) - .setPostData(Map.of("f", "v"))) + .setRequestInfo(request) .setUserIdentity("id", "name") .setUserDescription("a@b.c", "d") .setManualStackingKey("key", "title") diff --git a/exceptionless-core/src/test/java/com/exceptionless/plugins/DefaultPluginsTest.java b/exceptionless-core/src/test/java/com/exceptionless/plugins/DefaultPluginsTest.java index c288128..b4d9930 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/plugins/DefaultPluginsTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/DefaultPluginsTest.java @@ -75,7 +75,7 @@ void configurationDefaultsAddTagsAndDataWithoutOverwriting() { @Test void configurationDefaultsNormalizeObjects() { config.addDefaultData("info", Map.of("nested", List.of(1, 2))); - config.setUserIdentity("user-1", "Ann"); + 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))); @@ -145,23 +145,24 @@ void errorPluginAttachesModulesWhenEnabled() { new ErrorPlugin(), client, event, new ContextData().setException(new RuntimeException())); assertThat(event.getError().getModules()).isNotEmpty(); assertThat(event.getError().getModules()) - .anySatisfy(m -> assertThat(m.getName()).contains("junit")); + .anySatisfy(m -> assertThat(m.name()).contains("junit")); } @Test void referenceIdsOnlyForErrors() { ReferenceIdPlugin plugin = new ReferenceIdPlugin(); - Event error = new Event().setType("error"); + Event error = event("error"); PluginTestSupport.run(plugin, client, error); assertThat(error.getReferenceId()).hasSize(16); - Event log = new Event().setType("log"); + Event log = event("log"); PluginTestSupport.run(plugin, client, log); assertThat(log.getReferenceId()).isNull(); - Event explicit = new Event().setType("error").setReferenceId("my-own-id"); + 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 = new Event().setType("error"); + Event disabled = event("error"); PluginTestSupport.run(plugin, client, disabled); assertThat(disabled.getReferenceId()).isNull(); } @@ -186,6 +187,24 @@ void environmentInfoRespectsPrivacy() { .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")); @@ -199,15 +218,16 @@ private static RequestInfo request() { Map query = new LinkedHashMap<>(); query.put("q", "search"); query.put("passwordReset", "1"); - return new RequestInfo() - .setHttpMethod("POST") - .setPath("/orders") - .setClientIpAddress("10.0.0.1") - .setUserAgent("Mozilla/5.0") - .setHeaders(headers) - .setCookies(cookies) - .setQueryString(query) - .setPostData(new LinkedHashMap<>(Map.of("name", "Ann", "password", "x"))); + 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 @@ -235,7 +255,7 @@ void requestInfoIsSanitized() { @Test void requestInfoRespectsPrivacySwitches() { config.includePrivateInformation(false); - Event event = new Event().setRequestInfo(request()); + Event event = withRequest(request()); PluginTestSupport.run(new RequestInfoPlugin(), client, event); RequestInfo request = event.getRequestInfo(); assertThat(request.getHeaders()).isNull(); @@ -251,7 +271,7 @@ void serverCanTurnOffPrivateInformation() { config.applyServerSettings(Map.of("@@IncludePrivateInformation", "false")); assertThat(config.isIncludePrivateInformation()).isFalse(); assertThat(config.isIncludeCookies()).isFalse(); - Event event = new Event().setRequestInfo(request()); + Event event = withRequest(request()); PluginTestSupport.run(new RequestInfoPlugin(), client, event); assertThat(event.getRequestInfo().getCookies()).isNull(); } @@ -261,26 +281,11 @@ void botsAreIgnored() { config.addUserAgentBotPatterns("*bot*"); config.applyServerSettings(Map.of("@@UserAgentBotPatterns", "*crawler*")); IgnoreUserAgentPlugin plugin = new IgnoreUserAgentPlugin(); - assertThat( - PluginTestSupport.run( - plugin, - client, - new Event().setRequestInfo(new RequestInfo().setUserAgent("Googlebot/2.1"))) - .isCancelled()) + assertThat(PluginTestSupport.run(plugin, client, withUserAgent("Googlebot/2.1")).isCancelled()) .isTrue(); - assertThat( - PluginTestSupport.run( - plugin, - client, - new Event().setRequestInfo(new RequestInfo().setUserAgent("SomeCrawler"))) - .isCancelled()) + assertThat(PluginTestSupport.run(plugin, client, withUserAgent("SomeCrawler")).isCancelled()) .isTrue(); - assertThat( - PluginTestSupport.run( - plugin, - client, - new Event().setRequestInfo(new RequestInfo().setUserAgent("Mozilla/5.0"))) - .isCancelled()) + assertThat(PluginTestSupport.run(plugin, client, withUserAgent("Mozilla/5.0")).isCancelled()) .isFalse(); assertThat(PluginTestSupport.run(plugin, client, new Event()).isCancelled()).isFalse(); } @@ -291,7 +296,8 @@ void versionComesFromConfiguration() { Event event = new Event(); PluginTestSupport.run(new VersionPlugin(), client, event); assertThat(event.getVersion()).isEqualTo("1.2.3"); - Event explicit = new Event().setVersion("9.9"); + Event explicit = new Event(); + explicit.setVersion("9.9"); PluginTestSupport.run(new VersionPlugin(), client, explicit); assertThat(explicit.getVersion()).isEqualTo("9.9"); } @@ -310,39 +316,31 @@ void submissionMethodComesFromContext() { @Test void sessionsWithoutUserAreCancelled() { CancelSessionsWithNoUserPlugin plugin = new CancelSessionsWithNoUserPlugin(); - assertThat(PluginTestSupport.run(plugin, client, new Event().setType("session")).isCancelled()) - .isTrue(); - assertThat( - PluginTestSupport.run( - plugin, client, new Event().setType("session").setUserInfo(new UserInfo("u1"))) - .isCancelled()) - .isFalse(); - assertThat( - PluginTestSupport.run( - plugin, - client, - new Event().setType("session").setReferenceId("0123456789abcdef")) - .isCancelled()) - .isFalse(); - assertThat(PluginTestSupport.run(plugin, client, new Event().setType("log")).isCancelled()) - .isFalse(); + 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 = new Event().setType("session"); + 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 = new Event().setType("log"); + Event log = event("log"); PluginTestSupport.run(plugin, client, log); assertThat(log.getSessionId()).isEqualTo(sessionId); assertThat(log.getData()).containsEntry("@ref:session", sessionId); - Event end = new Event().setType("sessionend"); + 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 index 9f6e1c8..2daaca5 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/plugins/DuplicateCheckerPluginTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/DuplicateCheckerPluginTest.java @@ -27,7 +27,8 @@ void close() { } private static Event error(Throwable t) { - Event event = new Event().setType("error"); + Event event = new Event(); + event.setType("error"); event.setError(ErrorConverter.convert(t, n -> false, List.of())); return event; } @@ -89,12 +90,12 @@ void sameMessageFromDifferentCodeIsNotADuplicate() { @Test void ignoresNonErrors() { - Event log = new Event().setType("log").setMessage("x"); - assertThat(PluginTestSupport.run(plugin, client, log).isCancelled()).isFalse(); - assertThat( - PluginTestSupport.run(plugin, client, new Event().setType("log").setMessage("x")) - .isCancelled()) - .isFalse(); + 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 @@ -111,8 +112,10 @@ void suspendFlushesMergedEvents() { @Test void hashIgnoresDateButUsesStack() { List events = sameErrors(2); - Event a = events.get(0).setDate(java.time.OffsetDateTime.now()); - Event b = events.get(1).setDate(java.time.OffsetDateTime.now().minusDays(1)); + Event a = events.get(0); + a.setDate(java.time.OffsetDateTime.now()); + Event b = events.get(1); + b.setDate(java.time.OffsetDateTime.now().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 index 8804b42..7acd14e 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/plugins/EventExclusionPluginTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/EventExclusionPluginTest.java @@ -11,6 +11,7 @@ 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; @@ -30,8 +31,23 @@ private boolean cancelled(Event event) { return PluginTestSupport.run(plugin, client, event).isCancelled(); } - private static Event log(String source, String level) { - return new Event().setType("log").setSource(source).setLevel(level); + 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 @@ -54,7 +70,7 @@ void filtersLogsByServerLevel(String level, String minimum, boolean excluded) { @Test void logsWithoutLevelOrMatchingSettingAreKept() { config.applyServerSettings(Map.of("@@log:com.example.*", "error")); - assertThat(cancelled(new Event().setType("log").setSource("com.example.X"))).isFalse(); + assertThat(cancelled(log("com.example.X", null))).isFalse(); assertThat(cancelled(log("org.other", "trace"))).isFalse(); } @@ -70,13 +86,13 @@ void defaultMinLogLevelAppliesWhenNoSettingMatches() { @Test void excludesErrorTypesAcrossTheCauseChain() { config.applyServerSettings(Map.of("@@error:java.io.FileNotFoundException", "false")); - Event event = new Event().setType("error"); + 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 = new Event().setType("error"); + Event other = event("error", null); other.setError(ErrorConverter.convert(new IllegalStateException("x"), n -> false, List.of())); assertThat(cancelled(other)).isFalse(); } @@ -84,10 +100,9 @@ void excludesErrorTypesAcrossTheCauseChain() { @Test void excludesErrorTypesByWildcard() { config.applyServerSettings(Map.of("@@error:org.springframework.web.*", "false")); - Event event = new Event().setType("error"); - com.exceptionless.models.ErrorInfo error = - new com.exceptionless.models.ErrorInfo() - .setType("org.springframework.web.HttpMediaTypeNotSupportedException"); + 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(); } @@ -96,11 +111,11 @@ void excludesErrorTypesByWildcard() { void excludesTypesAndSources() { config.applyServerSettings( Map.of("@@usage:Export", "false", "@@404:/favicon.ico", "false", "@@404:/wp-*", "false")); - assertThat(cancelled(new Event().setType("usage").setSource("Export"))).isTrue(); - assertThat(cancelled(new Event().setType("usage").setSource("Import"))).isFalse(); - assertThat(cancelled(new Event().setType("404").setSource("/favicon.ico"))).isTrue(); - assertThat(cancelled(new Event().setType("404").setSource("/wp-admin"))).isTrue(); - assertThat(cancelled(new Event().setType("404").setSource("/api"))).isFalse(); + 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 @@ -110,8 +125,8 @@ void customExclusions() { e -> { throw new IllegalStateException("failing callbacks are ignored"); }); - assertThat(cancelled(new Event().setType("log").setMessage("secret"))).isTrue(); - assertThat(cancelled(new Event().setType("log").setMessage("public"))).isFalse(); + assertThat(cancelled(message("secret"))).isTrue(); + assertThat(cancelled(message("public"))).isFalse(); } @Test diff --git a/exceptionless-core/src/test/java/com/exceptionless/queue/DefaultEventQueueTest.java b/exceptionless-core/src/test/java/com/exceptionless/queue/DefaultEventQueueTest.java index e2396f2..6021a54 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/queue/DefaultEventQueueTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/queue/DefaultEventQueueTest.java @@ -51,10 +51,17 @@ void advance(Duration duration) { private void enqueue(int count) { for (int i = 0; i < count; i++) { - queue.enqueue(new Event().setType("log").setMessage("message " + 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); } @@ -228,7 +235,7 @@ void dropsEventsOlderThanMaxAge() { @Test void doesNotSendWhileSuspendedOrDisabled() { enqueue(1); - queue.suspendProcessing(Duration.ofMinutes(1), false, false); + queue.suspendProcessing(Duration.ofMinutes(1)); queue.process(); assertThat(submission.posts).isEmpty(); queue.resume(); @@ -269,7 +276,7 @@ void submissionClientExceptionsAreTreatedAsFailures() { void limitsBatchBytes() { String big = "x".repeat(300_000); for (int i = 0; i < 5; i++) { - queue.enqueue(new Event().setType("log").setMessage(big)); + queue.enqueue(log(big)); } queue.process(); assertThat(submission.posts.size()).isGreaterThanOrEqualTo(2); diff --git a/exceptionless-core/src/test/java/com/exceptionless/settings/ClientSettingsTest.java b/exceptionless-core/src/test/java/com/exceptionless/settings/ClientSettingsTest.java index 298cb03..52ffd21 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/settings/ClientSettingsTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/settings/ClientSettingsTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.exceptionless.LogLevel; +import com.exceptionless.internal.Strings; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -46,9 +47,9 @@ void parsesBooleans(String value, Boolean expected) { @Test void splitsLists() { - assertThat(ClientSettings.splitList(" *password* , ;secret\n\r token ,")) + assertThat(Strings.splitList(" *password* , ;secret\n\r token ,")) .containsExactly("*password*", "secret", "token"); - assertThat(ClientSettings.splitList(null)).isEmpty(); + assertThat(Strings.splitList(null)).isEmpty(); } @Test diff --git a/exceptionless-core/src/test/java/com/exceptionless/submission/DefaultSubmissionClientTest.java b/exceptionless-core/src/test/java/com/exceptionless/submission/DefaultSubmissionClientTest.java index bdf9653..90f91b4 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/submission/DefaultSubmissionClientTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/submission/DefaultSubmissionClientTest.java @@ -29,7 +29,7 @@ void close() { @Test void postsEventsWithBearerTokenAndUserAgent() throws Exception { - SubmissionResponse response = client.postEvents("[{\"type\":\"log\"}]", config); + SubmissionResponse response = client.postEvents("[{\"type\":\"log\"}]"); assertThat(response.statusCode()).isEqualTo(202); assertThat(response.isSuccess()).isTrue(); FakeServer.Request request = server.take(Duration.ofSeconds(5)); @@ -46,7 +46,7 @@ void postsEventsWithBearerTokenAndUserAgent() throws Exception { @Test void gzipsLargeBodies() throws Exception { String json = "[{\"message\":\"" + "x".repeat(5000) + "\"}]"; - client.postEvents(json, config); + client.postEvents(json); FakeServer.Request request = server.take(Duration.ofSeconds(5)); assertThat(request.header("Content-Encoding")).isEqualTo("gzip"); assertThat(request.body()).isEqualTo(json); @@ -55,13 +55,13 @@ void gzipsLargeBodies() throws Exception { @Test void readsConfigVersionHeader() { server.setConfigVersionHeader(12); - assertThat(client.postEvents("[]", config).configVersion()).isEqualTo(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("[]", config); + SubmissionResponse response = client.postEvents("[]"); assertThat(response.isBadRequest()).isTrue(); assertThat(response.message()).contains("bad"); } @@ -69,7 +69,7 @@ void returnsErrorStatusesAndBodies() { @Test void connectionFailuresBecomeFailedResponses() { server.close(); - SubmissionResponse response = client.postEvents("[]", config); + SubmissionResponse response = client.postEvents("[]"); assertThat(response.statusCode()).isEqualTo(-1); assertThat(response.isSuccess()).isFalse(); assertThat(response.message()).isNotBlank(); @@ -81,10 +81,11 @@ void getsSettings() throws Exception { r -> new FakeServer.Response( 200, "{\"version\":3,\"settings\":{\"@@log:*\":\"info\",\"n\":5}}", Map.of())); - SettingsResponse response = client.getSettings(2, config); - assertThat(response.success()).isTrue(); - assertThat(response.version()).isEqualTo(3); - assertThat(response.settings()).containsEntry("@@log:*", "info").containsEntry("n", "5"); + 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"); @@ -94,19 +95,19 @@ void getsSettings() throws Exception { @Test void settingsNotModified() { server.onConfig(r -> FakeServer.Response.of(304)); - SettingsResponse response = client.getSettings(3, config); - assertThat(response.notModified()).isTrue(); - assertThat(response.success()).isFalse(); + 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, config).success()).isFalse(); + assertThat(client.getSettings(0)).isInstanceOf(SettingsResponse.Failed.class); server.onConfig(r -> new FakeServer.Response(200, "{\"settings\":{}}", Map.of())); - assertThat(client.getSettings(0, config).success()).isFalse(); + assertThat(client.getSettings(0)).isInstanceOf(SettingsResponse.Failed.class); server.onConfig(r -> FakeServer.Response.of(401)); - assertThat(client.getSettings(0, config).message()).contains("401"); + assertThat(client.getSettings(0)) + .isInstanceOfSatisfying( + SettingsResponse.Failed.class, failed -> assertThat(failed.message()).contains("401")); } @Test @@ -118,8 +119,8 @@ void usesConfigAndHeartbeatServerUrls() throws Exception { .configServerUrl(other.url()) .heartbeatServerUrl(other.url()); DefaultSubmissionClient splitClient = new DefaultSubmissionClient(split); - splitClient.getSettings(0, split); - splitClient.sendHeartbeat("user 1&x", true, 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"); @@ -130,7 +131,7 @@ void usesConfigAndHeartbeatServerUrls() throws Exception { @Test void postsUserDescriptions() throws Exception { - client.postUserDescription("abc-12345", new UserDescription("a@b.c", "desc"), config); + 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\"}"); @@ -142,7 +143,7 @@ void reportsRedirectsInsteadOfFollowingThem() { r -> new FakeServer.Response( 301, "", Map.of("Location", "https://secure.example.com/api/v2/events"))); - SubmissionResponse response = client.postEvents("[]", config); + SubmissionResponse response = client.postEvents("[]"); assertThat(response.statusCode()).isEqualTo(301); assertThat(response.message()).contains("https://secure.example.com"); assertThat(server.requests()).hasSize(1); 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 index 6000214..6fcf2ff 100644 --- a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/EventWireFormatE2ETest.java +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/EventWireFormatE2ETest.java @@ -86,19 +86,18 @@ void submitEvents() throws Exception { 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() - .setHttpMethod("GET") - .setSecure(true) - .setHost("shop.example.com") - .setPort(443) - .setPath("/products/missing") - .setReferrer("https://www.example.com/search") - .setClientIpAddress("203.0.113.10") - .setUserAgent(CHROME_UA) - .setHeaders(headers) - .setCookies(Map.of("theme", "dark", "visitor", "v-1")) - .setQueryString(Map.of("q", "shoes", "page", "2")); + 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) 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 index 00d4403..f2255fa 100644 --- a/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/SessionE2ETest.java +++ b/exceptionless-e2e-tests/src/test/java/com/exceptionless/e2e/SessionE2ETest.java @@ -43,7 +43,7 @@ void sessionForSignedInUser() throws Exception { try (TestClient client = project.newClient( c -> - c.setUserIdentity(identity, "Session User") + c.userIdentity(identity, "Session User") .useSessions() .addDefaultTags(run, "identity"))) { client 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 index cbde30f..05e0c5a 100644 --- 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 @@ -34,8 +34,8 @@ public List calls(String kind) { } @Override - public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { - SubmissionResponse response = delegate.postEvents(eventsJson, config); + public SubmissionResponse postEvents(String eventsJson) { + SubmissionResponse response = delegate.postEvents(eventsJson); calls.add( new Call( "events", @@ -48,9 +48,8 @@ public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfigurati } @Override - public SubmissionResponse postUserDescription( - String referenceId, UserDescription description, ExceptionlessConfiguration config) { - SubmissionResponse response = delegate.postUserDescription(referenceId, description, config); + public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { + SubmissionResponse response = delegate.postUserDescription(referenceId, description); calls.add( new Call( "user-description", @@ -63,24 +62,29 @@ public SubmissionResponse postUserDescription( } @Override - public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { - SettingsResponse response = delegate.getSettings(version, config); - int status = response.success() ? 200 : response.notModified() ? 304 : -1; - calls.add( - new Call( - "settings", - "v=" + version, - String.valueOf(response.settings()), - status, - response.version(), - response.message())); + 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, ExceptionlessConfiguration config) { - SubmissionResponse response = delegate.sendHeartbeat(sessionIdOrUserId, closeSession, config); + public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { + SubmissionResponse response = delegate.sendHeartbeat(sessionIdOrUserId, closeSession); calls.add( new Call( "heartbeat", diff --git a/exceptionless-jul/src/test/java/com/exceptionless/jul/CapturingSubmissionClient.java b/exceptionless-jul/src/test/java/com/exceptionless/jul/CapturingSubmissionClient.java index 3bf483f..abc6db8 100644 --- a/exceptionless-jul/src/test/java/com/exceptionless/jul/CapturingSubmissionClient.java +++ b/exceptionless-jul/src/test/java/com/exceptionless/jul/CapturingSubmissionClient.java @@ -47,25 +47,23 @@ List> events() { @Override @SuppressWarnings("unchecked") - public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { + public SubmissionResponse postEvents(String eventsJson) { events.addAll((List>) JsonParser.parse(eventsJson)); return new SubmissionResponse(202, null, null); } @Override - public SubmissionResponse postUserDescription( - String referenceId, UserDescription description, ExceptionlessConfiguration config) { + public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { return new SubmissionResponse(202, null, null); } @Override - public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { - return SettingsResponse.notModifiedResponse(); + public SettingsResponse getSettings(int version) { + return new SettingsResponse.NotModified(); } @Override - public SubmissionResponse sendHeartbeat( - String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { return new SubmissionResponse(200, null, null); } diff --git a/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/CapturingSubmissionClient.java b/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/CapturingSubmissionClient.java index 6aba7d1..ecacf01 100644 --- a/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/CapturingSubmissionClient.java +++ b/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/CapturingSubmissionClient.java @@ -47,25 +47,23 @@ List> events() { @Override @SuppressWarnings("unchecked") - public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { + public SubmissionResponse postEvents(String eventsJson) { events.addAll((List>) JsonParser.parse(eventsJson)); return new SubmissionResponse(202, null, null); } @Override - public SubmissionResponse postUserDescription( - String referenceId, UserDescription description, ExceptionlessConfiguration config) { + public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { return new SubmissionResponse(202, null, null); } @Override - public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { - return SettingsResponse.notModifiedResponse(); + public SettingsResponse getSettings(int version) { + return new SettingsResponse.NotModified(); } @Override - public SubmissionResponse sendHeartbeat( - String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { return new SubmissionResponse(200, null, null); } diff --git a/exceptionless-logback/src/test/java/com/exceptionless/logback/CapturingSubmissionClient.java b/exceptionless-logback/src/test/java/com/exceptionless/logback/CapturingSubmissionClient.java index 40bb3d2..19b313a 100644 --- a/exceptionless-logback/src/test/java/com/exceptionless/logback/CapturingSubmissionClient.java +++ b/exceptionless-logback/src/test/java/com/exceptionless/logback/CapturingSubmissionClient.java @@ -47,25 +47,23 @@ List> events() { @Override @SuppressWarnings("unchecked") - public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { + public SubmissionResponse postEvents(String eventsJson) { events.addAll((List>) JsonParser.parse(eventsJson)); return new SubmissionResponse(202, null, null); } @Override - public SubmissionResponse postUserDescription( - String referenceId, UserDescription description, ExceptionlessConfiguration config) { + public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { return new SubmissionResponse(202, null, null); } @Override - public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { - return SettingsResponse.notModifiedResponse(); + public SettingsResponse getSettings(int version) { + return new SettingsResponse.NotModified(); } @Override - public SubmissionResponse sendHeartbeat( - String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { return new SubmissionResponse(200, null, null); } diff --git a/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoFactory.java b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoFactory.java index c9bdb6c..c4509c9 100644 --- a/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoFactory.java +++ b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoFactory.java @@ -47,16 +47,15 @@ public static RequestInfo create(HttpServletRequest request) { * true} once the application is done with the request body. */ public static RequestInfo create(HttpServletRequest request, boolean includeFormData) { - RequestInfo info = - new RequestInfo() - .setHttpMethod(request.getMethod()) - .setSecure(request.isSecure()) - .setHost(request.getServerName()) - .setPort(request.getServerPort()) - .setPath(request.getRequestURI()) - .setReferrer(request.getHeader("Referer")) - .setUserAgent(request.getHeader("User-Agent")) - .setClientIpAddress(request.getRemoteAddr()); + 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()) { diff --git a/exceptionless-servlet/src/test/java/com/exceptionless/servlet/RecordingSubmissionClient.java b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/RecordingSubmissionClient.java index 963def6..3f09277 100644 --- a/exceptionless-servlet/src/test/java/com/exceptionless/servlet/RecordingSubmissionClient.java +++ b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/RecordingSubmissionClient.java @@ -30,25 +30,23 @@ static ExceptionlessConfiguration configuration(RecordingSubmissionClient submis @Override @SuppressWarnings("unchecked") - public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { + public SubmissionResponse postEvents(String eventsJson) { events.addAll((List>) JsonParser.parse(eventsJson)); return new SubmissionResponse(202, null, null); } @Override - public SubmissionResponse postUserDescription( - String referenceId, UserDescription description, ExceptionlessConfiguration config) { + public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { return new SubmissionResponse(202, null, null); } @Override - public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { - return SettingsResponse.notModifiedResponse(); + public SettingsResponse getSettings(int version) { + return new SettingsResponse.NotModified(); } @Override - public SubmissionResponse sendHeartbeat( - String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { return new SubmissionResponse(200, null, null); } 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 index 489cad0..000e8d1 100644 --- 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 @@ -35,15 +35,14 @@ public static RequestInfo create(ServerHttpRequest request) { if (port < 0) { port = secure ? 443 : 80; } - RequestInfo info = - new RequestInfo() - .setHttpMethod(request.getMethod().name()) - .setSecure(secure) - .setHost(uri.getHost()) - .setPort(port) - .setPath(uri.getRawPath()) - .setReferrer(headers.getFirst("Referer")) - .setUserAgent(headers.getFirst("User-Agent")); + 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) { diff --git a/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/RecordingSubmissionClient.java b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/RecordingSubmissionClient.java index 6d74053..583e8ab 100644 --- a/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/RecordingSubmissionClient.java +++ b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/RecordingSubmissionClient.java @@ -30,25 +30,23 @@ static ExceptionlessConfiguration configuration(RecordingSubmissionClient submis @Override @SuppressWarnings("unchecked") - public SubmissionResponse postEvents(String eventsJson, ExceptionlessConfiguration config) { + public SubmissionResponse postEvents(String eventsJson) { events.addAll((List>) JsonParser.parse(eventsJson)); return new SubmissionResponse(202, null, null); } @Override - public SubmissionResponse postUserDescription( - String referenceId, UserDescription description, ExceptionlessConfiguration config) { + public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { return new SubmissionResponse(202, null, null); } @Override - public SettingsResponse getSettings(int version, ExceptionlessConfiguration config) { - return SettingsResponse.notModifiedResponse(); + public SettingsResponse getSettings(int version) { + return new SettingsResponse.NotModified(); } @Override - public SubmissionResponse sendHeartbeat( - String sessionIdOrUserId, boolean closeSession, ExceptionlessConfiguration config) { + public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { return new SubmissionResponse(200, null, null); } diff --git a/samples/console/src/main/java/com/example/console/ConsoleApp.java b/samples/console/src/main/java/com/example/console/ConsoleApp.java index 9105b27..8d3ab4c 100644 --- a/samples/console/src/main/java/com/example/console/ConsoleApp.java +++ b/samples/console/src/main/java/com/example/console/ConsoleApp.java @@ -38,7 +38,7 @@ public static void main(String[] args) throws Exception { .version("1.0.0") .addDefaultTags("sample", "console") .addDefaultData("environment", "development") - .setUserIdentity("sample-user", "Sample User") + .userIdentity("sample-user", "Sample User") .addDataExclusions("*password*") .useSessions()); From c0660802966993859409569ec3cc75be595f8fe2 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 19 Oct 2026 18:09:03 -0500 Subject: [PATCH 3/9] Modernize core internals - One InstantSource-based clock across the queue, storage and plugins - FileQueueStorage keeps an in-memory index instead of listing the directory per enqueue - Precompiled wildcard exclusions cached by the configuration - Strict RFC 8259 JsonParser; JsonWriter and DataNormalizer share simple-value rules - Holder-class lazy init, ClassValue accessor cache, strip() over trim() - Test cleanups: latches instead of sleeps, parameterized tables, @TempDir Co-Authored-By: Claude Opus 5.5 --- .../java/com/exceptionless/EventBuilder.java | 27 ++- .../java/com/exceptionless/Exceptionless.java | 2 +- .../exceptionless/ExceptionlessClient.java | 41 +++- .../ExceptionlessConfiguration.java | 77 ++++--- .../main/java/com/exceptionless/LogLevel.java | 23 +- .../com/exceptionless/SubmittingEvent.java | 2 +- .../exceptionless/internal/ClientInfo.java | 5 +- .../internal/DataNormalizer.java | 110 ++++++---- .../internal/EnvironmentCollector.java | 109 +++++----- .../internal/ErrorConverter.java | 7 +- .../exceptionless/internal/Exclusions.java | 49 ++++- .../exceptionless/internal/JavaCommand.java | 37 ++++ .../java/com/exceptionless/internal/Log.java | 4 + .../internal/ModuleCollector.java | 49 ++--- .../internal/SettingsManager.java | 2 +- .../com/exceptionless/internal/Strings.java | 36 ++-- .../com/exceptionless/internal/Wildcard.java | 78 +++++-- .../com/exceptionless/json/JsonParser.java | 97 ++++++--- .../com/exceptionless/json/JsonWriter.java | 51 +++-- .../com/exceptionless/models/ErrorInfo.java | 6 +- .../java/com/exceptionless/models/Event.java | 4 +- .../com/exceptionless/models/RequestInfo.java | 2 +- .../plugins/ConfigurationDefaultsPlugin.java | 3 +- .../exceptionless/plugins/ContextData.java | 2 +- .../plugins/DuplicateCheckerPlugin.java | 44 ++-- .../exceptionless/plugins/ErrorPlugin.java | 19 +- .../plugins/EventExclusionPlugin.java | 3 +- .../plugins/EventPluginContext.java | 4 +- .../plugins/HeartbeatPlugin.java | 19 +- .../plugins/RequestInfoPlugin.java | 27 ++- .../exceptionless/plugins/VersionPlugin.java | 24 +-- .../queue/DefaultEventQueue.java | 140 ++++++------- .../com/exceptionless/queue/EventQueue.java | 6 +- .../settings/ClientSettings.java | 2 +- .../storage/FileKeyValueStorage.java | 3 +- .../storage/FileQueueStorage.java | 196 +++++++++++++----- .../storage/InMemoryQueueStorage.java | 33 +-- .../storage/KeyValueStorage.java | 14 ++ .../com/exceptionless/storage/QueueEntry.java | 7 +- .../exceptionless/storage/QueueStorage.java | 2 + .../submission/DefaultSubmissionClient.java | 2 +- .../com/exceptionless/ClientSmokeTest.java | 5 +- .../ExceptionlessClientTest.java | 43 ++-- .../ExceptionlessConfigurationTest.java | 22 ++ .../java/com/exceptionless/TestClock.java | 61 ++++++ .../test/java/com/exceptionless/TestJson.java | 32 +++ .../internal/DataNormalizerTest.java | 14 +- .../internal/ErrorConverterTest.java | 13 +- .../com/exceptionless/internal/IdsTest.java | 31 ++- .../exceptionless/internal/WildcardTest.java | 22 +- .../exceptionless/json/JsonParserTest.java | 105 ++++++++-- .../exceptionless/json/JsonWriterTest.java | 13 ++ .../models/WireContractTest.java | 36 ++-- .../plugins/DuplicateCheckerPluginTest.java | 38 +++- .../queue/DefaultEventQueueTest.java | 38 +--- .../settings/ClientSettingsTest.java | 52 +++-- .../storage/QueueStorageTest.java | 179 ++++++++++++---- 57 files changed, 1413 insertions(+), 659 deletions(-) create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/JavaCommand.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/TestClock.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/TestJson.java diff --git a/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java b/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java index b752a5a..1f1307c 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java +++ b/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java @@ -4,7 +4,6 @@ import com.exceptionless.internal.Exclusions; import com.exceptionless.internal.Ids; import com.exceptionless.internal.Strings; -import com.exceptionless.internal.Wildcard; import com.exceptionless.json.JsonSerializable; import com.exceptionless.models.Event; import com.exceptionless.models.EventTags; @@ -16,11 +15,9 @@ import com.exceptionless.plugins.EventPluginContext; import java.math.BigDecimal; import java.time.OffsetDateTime; -import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; -import java.util.function.Predicate; import org.jspecify.annotations.Nullable; /** @@ -47,7 +44,7 @@ public final class EventBuilder { this.contextData = contextData; } - /** The event being built. */ + /** {@return the event being built} */ public Event getEvent() { return event; } @@ -190,7 +187,7 @@ public EventBuilder markAsCritical(boolean critical) { /** Sets the application version ({@code @version}) for this event. */ public EventBuilder setVersion(@Nullable String version) { - event.setVersion(Strings.trimToNull(version)); + event.setVersion(Strings.stripToNull(version)); return this; } @@ -261,18 +258,18 @@ public EventBuilder setProperty( if (Strings.isBlank(name) || value == null) { return this; } - List exclusions = new ArrayList<>(client.getConfiguration().getDataExclusions()); - exclusions.addAll(List.of(excludedPropertyNames)); - if (Wildcard.matchesAny(name, exclusions)) { + Exclusions excluded = + client + .getConfiguration() + .compiledDataExclusions() + .plus(Exclusions.of(List.of(excludedPropertyNames))); + if (excluded.test(name)) { return this; } - Object normalized; - if (value instanceof JsonSerializable) { - normalized = value; - } else { - Predicate excluded = Exclusions.of(exclusions); - normalized = DataNormalizer.normalize(value, maxDepth, excluded); - } + Object normalized = + value instanceof JsonSerializable + ? value + : DataNormalizer.normalize(value, maxDepth, excluded); if (normalized != null) { event.getData().put(name, normalized); } diff --git a/exceptionless-core/src/main/java/com/exceptionless/Exceptionless.java b/exceptionless-core/src/main/java/com/exceptionless/Exceptionless.java index 045c95f..73f4636 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/Exceptionless.java +++ b/exceptionless-core/src/main/java/com/exceptionless/Exceptionless.java @@ -31,7 +31,7 @@ public final class Exceptionless { private Exceptionless() {} - /** The default client, created (but not started) on first use. */ + /** {@return the default client, created (but not started) on first use} */ public static ExceptionlessClient getDefault() { ExceptionlessClient client = defaultClient; if (client == null) { diff --git a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java index baf1d6d..96d2ebb 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java +++ b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java @@ -26,6 +26,7 @@ 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.util.Collections; @@ -77,6 +78,7 @@ private enum State { } private final ExceptionlessConfiguration config; + private final Clock clock; private final Object lifecycleLock = new Object(); private volatile State state = State.NEW; @@ -99,7 +101,13 @@ private enum State { /** Creates a client. It starts automatically when first used. */ public ExceptionlessClient(ExceptionlessConfiguration configuration) { + this(configuration, Clock.systemDefaultZone()); + } + + /** For tests: {@code clock} dates new events, in its time zone. */ + ExceptionlessClient(ExceptionlessConfiguration configuration, Clock clock) { this.config = configuration; + this.clock = clock; } /** @@ -179,7 +187,7 @@ private void initialize() { id = Ids.newGuid(); stateStorage.write("install-id", id); } - installId = id.trim(); + installId = id.strip(); SubmissionClient client = config.getSubmissionClient(); if (client == null) { @@ -270,7 +278,17 @@ private void installUncaughtExceptionHandler() { previousHandler = previous; } - /** True once the client has started and until it is closed. */ + /** + * 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; } @@ -299,8 +317,7 @@ public void close() { // Already shutting down. } } - if (installedHandler != null - && Thread.getDefaultUncaughtExceptionHandler() == installedHandler) { + if (isOurUncaughtExceptionHandlerInstalled()) { Thread.setDefaultUncaughtExceptionHandler(previousHandler); } @@ -363,7 +380,7 @@ private boolean ensureStarted() { /** Creates an event of any type. */ public EventBuilder createEvent() { Event event = new Event(); - event.setDate(OffsetDateTime.now()); + event.setDate(OffsetDateTime.now(clock)); return new EventBuilder(this, event, new ContextData()); } @@ -463,10 +480,10 @@ public EventPluginContext submitEvent(Event event, @Nullable ContextData context context.cancel(); return context; } - SUBMITTING.set(Boolean.TRUE); + SUBMITTING.set(true); try { if (event.getDate() == null) { - event.setDate(OffsetDateTime.now()); + event.setDate(OffsetDateTime.now(clock)); } runPlugins(context); if (context.isCancelled()) { @@ -548,7 +565,7 @@ public ExceptionlessClient addSubmittedEventHandler(Consumer handler) { return this; } - /** The reference id of the last error submitted, to show to users. */ + /** {@return the reference id of the last error submitted, to show to users} */ public @Nullable String getLastReferenceId() { return lastReferenceId; } @@ -650,7 +667,7 @@ public CompletableFuture updateUserEmailAndDescription( // --------------------------------------------------------------------------------------------- // Queue and background work - /** The event queue. */ + /** {@return the event queue} */ public EventQueue getQueue() { ensureStarted(); EventQueue eventQueue = queue; @@ -703,7 +720,7 @@ public CompletableFuture updateSettings() { : runAsync(settings::updateSettings); } - /** A unique id for this installation, persisted when file storage is used. */ + /** {@return a unique id for this installation, persisted when file storage is used} */ public @Nullable String getInstallId() { ensureStarted(); return installId; @@ -721,6 +738,9 @@ public ScheduledFuture 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( @@ -730,6 +750,7 @@ private void scheduleWithFixedDelay(Runnable task, Duration initialDelay, Durati TimeUnit.MILLISECONDS); } + @SuppressWarnings("FutureReturnValueIgnored") // see scheduleWithFixedDelay private void schedule(Runnable task, Duration delay) { requireScheduler().schedule(safe(task), delay.toMillis(), TimeUnit.MILLISECONDS); } diff --git a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java index 378e74e..e3c329d 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java +++ b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java @@ -1,5 +1,6 @@ package com.exceptionless; +import com.exceptionless.internal.Exclusions; import com.exceptionless.internal.Log; import com.exceptionless.internal.Strings; import com.exceptionless.models.Event; @@ -91,6 +92,10 @@ public final class ExceptionlessConfiguration { 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; @@ -238,13 +243,13 @@ static String environmentName(String key) { return sb.toString(); } - /** Passes the trimmed value of {@code name} to {@code setter}, when it is set. */ + /** 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.trimToNull(values.get(keyMapper.apply(name))); + String value = Strings.stripToNull(values.get(keyMapper.apply(name))); if (value != null) { setter.accept(value); } @@ -260,7 +265,7 @@ private static void apply( /** Sets the project API key. Required. */ public ExceptionlessConfiguration apiKey(@Nullable String apiKey) { ensureUnlocked("apiKey"); - String key = Strings.trimToNull(apiKey); + String key = Strings.stripToNull(apiKey); this.apiKey = key; this.apiKeyValid = isValidApiKey(key); return this; @@ -304,17 +309,17 @@ public ExceptionlessConfiguration heartbeatServerUrl(String heartbeatServerUrl) } private static String normalizeUrl(String url) { - String trimmed = Strings.trimToNull(url); - if (trimmed == null) { + String stripped = Strings.stripToNull(url); + if (stripped == null) { throw new IllegalArgumentException("A server URL is required"); } - while (trimmed.endsWith("/")) { - trimmed = trimmed.substring(0, trimmed.length() - 1); + while (stripped.endsWith("/")) { + stripped = stripped.substring(0, stripped.length() - 1); } - if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + if (!stripped.startsWith("http://") && !stripped.startsWith("https://")) { throw new IllegalArgumentException("Server URL must start with http:// or https://: " + url); } - return trimmed; + return stripped; } /** Whether events are sent. A configuration without a valid API key is never enabled. */ @@ -370,20 +375,20 @@ private void ensureUnlocked(String property) { /** Sets the application version, attached to every event as {@code @version}. */ public ExceptionlessConfiguration version(@Nullable String version) { - this.version = Strings.trimToNull(version); + this.version = Strings.stripToNull(version); return this; } - /** A snapshot of the tags added to every event. */ + /** {@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 trimmed = Strings.trimToNull(tag); - if (trimmed != null) { - defaultTags.add(trimmed); + String stripped = Strings.stripToNull(tag); + if (stripped != null) { + defaultTags.add(stripped); } } return this; @@ -396,7 +401,7 @@ public ExceptionlessConfiguration removeDefaultTags(String... tags) { return this; } - /** A snapshot of the data added to every event. */ + /** {@return a snapshot of the data added to every event} */ public Map getDefaultData() { return Map.copyOf(defaultData); } @@ -426,12 +431,15 @@ public ExceptionlessConfiguration userIdentity(@Nullable String identity, @Nulla // --------------------------------------------------------------------------------------------- // Settings, exclusions and privacy - /** The effective configuration values: local settings overlaid with the project's settings. */ + /** + * {@return the effective configuration values: local settings overlaid with the project's + * settings} + */ public ClientSettings getSettings() { return effectiveSettings; } - /** The settings most recently received from the server. */ + /** {@return the settings most recently received from the server} */ public ClientSettings getServerSettings() { return serverSettings; } @@ -460,7 +468,10 @@ public void applyServerSettings(Map values) { } private void recomputeSettings() { - effectiveSettings = ClientSettings.of(Map.copyOf(settings), serverSettings.asMap()); + synchronized (settingsLock) { + effectiveSettings = ClientSettings.of(Map.copyOf(settings), serverSettings.asMap()); + compiledDataExclusions = Exclusions.of(getDataExclusions()); + } } /** @@ -469,11 +480,14 @@ private void recomputeSettings() { */ public ExceptionlessConfiguration addDataExclusions(String... patterns) { for (String pattern : patterns) { - String trimmed = Strings.trimToNull(pattern); - if (trimmed != null) { - dataExclusions.add(trimmed); + String stripped = Strings.stripToNull(pattern); + if (stripped != null) { + dataExclusions.add(stripped); } } + synchronized (settingsLock) { + compiledDataExclusions = Exclusions.of(getDataExclusions()); + } return this; } @@ -488,12 +502,25 @@ public List getDataExclusions() { 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 trimmed = Strings.trimToNull(pattern); - if (trimmed != null) { - userAgentBotPatterns.add(trimmed); + String stripped = Strings.stripToNull(pattern); + if (stripped != null) { + userAgentBotPatterns.add(stripped); } } return this; @@ -906,7 +933,7 @@ public ExceptionlessConfiguration shutdownTimeout(Duration timeout) { // --------------------------------------------------------------------------------------------- // Plugins - /** The plugins in the order they run (ascending priority). */ + /** {@return the plugins in the order they run (ascending priority)} */ public List getPlugins() { return plugins.stream().sorted(Comparator.comparingInt(EventPlugin::getPriority)).toList(); } diff --git a/exceptionless-core/src/main/java/com/exceptionless/LogLevel.java b/exceptionless-core/src/main/java/com/exceptionless/LogLevel.java index 6ff4b5e..f3ee3ed 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/LogLevel.java +++ b/exceptionless-core/src/main/java/com/exceptionless/LogLevel.java @@ -3,13 +3,25 @@ import java.util.Locale; import org.jspecify.annotations.Nullable; -/** Log levels understood by Exceptionless, in increasing order of severity. */ +/** + * 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"); @@ -20,7 +32,7 @@ public enum LogLevel { this.value = value; } - /** The value sent to the server in {@code @level}. */ + /** {@return the value sent to the server in {@code @level}, e.g. {@code error}} */ public String value() { return value; } @@ -41,7 +53,7 @@ public boolean isAtLeast(LogLevel minimum) { if (value == null) { return null; } - return switch (value.trim().toLowerCase(Locale.ROOT)) { + 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; @@ -52,9 +64,4 @@ public boolean isAtLeast(LogLevel minimum) { default -> null; }; } - - @Override - public String toString() { - return value; - } } diff --git a/exceptionless-core/src/main/java/com/exceptionless/SubmittingEvent.java b/exceptionless-core/src/main/java/com/exceptionless/SubmittingEvent.java index 7de14a3..f00a0b1 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/SubmittingEvent.java +++ b/exceptionless-core/src/main/java/com/exceptionless/SubmittingEvent.java @@ -19,7 +19,7 @@ public final class SubmittingEvent { this.contextData = contextData; } - /** The client submitting the event. */ + /** {@return the client submitting the event} */ public ExceptionlessClient getClient() { return client; } diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/ClientInfo.java b/exceptionless-core/src/main/java/com/exceptionless/internal/ClientInfo.java index 3a6af44..7747d90 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/ClientInfo.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/ClientInfo.java @@ -27,8 +27,9 @@ private static String loadVersion() { Properties properties = new Properties(); properties.load(in); String version = properties.getProperty("version"); - if (version != null && !version.isBlank() && !version.startsWith("${")) { - return version.trim(); + String stripped = Strings.stripToNull(version); + if (stripped != null && !stripped.startsWith("${")) { + return stripped; } } } catch (Exception e) { diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/DataNormalizer.java b/exceptionless-core/src/main/java/com/exceptionless/internal/DataNormalizer.java index 2f66976..99a9120 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/DataNormalizer.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/DataNormalizer.java @@ -2,6 +2,7 @@ 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; @@ -11,6 +12,8 @@ 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; @@ -35,7 +38,8 @@ * 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()}. + * 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. */ @@ -44,6 +48,22 @@ public final class DataNormalizer { 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<>()); @@ -79,7 +99,11 @@ public static Map normalizeProperties( return map; } - /** True when {@code value} is serialized as-is (no nested properties to normalize). */ + /** + * 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 @@ -103,6 +127,26 @@ public static boolean isSimpleValue(@Nullable Object value) { || 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; @@ -113,14 +157,8 @@ public static boolean isSimpleValue(@Nullable Object value) { if (value instanceof Number || value instanceof Boolean || value instanceof JsonSerializable) { return value; } - if (value instanceof Class type) { - return type.getName(); - } - if (value instanceof Enum enumValue) { - return enumValue.name(); - } if (isSimpleValue(value)) { - return value instanceof Date date ? date.toInstant().toString() : value.toString(); + return simpleValueToString(value); } if (value instanceof Optional optional) { return convert(optional.orElse(null), depth); @@ -214,45 +252,47 @@ private Object convertRecord(Object value, int depth) { 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<>(); - boolean anyProperty = false; - for (Method method : type.getMethods()) { - String name = propertyName(method); - if (name == null) { - continue; - } - anyProperty = true; + for (Property property : properties) { + String name = property.name(); if (excluded.test(name) || result.containsKey(name)) { continue; } - if (!accessible(method, value)) { + if (!accessible(property.member(), value)) { continue; } try { - put(result, name, method.invoke(value), depth); + put(result, name, property.read(value), depth); } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { LOG.trace(() -> "Unable to read property " + name + " of " + type.getName() + ": " + e); } } - for (Field field : type.getFields()) { - if (Modifier.isStatic(field.getModifiers())) { - continue; - } - anyProperty = true; - String name = field.getName(); - if (excluded.test(name) || result.containsKey(name)) { - continue; - } - if (!accessible(field, value)) { - continue; + 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)); + } } - try { - put(result, name, field.get(value), depth); - } catch (ReflectiveOperationException | RuntimeException e) { - LOG.trace(() -> "Unable to read field " + name + " of " + type.getName() + ": " + e); + 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 anyProperty ? result : safeToString(value); + return List.copyOf(properties); } /** @@ -260,7 +300,7 @@ private Object convertBean(Object value, int depth) { * setAccessible}, which only works when the package is open to this library (always the case on * the class path). */ - private static boolean accessible(java.lang.reflect.AccessibleObject member, Object target) { + private static boolean accessible(AccessibleObject member, Object target) { try { return member.canAccess(target) || member.trySetAccessible(); } catch (RuntimeException e) { diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/EnvironmentCollector.java b/exceptionless-core/src/main/java/com/exceptionless/internal/EnvironmentCollector.java index a8bddbf..95b8808 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/EnvironmentCollector.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/EnvironmentCollector.java @@ -11,15 +11,25 @@ import java.util.List; import org.jspecify.annotations.Nullable; -/** Collects information about the machine and JVM. Static values are computed once. */ +/** + * 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 volatile @Nullable String machineName; - private static volatile @Nullable String ipAddresses; 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. */ @@ -31,6 +41,7 @@ public static EnvironmentInfo collect( 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(); @@ -88,66 +99,68 @@ static void addMemory(EnvironmentInfo info) { } static @Nullable String machineName() { - String name = 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.trimToNull(System.getenv("HOSTNAME")); - if (name == null) { - name = Strings.trimToNull(System.getenv("COMPUTERNAME")); - } - if (name == null) { - try { - name = InetAddress.getLocalHost().getHostName(); - } catch (Exception e) { - name = ""; - } + 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); } - machineName = name; } - return name.isEmpty() ? null : name; + return name; } - static @Nullable String ipAddresses() { - String addresses = ipAddresses; - if (addresses == null) { - List list = new ArrayList<>(); - try { - for (NetworkInterface nic : Collections.list(NetworkInterface.getNetworkInterfaces())) { - if (!nic.isUp() || nic.isLoopback() || nic.isVirtual()) { + 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; } - 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); + 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); } - addresses = String.join(",", list); - ipAddresses = addresses; + } catch (Exception e) { + LOG.debug(() -> "Unable to read network interfaces: " + e); } - return addresses.isEmpty() ? null : addresses; + 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 command = System.getProperty("sun.java.command"); - if (command != null && !command.isBlank()) { - String first = command.trim().split("\\s+", 2)[0]; - int slash = Math.max(first.lastIndexOf('/'), first.lastIndexOf('\\')); - return slash >= 0 ? first.substring(slash + 1) : first; + String mainClassOrJar = JavaCommand.mainClassOrJar(); + if (mainClassOrJar != null) { + return JavaCommand.fileName(mainClassOrJar); } try { return ProcessHandle.current().info().command().orElse("java"); diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/ErrorConverter.java b/exceptionless-core/src/main/java/com/exceptionless/internal/ErrorConverter.java index 0655375..e16c537 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/ErrorConverter.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/ErrorConverter.java @@ -17,7 +17,7 @@ /** Converts a {@link Throwable} (and its causes) into the Exceptionless error model. */ public final class ErrorConverter { /** Maximum number of causes followed. */ - static final int MAX_CAUSES = 20; + 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; @@ -211,7 +211,10 @@ private static StackTraceElement[] safeStackTrace(Throwable throwable) { } } - private static @Nullable Throwable safeCause(Throwable throwable) { + /** + * {@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) { diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/Exclusions.java b/exceptionless-core/src/main/java/com/exceptionless/internal/Exclusions.java index c46f5c0..4bb23c9 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/Exclusions.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Exclusions.java @@ -2,18 +2,53 @@ import java.util.List; import java.util.function.Predicate; +import java.util.stream.Stream; -/** Builds name predicates from data exclusion patterns. */ -public final class Exclusions { - private static final Predicate NONE = name -> false; +/** + * 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 Exclusions() {} + private final List patterns; - public static Predicate of(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; } - List copy = List.copyOf(patterns); - return name -> Wildcard.matchesAny(name, copy); + 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/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 index 6123d8d..ac37aa0 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/Log.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Log.java @@ -51,6 +51,10 @@ 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 index 5493933..3ada722 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/ModuleCollector.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/ModuleCollector.java @@ -18,8 +18,8 @@ import org.jspecify.annotations.Nullable; /** - * Discovers the libraries on the class path from their jar manifests. The result is computed once - * and cached, because scanning can take a few milliseconds. + * 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; @@ -27,23 +27,16 @@ public final class ModuleCollector { private static final Pattern JAR_NAME = Pattern.compile("(?:^|[/!])([^/!]+?)(?:-(\\d+(?:\\.\\w+)*(?:[-.+][\\w.]+)?))?\\.jar(?:!|$)"); - private static volatile @Nullable List cached; - 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() { - List modules = cached; - if (modules == null) { - synchronized (ModuleCollector.class) { - modules = cached; - if (modules == null) { - modules = List.copyOf(collect()); - cached = modules; - } - } - } - return modules; + return Modules.ALL; } static List collect() { @@ -57,7 +50,7 @@ static List collect() { if (loader == null) { return List.of(); } - String entryPoint = entryPoint(); + String entryPoint = JavaCommand.mainClassOrJar(); Enumeration manifests = loader.getResources("META-INF/MANIFEST.MF"); while (manifests.hasMoreElements() && byName.size() < MAX_MODULES) { URL url = manifests.nextElement(); @@ -133,36 +126,24 @@ Library merge(Library other) { entryPoint != null && (entryPoint.equals(mainClass) || entryPoint.equals(startClass) - || (entryPoint.endsWith(".jar") && location.contains(fileNameOf(entryPoint)))); + || (entryPoint.endsWith(".jar") + && location.contains(JavaCommand.fileName(entryPoint)))); return new Library(name, version, entry); } - private static @Nullable String entryPoint() { - String command = System.getProperty("sun.java.command"); - if (command == null || command.isBlank()) { - return null; - } - int space = command.indexOf(' '); - return space > 0 ? command.substring(0, space) : command; - } - - private static String fileNameOf(String path) { - int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); - return slash >= 0 ? path.substring(slash + 1) : path; - } - private static @Nullable String symbolicName(@Nullable String value) { if (value == null) { return null; } int semicolon = value.indexOf(';'); - return semicolon > 0 ? value.substring(0, semicolon).trim() : value.trim(); + return (semicolon > 0 ? value.substring(0, semicolon) : value).strip(); } private static @Nullable String firstNonBlank(@Nullable String... values) { for (String value : values) { - if (value != null && !value.isBlank()) { - return value.trim(); + String stripped = Strings.stripToNull(value); + if (stripped != null) { + return stripped; } } return null; diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/SettingsManager.java b/exceptionless-core/src/main/java/com/exceptionless/internal/SettingsManager.java index d50c157..e434b50 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/SettingsManager.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/SettingsManager.java @@ -31,7 +31,7 @@ public SettingsManager( this.storage = storage; } - /** The version of the settings currently applied (0 when none). */ + /** {@return the version of the settings currently applied (0 when none)} */ public int getVersion() { return version; } diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/Strings.java b/exceptionless-core/src/main/java/com/exceptionless/internal/Strings.java index 5850c0f..c8df9bc 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/Strings.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Strings.java @@ -1,44 +1,42 @@ package com.exceptionless.internal; -import java.util.ArrayList; 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(); } - public static @Nullable String trimToNull(@Nullable String value) { + /** {@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 trimmed = value.trim(); - return trimmed.isEmpty() ? null : trimmed; + String stripped = value.strip(); + return stripped.isEmpty() ? null : stripped; } - /** Splits a comma, semicolon or newline separated list, trimming and dropping blanks. */ + /** Splits a comma, semicolon or newline separated list, stripping and dropping blanks. */ public static List splitList(@Nullable String value) { - if (value == null || value.isBlank()) { + if (isBlank(value)) { return List.of(); } - List result = new ArrayList<>(); - for (String part : value.split("[,;\\r\\n]")) { - String trimmed = part.trim(); - if (!trimmed.isEmpty()) { - result.add(trimmed); - } - } - return List.copyOf(result); + return LIST_SEPARATORS + .splitAsStream(value) + .map(String::strip) + .filter(part -> !part.isEmpty()) + .toList(); } - public static @Nullable String truncate(@Nullable String value, int maxLength) { - if (value == null || value.length() <= maxLength) { - return value; - } - return value.substring(0, maxLength); + /** {@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 index 7d8a57a..318bdc3 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/Wildcard.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/Wildcard.java @@ -2,40 +2,82 @@ 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 { - private Wildcard() {} +public final class Wildcard implements Predicate<@Nullable String> { + private enum Kind { + ANY, + EXACT, + PREFIX, + SUFFIX, + CONTAINS, + NONE + } - public static boolean matches(@Nullable String value, @Nullable String pattern) { - if (value == null || pattern == null) { - return false; + 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.trim().toLowerCase(Locale.ROOT); + String p = pattern.strip().toLowerCase(Locale.ROOT); if (p.isEmpty()) { - return false; + return NEVER; } - String v = value.trim().toLowerCase(Locale.ROOT); if (p.equals("*")) { - return true; + return new Wildcard(Kind.ANY, ""); } - boolean startsWithWildcard = p.charAt(0) == '*'; - boolean endsWithWildcard = p.length() > 1 && p.charAt(p.length() - 1) == '*'; - if (startsWithWildcard && endsWithWildcard) { - return v.contains(p.substring(1, p.length() - 1)); + 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 (startsWithWildcard) { - return v.endsWith(p.substring(1)); + if (leading) { + return new Wildcard(Kind.SUFFIX, p.substring(1)); } - if (endsWithWildcard) { - return v.startsWith(p.substring(0, p.length() - 1)); + if (trailing) { + return new Wildcard(Kind.PREFIX, p.substring(0, p.length() - 1)); } - return v.equals(p); + 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) { diff --git a/exceptionless-core/src/main/java/com/exceptionless/json/JsonParser.java b/exceptionless-core/src/main/java/com/exceptionless/json/JsonParser.java index 4f6818c..6448404 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/json/JsonParser.java +++ b/exceptionless-core/src/main/java/com/exceptionless/json/JsonParser.java @@ -2,17 +2,23 @@ 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. Objects become {@link LinkedHashMap}, arrays become {@link - * ArrayList}, numbers become {@link Long} or {@link BigDecimal}. + * 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; @@ -29,6 +35,9 @@ private JsonParser(String input) { */ 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(); @@ -57,8 +66,8 @@ private JsonParser(String input) { case '{' -> readObject(); case '[' -> readArray(); case '"' -> readString(); - case 't' -> readLiteral("true", Boolean.TRUE); - case 'f' -> readLiteral("false", Boolean.FALSE); + case 't' -> readLiteral("true", true); + case 'f' -> readLiteral("false", false); case 'n' -> readLiteral("null", null); default -> { if (c == '-' || (c >= '0' && c <= '9')) { @@ -161,11 +170,12 @@ private String readString() { if (pos + 4 > input.length()) { throw error("Invalid unicode escape"); } - try { - sb.append((char) Integer.parseInt(input.substring(pos, pos + 4), 16)); - } catch (NumberFormatException e) { - 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 + "'"); @@ -178,38 +188,71 @@ private String readString() { } } + /** Reads {@code -? (0 | [1-9][0-9]*) (. [0-9]+)? ([eE] [+-]? [0-9]+)?}. */ private Number readNumber() { int start = pos; - if (peek() == '-') { + if (peekIs('-')) { pos++; } - boolean decimal = false; - while (pos < input.length()) { - char c = input.charAt(pos); - if (c >= '0' && c <= '9') { - pos++; - } else if (c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-') { - decimal = true; + 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++; - } else { - break; } + digits(); + integer = false; } String text = input.substring(start, pos); - try { - if (!decimal) { - try { - return Long.parseLong(text); - } catch (NumberFormatException overflow) { - return new BigDecimal(text); - } + if (integer) { + try { + return Long.parseLong(text); + } catch (NumberFormatException overflow) { + // Too large for a long. } + } + try { return new BigDecimal(text); - } catch (NumberFormatException e) { + } 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"); @@ -227,7 +270,7 @@ private void enter() { private void skipWhitespace() { while (pos < input.length()) { char c = input.charAt(pos); - if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '') { + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { pos++; } else { break; diff --git a/exceptionless-core/src/main/java/com/exceptionless/json/JsonWriter.java b/exceptionless-core/src/main/java/com/exceptionless/json/JsonWriter.java index 5e1876e..de9fa25 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/json/JsonWriter.java +++ b/exceptionless-core/src/main/java/com/exceptionless/json/JsonWriter.java @@ -1,13 +1,12 @@ package com.exceptionless.json; +import com.exceptionless.internal.DataNormalizer; import java.lang.reflect.Array; import java.math.BigDecimal; import java.math.BigInteger; -import java.time.Instant; -import java.time.OffsetDateTime; -import java.time.format.DateTimeFormatter; -import java.time.temporal.TemporalAccessor; -import java.util.Date; +import java.util.Arrays; +import java.util.Collection; +import java.util.HexFormat; import java.util.Map; import java.util.Optional; import java.util.OptionalDouble; @@ -20,9 +19,14 @@ /** * 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. @@ -30,10 +34,12 @@ public final class JsonWriter { 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; } @@ -45,6 +51,7 @@ public static String toJson(@Nullable Object value) { return writer.toString(); } + /** Starts an object: write {@link #name(String) name}/value pairs, then {@link #endObject()}. */ public JsonWriter beginObject() { beforeValue(); out.append('{'); @@ -52,12 +59,14 @@ public JsonWriter beginObject() { 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('['); @@ -65,6 +74,7 @@ public JsonWriter beginArray() { return this; } + /** Ends the current array. */ public JsonWriter endArray() { pop(); out.append(']'); @@ -105,13 +115,14 @@ public JsonWriter fieldIfNotEmpty(String name, @Nullable Object value) { if (value instanceof Map map && map.isEmpty()) { return this; } - if (value instanceof java.util.Collection collection && collection.isEmpty()) { + if (value instanceof Collection collection && collection.isEmpty()) { return this; } name(name); return value(value); } + /** Writes {@code null}. */ public JsonWriter nullValue() { beforeValue(); out.append("null"); @@ -146,6 +157,10 @@ public JsonWriter value(@Nullable Object value) { 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()) { @@ -181,27 +196,20 @@ public JsonWriter value(@Nullable Object value) { if (value instanceof OptionalDouble optional) { return optional.isPresent() ? number(optional.getAsDouble()) : nullValue(); } - if (value instanceof Enum enumValue) { - return string(enumValue.name()); - } - if (value instanceof OffsetDateTime dateTime) { - return string(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(dateTime)); - } - if (value instanceof Date date) { - return string(date.toInstant().toString()); - } - if (value instanceof Instant || value instanceof TemporalAccessor) { - return string(value.toString()); - } 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(); @@ -239,6 +247,7 @@ public JsonWriter number(Number value) { return this; } + /** {@return the JSON written so far} */ @Override public String toString() { return out.toString(); @@ -259,9 +268,7 @@ private void beforeValue() { private void push() { if (depth == needsComma.length) { - boolean[] grown = new boolean[needsComma.length * 2]; - System.arraycopy(needsComma, 0, grown, 0, needsComma.length); - needsComma = grown; + needsComma = Arrays.copyOf(needsComma, needsComma.length * 2); } needsComma[depth++] = false; } @@ -287,7 +294,7 @@ private void writeString(String value) { case '\f' -> out.append("\\f"); default -> { if (c < 0x20 || c == '
' || c == '
') { - out.append(String.format("\\u%04x", (int) 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) diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/ErrorInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/ErrorInfo.java index bbae24e..b703498 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/ErrorInfo.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/ErrorInfo.java @@ -28,7 +28,7 @@ public void setMessage(@Nullable String message) { this.message = message; } - /** The fully qualified exception class name. */ + /** {@return the fully qualified exception class name} */ public @Nullable String getType() { return type; } @@ -45,7 +45,7 @@ public void setCode(@Nullable String code) { this.code = code; } - /** The cause of this error, if any. */ + /** {@return the cause of this error, if any} */ public @Nullable ErrorInfo getInner() { return inner; } @@ -66,7 +66,7 @@ public Map getData() { return data; } - /** The innermost (root cause) error. */ + /** {@return the innermost (root cause) error} */ public ErrorInfo getRootCause() { ErrorInfo current = this; while (current.inner != null) { diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/Event.java b/exceptionless-core/src/main/java/com/exceptionless/models/Event.java index 17141b1..9586565 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/Event.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/Event.java @@ -126,7 +126,7 @@ public void setMessage(@Nullable String message) { this.message = message; } - /** The location as {@code "latitude,longitude"}. */ + /** {@return the location as {@code "latitude,longitude"}} */ public @Nullable String getGeo() { return geo; } @@ -239,7 +239,7 @@ public void setSubmissionMethod(@Nullable String submissionMethod) { putOrRemove(KnownDataKeys.SUBMISSION_METHOD, submissionMethod); } - /** The id of the session this event belongs to ({@code @ref:session}). */ + /** {@return the id of the session this event belongs to ({@code @ref:session})} */ public @Nullable String getSessionId() { return getEventReference("session"); } diff --git a/exceptionless-core/src/main/java/com/exceptionless/models/RequestInfo.java b/exceptionless-core/src/main/java/com/exceptionless/models/RequestInfo.java index b8b8182..a02763c 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/RequestInfo.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/RequestInfo.java @@ -39,7 +39,7 @@ public void setHttpMethod(@Nullable String httpMethod) { this.httpMethod = httpMethod; } - /** True when the request used HTTPS. */ + /** {@return whether the request used HTTPS} */ public @Nullable Boolean getSecure() { return secure; } diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/ConfigurationDefaultsPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/ConfigurationDefaultsPlugin.java index 3b6d361..f7afd22 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/ConfigurationDefaultsPlugin.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/ConfigurationDefaultsPlugin.java @@ -2,7 +2,6 @@ import com.exceptionless.ExceptionlessConfiguration; import com.exceptionless.internal.DataNormalizer; -import com.exceptionless.internal.Exclusions; import com.exceptionless.json.JsonSerializable; import com.exceptionless.models.Event; import java.util.Map; @@ -27,7 +26,7 @@ public void run(EventPluginContext context) { if (defaults.isEmpty()) { return; } - Predicate excluded = Exclusions.of(config.getDataExclusions()); + Predicate excluded = config::isDataExcluded; for (Map.Entry entry : defaults.entrySet()) { String key = entry.getKey(); if (event.getData().containsKey(key) || excluded.test(key)) { diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/ContextData.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/ContextData.java index e523ef0..d69adea 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/ContextData.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/ContextData.java @@ -48,7 +48,7 @@ public boolean isUnhandledError() { /** Marks the error as unhandled (it crashed a thread or request). */ public ContextData markAsUnhandledError() { - return put(IS_UNHANDLED, Boolean.TRUE); + return put(IS_UNHANDLED, true); } public @Nullable String getSubmissionMethod() { diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/DuplicateCheckerPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/DuplicateCheckerPlugin.java index cbe073c..a8dd21f 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/DuplicateCheckerPlugin.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/DuplicateCheckerPlugin.java @@ -5,6 +5,8 @@ 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; @@ -14,7 +16,6 @@ import java.util.Map; import java.util.Objects; import java.util.concurrent.ScheduledFuture; -import java.util.function.LongSupplier; import org.jspecify.annotations.Nullable; /** @@ -27,17 +28,20 @@ public final class DuplicateCheckerPlugin implements EventPlugin { static final int MAX_HASHES = 50; private final Duration window; - private final LongSupplier clock; - private final Deque processed = new ArrayDeque<>(); // {hash, timestampMillis} + 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), System::currentTimeMillis); + this(Duration.ofSeconds(30), InstantSource.system()); } - DuplicateCheckerPlugin(Duration window, LongSupplier clock) { + DuplicateCheckerPlugin(Duration window, InstantSource clock) { this.window = window; this.clock = clock; } @@ -62,7 +66,8 @@ public void run(EventPluginContext context) { return; } int hash = hash(event); - long now = clock.getAsLong(); + Instant now = clock.instant(); + Instant cutoff = now.minus(window); synchronized (this) { Merged existing = merged.get(hash); if (existing != null) { @@ -70,44 +75,53 @@ public void run(EventPluginContext context) { context.cancel(); return; } - for (Iterator it = processed.iterator(); it.hasNext(); ) { - long[] entry = it.next(); - if (now - entry[1] >= window.toMillis()) { + for (Iterator it = processed.iterator(); it.hasNext(); ) { + Processed entry = it.next(); + if (!entry.seenAt().isAfter(cutoff)) { it.remove(); - } else if (entry[0] == hash) { + } else if (entry.hash() == hash) { merged.put(hash, new Merged(event, countOf(event))); context.cancel(); return; } } - processed.addLast(new long[] {hash, now}); + 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; } - ExceptionlessClient target = client; for (Merged item : toSend) { item.event.setCount(item.count); - if (target != null) { - target.getQueue().enqueue(item.event); - } + target.getQueue().enqueue(item.event); } } diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/ErrorPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/ErrorPlugin.java index 76c597b..8244253 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/ErrorPlugin.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/ErrorPlugin.java @@ -2,11 +2,11 @@ import com.exceptionless.ExceptionlessConfiguration; import com.exceptionless.internal.ErrorConverter; -import com.exceptionless.internal.Exclusions; 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; @@ -20,8 +20,8 @@ public final class ErrorPlugin implements EventPlugin { public static final int PRIORITY = 20; - private final Set processed = - Collections.newSetFromMap(Collections.synchronizedMap(new WeakHashMap<>())); + /** Exceptions already reported. Weak, so reported exceptions can still be collected. */ + private final Set processed = Collections.newSetFromMap(new WeakHashMap<>()); @Override public int getPriority() { @@ -45,8 +45,7 @@ public void run(EventPluginContext context) { } ExceptionlessConfiguration config = context.getConfiguration(); List modules = config.isIncludeModules() ? ModuleCollector.getModules() : List.of(); - event.setError( - ErrorConverter.convert(exception, Exclusions.of(config.getDataExclusions()), modules)); + event.setError(ErrorConverter.convert(exception, config::isDataExcluded, modules)); } /** @@ -54,10 +53,10 @@ public void run(EventPluginContext context) { * wrapper of an already reported exception (or a cause of one) isn't sent again. */ private boolean markProcessed(Throwable exception) { - List chain = new java.util.ArrayList<>(); + List chain = new ArrayList<>(); for (Throwable t = exception; - t != null && chain.size() < 20 && !chain.contains(t); - t = t.getCause()) { + t != null && chain.size() < ErrorConverter.MAX_CAUSES && !chain.contains(t); + t = ErrorConverter.safeCause(t)) { chain.add(t); } synchronized (processed) { @@ -73,6 +72,8 @@ private boolean markProcessed(Throwable exception) { /** Visible for tests: forgets which exceptions were reported. */ void reset() { - processed.clear(); + 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 index 5e3aee3..56b1ebc 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/EventExclusionPlugin.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventExclusionPlugin.java @@ -5,6 +5,7 @@ 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; @@ -80,7 +81,7 @@ public void run(EventPluginContext context) { ErrorInfo error = event.getError(); while (error != null) { - if (!settings.getTypeAndSourceSetting("error", error.getType(), true)) { + if (!settings.getTypeAndSourceSetting(EventTypes.ERROR, error.getType(), true)) { String errorType = error.getType(); LOG.debug(() -> "Cancelling error from excluded exception type " + errorType); context.cancel(); diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPluginContext.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPluginContext.java index 6c43fc8..bd6670c 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPluginContext.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/EventPluginContext.java @@ -17,12 +17,12 @@ public EventPluginContext(ExceptionlessClient client, Event event, ContextData c this.contextData = contextData; } - /** The client processing the event. */ + /** {@return the client processing the event} */ public ExceptionlessClient getClient() { return client; } - /** The client's configuration. */ + /** {@return the client's configuration} */ public ExceptionlessConfiguration getConfiguration() { return client.getConfiguration(); } diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/HeartbeatPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/HeartbeatPlugin.java index 43f717e..5213d8b 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/HeartbeatPlugin.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/HeartbeatPlugin.java @@ -2,6 +2,7 @@ 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; @@ -45,9 +46,11 @@ public void run(EventPluginContext context) { } private static @Nullable String identityOf(@Nullable UserInfo user) { - return user != null && user.identity() != null && !user.identity().isBlank() - ? user.identity() - : null; + if (user == null) { + return null; + } + String identity = user.identity(); + return Strings.isBlank(identity) ? null : identity; } private synchronized void start( @@ -59,12 +62,18 @@ private synchronized void start( identity = id; heartbeat = client.scheduleAtFixedRate( - () -> client.submitSessionHeartbeat(id), + () -> sendHeartbeat(client, id), config.getHeartbeatInterval(), config.getHeartbeatInterval()); } - /** True while heartbeats are being sent. */ + // 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(); } diff --git a/exceptionless-core/src/main/java/com/exceptionless/plugins/RequestInfoPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/RequestInfoPlugin.java index e5f2de5..87fe2ce 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/RequestInfoPlugin.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/RequestInfoPlugin.java @@ -1,13 +1,14 @@ package com.exceptionless.plugins; import com.exceptionless.ExceptionlessConfiguration; -import com.exceptionless.internal.Exclusions; +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; @@ -57,7 +58,7 @@ public void run(EventPluginContext context) { /** Applies the privacy settings and data exclusions to {@code request} in place. */ private static void sanitize(RequestInfo request, ExceptionlessConfiguration config) { - Predicate excluded = Exclusions.of(config.getDataExclusions()); + Predicate excluded = config::isDataExcluded; if (!config.isIncludeIpAddress()) { request.setClientIpAddress(null); @@ -79,7 +80,7 @@ private static void sanitize(RequestInfo request, ExceptionlessConfiguration con for (Map.Entry entry : form.entrySet()) { String key = String.valueOf(entry.getKey()); if (!excluded.test(key) && entry.getValue() != null) { - filtered.put(key, truncate(entry.getValue())); + filtered.put(key, truncateFormValue(entry.getValue())); } } request.setPostData(filtered.isEmpty() ? null : filtered); @@ -97,7 +98,11 @@ private static void sanitize(RequestInfo request, ExceptionlessConfiguration con || entry.getValue() == null) { continue; } - List values = entry.getValue().stream().map(v -> (String) truncate(v)).toList(); + List values = + entry.getValue().stream() + .filter(Objects::nonNull) + .map(RequestInfoPlugin::truncate) + .toList(); filtered.put(name, values); } request.setHeaders(filtered.isEmpty() ? null : filtered); @@ -112,16 +117,18 @@ private static void sanitize(RequestInfo request, ExceptionlessConfiguration con 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(), (String) truncate(entry.getValue())); + filtered.put(entry.getKey(), truncate(entry.getValue())); } } return filtered.isEmpty() ? null : filtered; } - private static Object truncate(Object value) { - if (value instanceof String s && s.length() > MAX_VALUE_LENGTH) { - return s.substring(0, MAX_VALUE_LENGTH); - } - return value; + 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/VersionPlugin.java b/exceptionless-core/src/main/java/com/exceptionless/plugins/VersionPlugin.java index febb699..d35f9fe 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/VersionPlugin.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/VersionPlugin.java @@ -12,8 +12,10 @@ public final class VersionPlugin implements EventPlugin { public static final int PRIORITY = 80; - private volatile @Nullable String detected; - private volatile boolean detectionAttempted; + /** 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() { @@ -28,24 +30,20 @@ public void run(EventPluginContext context) { } String version = context.getConfiguration().getVersion(); if (version == null) { - version = detect(); + version = Detected.VERSION; } if (version != null) { event.setVersion(version); } } - private @Nullable String detect() { - if (!detectionAttempted) { - for (ModuleInfo module : ModuleCollector.getModules()) { - String version = module.version(); - if (module.entry() && version != null) { - detected = version; - break; - } + /** 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(); } - detectionAttempted = true; } - return detected; + return null; } } diff --git a/exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java b/exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java index 0100222..02184d0 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java +++ b/exceptionless-core/src/main/java/com/exceptionless/queue/DefaultEventQueue.java @@ -9,14 +9,15 @@ import com.exceptionless.submission.SubmissionClient; import com.exceptionless.submission.SubmissionResponse; import java.nio.charset.StandardCharsets; -import java.time.Clock; 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, @@ -49,25 +50,26 @@ public final class DefaultEventQueue implements EventQueue { private final ExceptionlessConfiguration config; private final SubmissionClient submissionClient; private final QueueStorage storage; - private final Clock clock; + 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; - private volatile int currentBatchSize; - private volatile int consecutiveFailures; + // Only accessed while holding processLock. + private int currentBatchSize; + private int consecutiveFailures; public DefaultEventQueue( ExceptionlessConfiguration config, SubmissionClient submissionClient, QueueStorage storage) { - this(config, submissionClient, storage, Clock.systemUTC()); + this(config, submissionClient, storage, InstantSource.system()); } DefaultEventQueue( ExceptionlessConfiguration config, SubmissionClient submissionClient, QueueStorage storage, - Clock clock) { + InstantSource clock) { this.config = config; this.submissionClient = submissionClient; this.storage = storage; @@ -157,18 +159,11 @@ private List nextBatch() { /** Sends one batch. Returns true to keep processing. */ private boolean send(List batch) { - StringBuilder json = new StringBuilder("["); - for (int i = 0; i < batch.size(); i++) { - if (i > 0) { - json.append(','); - } - json.append(batch.get(i).json()); - } - json.append(']'); + String json = batch.stream().map(QueueEntry::json).collect(Collectors.joining(",", "[", "]")); SubmissionResponse response; try { - response = submissionClient.postEvents(json.toString()); + response = submissionClient.postEvents(json); } catch (RuntimeException e) { response = SubmissionResponse.failed(e); } @@ -184,72 +179,75 @@ private boolean send(List batch) { } private boolean handleResponse(List batch, SubmissionResponse response) { - int status = response.statusCode(); if (response.isSuccess()) { LOG.debug(() -> "Sent " + batch.size() + " events"); storage.remove(batch); consecutiveFailures = 0; return true; } - if (response.isTooManyRequests()) { - // 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); + 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; } - LOG.warn("Rate limit reached; pausing event submission until " + window); - suspendProcessing(Duration.between(now, window)); - return false; - } - if (response.isServiceUnavailable()) { - LOG.warn("Server unavailable (503); pausing event submission for 5 minutes"); - suspendProcessing(Duration.ofMinutes(5)); - return false; - } - if (response.isPaymentRequired()) { - LOG.warn("Event limit reached for this billing period; discarding events for an hour"); - suspendProcessing(Duration.ofHours(1), true, true); - return false; - } - if (response.isUnauthorized() || response.isForbidden()) { - LOG.error( - "Unable to send events: the API key is invalid or not authorized (" + status + ")", null); - storage.remove(batch); - suspendProcessing(Duration.ofMinutes(15)); - return false; - } - if (response.isBadRequest() || response.isNotFound()) { - LOG.error("Unable to send events (HTTP " + status + "): " + response.message(), null); - storage.remove(batch); - suspendProcessing(Duration.ofHours(4)); - return false; - } - if (response.isRequestEntityTooLarge()) { - 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"); + 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; } - LOG.error("Discarding an event that is too large to send", null); - 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; + } } - - // Network errors, timeouts and 5xx: retry with backoff. - storage.incrementAttempts(batch); - int failures = ++consecutiveFailures; - long seconds = Math.min(300, 15L << Math.min(failures - 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 diff --git a/exceptionless-core/src/main/java/com/exceptionless/queue/EventQueue.java b/exceptionless-core/src/main/java/com/exceptionless/queue/EventQueue.java index 74692d7..b0b0756 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/queue/EventQueue.java +++ b/exceptionless-core/src/main/java/com/exceptionless/queue/EventQueue.java @@ -27,9 +27,13 @@ default void suspendProcessing(Duration duration) { /** 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(); - /** The number of queued events. */ + /** {@return the number of queued events} */ int size(); /** Adds a listener called after each batch is posted. */ diff --git a/exceptionless-core/src/main/java/com/exceptionless/settings/ClientSettings.java b/exceptionless-core/src/main/java/com/exceptionless/settings/ClientSettings.java index bfd3370..1e623c4 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/settings/ClientSettings.java +++ b/exceptionless-core/src/main/java/com/exceptionless/settings/ClientSettings.java @@ -150,7 +150,7 @@ public boolean getTypeAndSourceSetting( if (value == null) { return null; } - return switch (value.trim().toLowerCase(Locale.ROOT)) { + return switch (value.strip().toLowerCase(Locale.ROOT)) { case "true", "yes", "1", "on" -> Boolean.TRUE; case "false", "no", "0", "off" -> Boolean.FALSE; default -> null; diff --git a/exceptionless-core/src/main/java/com/exceptionless/storage/FileKeyValueStorage.java b/exceptionless-core/src/main/java/com/exceptionless/storage/FileKeyValueStorage.java index 696bbc3..3e13217 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/storage/FileKeyValueStorage.java +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/FileKeyValueStorage.java @@ -2,6 +2,7 @@ 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; @@ -22,7 +23,7 @@ public FileKeyValueStorage(Path directory) { try { Files.createDirectories(directory); } catch (IOException e) { - throw new IllegalArgumentException("Unable to create storage directory " + directory, e); + throw new UncheckedIOException("Unable to create storage directory " + directory, e); } } diff --git a/exceptionless-core/src/main/java/com/exceptionless/storage/FileQueueStorage.java b/exceptionless-core/src/main/java/com/exceptionless/storage/FileQueueStorage.java index c2e008e..a3fc760 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/storage/FileQueueStorage.java +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/FileQueueStorage.java @@ -2,6 +2,7 @@ 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; @@ -9,93 +10,194 @@ 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.concurrent.atomic.AtomicLong; +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 AtomicLong sequence = new AtomicLong(); + 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 IllegalArgumentException("Unable to create queue directory " + directory, 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) { - List files = listFiles(); - int excess = files.size() - maxItems + 1; + int excess = index.size() - maxItems + 1; for (int i = 0; i < excess; i++) { - deleteQuietly(files.get(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"); } - String name = - String.format( - "%013d-%06d.0.json", - System.currentTimeMillis(), sequence.incrementAndGet() % 1_000_000); - Path target = directory.resolve(name); - Path temp = directory.resolve(name + ".tmp"); + 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<>(); - for (Path file : listFiles()) { - if (result.size() >= max) { - break; - } - Matcher matcher = FILE_NAME.matcher(file.getFileName().toString()); - if (!matcher.matches()) { - continue; - } + 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( - file.getFileName().toString(), + stored.fileName(), json, - Integer.parseInt(matcher.group(3)), - Instant.ofEpochMilli(Long.parseLong(matcher.group(1))))); + stored.attempts(), + Instant.ofEpochMilli(item.getKey().millis()))); } catch (NoSuchFileException e) { - // Removed concurrently. + it.remove(); // deleted outside of this storage } catch (IOException | RuntimeException e) { LOG.warn("Discarding unreadable queued event " + file, e); + it.remove(); deleteQuietly(file); } } @@ -105,24 +207,34 @@ public synchronized List peek(int max) { @Override public synchronized void remove(Collection entries) { for (QueueEntry entry : entries) { - deleteQuietly(directory.resolve(entry.id())); + 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) { - Matcher matcher = FILE_NAME.matcher(entry.id()); - if (!matcher.matches()) { + Parsed parsed = Parsed.of(entry.id()); + Stored stored = parsed != null ? index.get(parsed.key()) : null; + if (stored == null) { continue; } - String renamed = - matcher.group(1) + "-" + matcher.group(2) + "." + (entry.attempts() + 1) + ".json"; + Stored updated = new Stored(stored.baseName(), stored.attempts() + 1); try { Files.move( - directory.resolve(entry.id()), - directory.resolve(renamed), + 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); } @@ -131,27 +243,15 @@ public synchronized void incrementAttempts(Collection entries) { @Override public synchronized void clear() { - for (Path file : listFiles()) { - deleteQuietly(file); + for (Stored stored : index.values()) { + deleteQuietly(directory.resolve(stored.fileName())); } + index.clear(); } @Override public synchronized int size() { - return listFiles().size(); - } - - private List listFiles() { - List files = new ArrayList<>(); - try (DirectoryStream stream = Files.newDirectoryStream(directory, "*.json")) { - for (Path file : stream) { - files.add(file); - } - } catch (IOException e) { - LOG.warn("Unable to list queued events in " + directory, e); - } - files.sort(null); - return files; + return index.size(); } private static void deleteQuietly(Path file) { diff --git a/exceptionless-core/src/main/java/com/exceptionless/storage/InMemoryQueueStorage.java b/exceptionless-core/src/main/java/com/exceptionless/storage/InMemoryQueueStorage.java index 0e539e0..74491f6 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/storage/InMemoryQueueStorage.java +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/InMemoryQueueStorage.java @@ -1,16 +1,13 @@ package com.exceptionless.storage; import com.exceptionless.internal.Log; -import java.time.Instant; +import java.time.InstantSource; import java.util.ArrayList; import java.util.Collection; -import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Set; -import java.util.concurrent.atomic.AtomicLong; /** Keeps queued events in memory. When full, the oldest events are dropped. */ public final class InMemoryQueueStorage implements QueueStorage { @@ -18,8 +15,10 @@ public final class InMemoryQueueStorage implements QueueStorage { 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 final AtomicLong sequence = new AtomicLong(); + private long sequence; private long dropped; public InMemoryQueueStorage() { @@ -27,10 +26,20 @@ public InMemoryQueueStorage() { } 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 @@ -43,8 +52,8 @@ public synchronized void enqueue(String json) { LOG.warn("Event queue is full (" + maxItems + " items); dropping the oldest events"); } } - String id = Long.toString(sequence.incrementAndGet()); - entries.put(id, new QueueEntry(id, json, 0, Instant.now())); + String id = Long.toString(++sequence); + entries.put(id, new QueueEntry(id, json, 0, clock.instant())); } @Override @@ -68,15 +77,9 @@ public synchronized void remove(Collection toRemove) { @Override public synchronized void incrementAttempts(Collection toUpdate) { - Set ids = new HashSet<>(); for (QueueEntry entry : toUpdate) { - ids.add(entry.id()); - } - for (Map.Entry item : entries.entrySet()) { - if (ids.contains(item.getKey())) { - QueueEntry e = item.getValue(); - item.setValue(new QueueEntry(e.id(), e.json(), e.attempts() + 1, e.enqueuedAt())); - } + entries.computeIfPresent( + entry.id(), (id, current) -> current.withAttempts(current.attempts() + 1)); } } diff --git a/exceptionless-core/src/main/java/com/exceptionless/storage/KeyValueStorage.java b/exceptionless-core/src/main/java/com/exceptionless/storage/KeyValueStorage.java index 7b328a0..8d722c3 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/storage/KeyValueStorage.java +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/KeyValueStorage.java @@ -4,7 +4,21 @@ /** 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 index d2fcfa2..55241af 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/storage/QueueEntry.java +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/QueueEntry.java @@ -10,4 +10,9 @@ * @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) {} +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 index 4d3a6ba..d363935 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/storage/QueueStorage.java +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/QueueStorage.java @@ -17,7 +17,9 @@ public interface QueueStorage { /** 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/submission/DefaultSubmissionClient.java b/exceptionless-core/src/main/java/com/exceptionless/submission/DefaultSubmissionClient.java index e5e0124..578610d 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/submission/DefaultSubmissionClient.java +++ b/exceptionless-core/src/main/java/com/exceptionless/submission/DefaultSubmissionClient.java @@ -172,7 +172,7 @@ private HttpRequest.Builder request(String url) { return null; } try { - return Integer.parseInt(value.trim()); + return Integer.parseInt(value.strip()); } catch (NumberFormatException e) { return null; } diff --git a/exceptionless-core/src/test/java/com/exceptionless/ClientSmokeTest.java b/exceptionless-core/src/test/java/com/exceptionless/ClientSmokeTest.java index cfd8399..b8767f6 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/ClientSmokeTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/ClientSmokeTest.java @@ -29,10 +29,7 @@ void sendsErrorWithSnakeCaseStackTrace() throws Exception { assertThat(events).hasSize(1); Map event = events.get(0); assertThat(event.get("type")).isEqualTo("error"); - @SuppressWarnings("unchecked") - Map data = (Map) event.get("data"); - @SuppressWarnings("unchecked") - Map error = (Map) data.get("@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 index 912f7fd..8b42f21 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessClientTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessClientTest.java @@ -7,11 +7,14 @@ 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; @@ -52,6 +55,15 @@ void startsOnFirstUse() { }); } + @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); @@ -267,12 +279,15 @@ void neverStartedClientClosesQuietly() { @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"); - long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); - while (submission.posts.isEmpty() && System.nanoTime() < deadline) { - Thread.sleep(20); - } + assertThat(posted.await(5, TimeUnit.SECONDS)).isTrue(); assertThat(submission.posts).hasSize(1); } @@ -361,15 +376,13 @@ void heartbeatsAreSentOnTheInterval() throws Exception { // 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), + () -> hbClient.submitSessionHeartbeat(null).thenRun(sent::countDown), Duration.ofMillis(10), Duration.ofMillis(50)); - long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); - while (submission.heartbeats.size() < 2 && System.nanoTime() < deadline) { - Thread.sleep(20); - } + assertThat(sent.await(5, TimeUnit.SECONDS)).isTrue(); } assertThat(submission.heartbeats).contains("hb-user"); } @@ -378,12 +391,12 @@ void heartbeatsAreSentOnTheInterval() throws Exception { void sessionEndStopsHeartbeats() throws Exception { config.userIdentity("user-2").useSessions(true, Duration.ofMinutes(1), false); client.submitLog("starts the heartbeat"); - com.exceptionless.plugins.HeartbeatPlugin heartbeat = - (com.exceptionless.plugins.HeartbeatPlugin) - config.getPlugins().stream() - .filter(p -> p instanceof com.exceptionless.plugins.HeartbeatPlugin) - .findFirst() - .orElseThrow(); + 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(); diff --git a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java index fb787d1..75ed94d 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java @@ -167,6 +167,28 @@ void dataExclusionsCombineLocalAndServer() { 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(); 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/DataNormalizerTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/DataNormalizerTest.java index 5d02d04..4ada732 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/internal/DataNormalizerTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/DataNormalizerTest.java @@ -2,6 +2,7 @@ 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; @@ -16,6 +17,7 @@ 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 { @@ -100,9 +102,7 @@ void convertsRecordsAndBeans() { new BigDecimal("9.99"), new Customer("Ann", new Address("Main", "Dallas"), List.of("vip"), "secret")); Object normalized = DataNormalizer.normalize(order, 5, NONE); - assertThat(normalized).isInstanceOf(Map.class); - @SuppressWarnings("unchecked") - Map map = (Map) normalized; + Map map = TestJson.object(normalized); assertThat(map) .containsEntry("id", "o-1") .containsEntry("total", new BigDecimal("9.99")) @@ -209,12 +209,16 @@ void capsCollectionSizes() { for (int i = 0; i < 5000; i++) { big.add(i); } - assertThat((List) DataNormalizer.normalize(big, 5, NONE)).hasSize(1000); + 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((Map) DataNormalizer.normalize(bigMap, 5, NONE)).hasSize(1000); + assertThat(DataNormalizer.normalize(bigMap, 5, NONE)) + .asInstanceOf(InstanceOfAssertFactories.MAP) + .hasSize(1000); } @Test diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/ErrorConverterTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/ErrorConverterTest.java index a00614f..7f6a2a6 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/internal/ErrorConverterTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/ErrorConverterTest.java @@ -10,8 +10,8 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.util.List; -import java.util.Map; 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; @@ -111,9 +111,8 @@ void recordsSuppressedExceptions() { Exception primary = new Exception("primary"); primary.addSuppressed(new IllegalStateException("close failed")); ErrorInfo error = convert(primary); - @SuppressWarnings("unchecked") - List suppressed = (List) error.getData().get("suppressed"); - assertThat(suppressed) + assertThat(error.getData().get("suppressed")) + .asInstanceOf(InstanceOfAssertFactories.list(ErrorInfo.class)) .singleElement() .satisfies(s -> assertThat(s.getMessage()).isEqualTo("close failed")); } @@ -121,10 +120,8 @@ void recordsSuppressedExceptions() { @Test void addsCustomPropertiesAsExtendedDataWithExclusions() { ErrorInfo error = convert(new PaymentException("declined", "order-7", null)); - @SuppressWarnings("unchecked") - Map ext = - (Map) error.getData().get(KnownDataKeys.EXTENDED_DATA); - assertThat(ext) + 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"); diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/IdsTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/IdsTest.java index a41031d..d2ea27e 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/internal/IdsTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/IdsTest.java @@ -5,6 +5,8 @@ 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 @@ -18,16 +20,25 @@ void referenceIdsAreValidAndUnique() { assertThat(ids).hasSize(10_000); } - @Test - void validatesReferenceIdsLikeTheServer() { - assertThat(Ids.isValidReferenceId("abcd1234")).isTrue(); - assertThat(Ids.isValidReferenceId("ABC-123-xyz")).isTrue(); - assertThat(Ids.isValidReferenceId("a".repeat(100))).isTrue(); - assertThat(Ids.isValidReferenceId("short")).isFalse(); - assertThat(Ids.isValidReferenceId("a".repeat(101))).isFalse(); - assertThat(Ids.isValidReferenceId("has space1")).isFalse(); - assertThat(Ids.isValidReferenceId("under_score")).isFalse(); - assertThat(Ids.isValidReferenceId(null)).isFalse(); + @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 diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/WildcardTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/WildcardTest.java index 5c16097..bee85b6 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/internal/WildcardTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/WildcardTest.java @@ -3,6 +3,7 @@ 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; @@ -26,6 +27,7 @@ class WildcardTest { }) void matchesLikeTheOtherClients(String value, String pattern, boolean expected) { assertThat(Wildcard.matches(value, pattern)).isEqualTo(expected); + assertThat(Wildcard.compile(pattern).test(value)).isEqualTo(expected); } @ParameterizedTest @@ -36,10 +38,28 @@ void nullOrBlankNeverMatches(String value, String pattern) { assertThat(Wildcard.matches(value, pattern)).isFalse(); } - @org.junit.jupiter.api.Test + @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 index 8e35ec8..fcd6484 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/json/JsonParserTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/json/JsonParserTest.java @@ -4,9 +4,13 @@ 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 @@ -24,7 +28,7 @@ void parsesAllValueTypes() { Object value = JsonParser.parse(" [1, -2.5, 1e3, true, false, null, \"s\", {}, []] "); assertThat(value) .isEqualTo( - java.util.Arrays.asList( + Arrays.asList( 1L, new BigDecimal("-2.5"), new BigDecimal("1e3"), @@ -47,26 +51,91 @@ void parsesHugeIntegersAsBigDecimal() { .isEqualTo(new BigDecimal("123456789012345678901234567890")); } - @Test - void rejectsInvalidJson() { - for (String invalid : - List.of( - "", - "{", - "[1,]", - "{\"a\" 1}", - "tru", - "\"unterminated", - "{} extra", - "{'a':1}", - "\"\\x\"", - "[\"\u0001\"]")) { - assertThatThrownBy(() -> JsonParser.parse(invalid)) - .as(invalid) - .isInstanceOf(IllegalArgumentException.class); + 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); diff --git a/exceptionless-core/src/test/java/com/exceptionless/json/JsonWriterTest.java b/exceptionless-core/src/test/java/com/exceptionless/json/JsonWriterTest.java index 5301537..e3f3afa 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/json/JsonWriterTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/json/JsonWriterTest.java @@ -2,7 +2,10 @@ 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; @@ -74,6 +77,16 @@ void writesOtherTypesWithToString() { 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(); diff --git a/exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java b/exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java index c559215..85a1e3e 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java @@ -5,6 +5,7 @@ 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; @@ -87,7 +88,6 @@ class WireContractTest { private static final Set MANUAL_STACKING = Set.of("title", "signature_data"); @Test - @SuppressWarnings("unchecked") void everyPropertyExistsOnTheServerModel() { CapturingSubmissionClient submission = new CapturingSubmissionClient(); try (ExceptionlessClient client = @@ -125,44 +125,46 @@ void everyPropertyExistsOnTheServerModel() { } Map event = submission.events().get(0); assertThat(event.keySet()).isSubsetOf(EVENT); - Map data = (Map) event.get("data"); + Map data = TestJson.object(event.get("data")); - Map error = (Map) data.get("@error"); + Map error = TestJson.object(data.get("@error")); checkError(error); - List> modules = (List>) error.get("modules"); - assertThat(modules).isNotEmpty().allSatisfy(m -> assertThat(m.keySet()).isSubsetOf(MODULE)); + assertThat(TestJson.objects(error.get("modules"))) + .isNotEmpty() + .allSatisfy(m -> assertThat(m.keySet()).isSubsetOf(MODULE)); - assertThat(((Map) data.get("@request")).keySet()) + 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(((Map) data.get("@environment")).keySet()) + assertThat(environment.keySet()) .isSubsetOf(ENVIRONMENT) .contains("o_s_name", "o_s_version", "process_id"); - assertThat(((Map) data.get("@user")).keySet()).isSubsetOf(USER); - assertThat(((Map) data.get("@user_description")).keySet()) + assertThat(TestJson.object(data.get("@user")).keySet()).isSubsetOf(USER); + assertThat(TestJson.object(data.get("@user_description")).keySet()) .isSubsetOf(USER_DESCRIPTION); - assertThat(((Map) data.get("@stack")).keySet()).isSubsetOf(MANUAL_STACKING); + assertThat(TestJson.object(data.get("@stack")).keySet()).isSubsetOf(MANUAL_STACKING); // Types the server is strict about. - Map request = (Map) data.get("@request"); 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")); - Map environment = (Map) data.get("@environment"); assertThat(environment.get("process_id")).isInstanceOf(String.class); assertThat(environment.get("thread_id")).isInstanceOf(String.class); assertThat(event.get("count")).isEqualTo(2L); - assertThat((String) event.get("date")) + 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)"); } - @SuppressWarnings("unchecked") private static void checkError(Map error) { assertThat(error.keySet()).isSubsetOf(ERROR); - List> frames = (List>) error.get("stack_trace"); - assertThat(frames).isNotEmpty().allSatisfy(f -> assertThat(f.keySet()).isSubsetOf(STACK_FRAME)); + assertThat(TestJson.objects(error.get("stack_trace"))) + .isNotEmpty() + .allSatisfy(f -> assertThat(f.keySet()).isSubsetOf(STACK_FRAME)); if (error.get("inner") != null) { - checkError((Map) error.get("inner")); + checkError(TestJson.object(error.get("inner"))); } } } diff --git a/exceptionless-core/src/test/java/com/exceptionless/plugins/DuplicateCheckerPluginTest.java b/exceptionless-core/src/test/java/com/exceptionless/plugins/DuplicateCheckerPluginTest.java index 2daaca5..9ee2059 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/plugins/DuplicateCheckerPluginTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/DuplicateCheckerPluginTest.java @@ -5,11 +5,13 @@ 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 java.util.concurrent.atomic.AtomicLong; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -17,9 +19,9 @@ class DuplicateCheckerPluginTest { private final CapturingSubmissionClient submission = new CapturingSubmissionClient(); private final ExceptionlessClient client = new ExceptionlessClient(TestClients.config().submissionClient(submission)); - private final AtomicLong now = new AtomicLong(1_000_000); + private final TestClock clock = new TestClock(); private final DuplicateCheckerPlugin plugin = - new DuplicateCheckerPlugin(Duration.ofSeconds(30), now::get); + new DuplicateCheckerPlugin(Duration.ofSeconds(30), clock); @AfterEach void close() { @@ -35,7 +37,7 @@ private static Event error(Throwable t) { /** Errors created at the same call site, so they have identical stacks. */ private static List sameErrors(int count) { - List events = new java.util.ArrayList<>(); + List events = new ArrayList<>(); for (int i = 0; i < count; i++) { events.add(error(new RuntimeException("duplicate"))); } @@ -77,7 +79,7 @@ void sameErrorAfterTheWindowIsSentAgain() { plugin.startup(client); List events = sameErrors(2); PluginTestSupport.run(plugin, client, events.get(0)); - now.addAndGet(31_000); + clock.advance(Duration.ofSeconds(31)); assertThat(PluginTestSupport.run(plugin, client, events.get(1)).isCancelled()).isFalse(); } @@ -109,13 +111,35 @@ void suspendFlushesMergedEvents() { 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(java.time.OffsetDateTime.now()); + a.setDate(OffsetDateTime.now(clock)); Event b = events.get(1); - b.setDate(java.time.OffsetDateTime.now().minusDays(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/queue/DefaultEventQueueTest.java b/exceptionless-core/src/test/java/com/exceptionless/queue/DefaultEventQueueTest.java index 6021a54..31da2ff 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/queue/DefaultEventQueueTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/queue/DefaultEventQueueTest.java @@ -5,13 +5,11 @@ 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.Clock; import java.time.Duration; -import java.time.Instant; -import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; @@ -19,34 +17,10 @@ import org.junit.jupiter.params.provider.ValueSource; class DefaultEventQueueTest { - /** A clock tests can move. */ - static final class MutableClock extends Clock { - Instant now = Instant.parse("2026-05-01T10:07:00Z"); - - @Override - public ZoneOffset getZone() { - return ZoneOffset.UTC; - } - - @Override - public Clock withZone(java.time.ZoneId zone) { - return this; - } - - @Override - public Instant instant() { - return now; - } - - void advance(Duration duration) { - now = now.plus(duration); - } - } - private final ExceptionlessConfiguration config = TestClients.config().submissionBatchSize(10); private final CapturingSubmissionClient submission = new CapturingSubmissionClient(); - private final InMemoryQueueStorage storage = new InMemoryQueueStorage(1000); - private final MutableClock clock = new MutableClock(); + 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) { @@ -224,10 +198,8 @@ void serverErrorsAreRetried() { void dropsEventsOlderThanMaxAge() { config.queueMaxAge(Duration.ofHours(1)); enqueue(2); - DefaultEventQueue later = - new DefaultEventQueue( - config, submission, storage, Clock.offset(Clock.systemUTC(), Duration.ofHours(2))); - later.process(); + clock.advance(Duration.ofHours(2)); + queue.process(); assertThat(submission.posts).isEmpty(); assertThat(storage.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 index 52ffd21..6d63afe 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/settings/ClientSettingsTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/settings/ClientSettingsTest.java @@ -4,6 +4,7 @@ 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; @@ -52,36 +53,36 @@ void splitsLists() { assertThat(Strings.splitList(null)).isEmpty(); } - @Test - void minLogLevelUsesExactSourceFirst() { + @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("com.example.Noisy")).isEqualTo(LogLevel.OFF); - assertThat(settings.getMinLogLevel("com.example.Service")).isEqualTo(LogLevel.INFO); - assertThat(settings.getMinLogLevel("org.other.Thing")).isEqualTo(LogLevel.ERROR); + "@@log:*", "error", + "@@log:com.example.*", "info", + "@@log:com.example.Noisy", "off")); + assertThat(settings.getMinLogLevel(source)).isEqualTo(expected); } - @Test - void longestWildcardWins() { + @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("com.example.api.Users")).isEqualTo(LogLevel.TRACE); - assertThat(settings.getMinLogLevel("com.example.db.Pool")).isEqualTo(LogLevel.INFO); - assertThat(settings.getMinLogLevel("com.zzz")).isEqualTo(LogLevel.WARN); + "@@log:com.*", "warn", + "@@log:com.example.api.*", "trace", + "@@log:com.example.*", "info")); + assertThat(settings.getMinLogLevel(source)).isEqualTo(expected); } @Test @@ -155,6 +156,13 @@ void unknownLevelsAreNull() { 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(); diff --git a/exceptionless-core/src/test/java/com/exceptionless/storage/QueueStorageTest.java b/exceptionless-core/src/test/java/com/exceptionless/storage/QueueStorageTest.java index 2438936..75881c8 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/storage/QueueStorageTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/storage/QueueStorageTest.java @@ -1,32 +1,47 @@ 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.MethodSource; +import org.junit.jupiter.params.provider.EnumSource; class QueueStorageTest { - @TempDir static Path tempDir; + enum Kind { + MEMORY, + FILE; - static Stream storages() throws Exception { - return Stream.of( - new InMemoryQueueStorage(5), - new FileQueueStorage(Files.createTempDirectory(tempDir, "q"), 5)); + 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 - @MethodSource("storages") - void peeksOldestFirstWithoutRemoving(QueueStorage storage) { + @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}"); @@ -35,11 +50,13 @@ void peeksOldestFirstWithoutRemoving(QueueStorage storage) { assertThat(storage.size()).isEqualTo(3); storage.remove(entries); assertThat(storage.peek(10)).extracting(QueueEntry::json).containsExactly("{\"n\":3}"); + assertThat(storage.size()).isEqualTo(1); } @ParameterizedTest - @MethodSource("storages") - void tracksAttempts(QueueStorage storage) { + @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)); @@ -47,8 +64,22 @@ void tracksAttempts(QueueStorage storage) { } @ParameterizedTest - @MethodSource("storages") - void dropsOldestWhenFull(QueueStorage storage) { + @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 + "}"); } @@ -57,8 +88,9 @@ void dropsOldestWhenFull(QueueStorage storage) { } @ParameterizedTest - @MethodSource("storages") - void clears(QueueStorage storage) { + @EnumSource(Kind.class) + void clears(Kind kind, @TempDir Path dir) { + QueueStorage storage = kind.create(dir, 5); storage.enqueue("{}"); storage.clear(); assertThat(storage.size()).isZero(); @@ -66,72 +98,137 @@ void clears(QueueStorage storage) { } @ParameterizedTest - @MethodSource("storages") - void isThreadSafe(QueueStorage storage) throws Exception { - QueueStorage big = - storage instanceof InMemoryQueueStorage - ? new InMemoryQueueStorage(10_000) - : new FileQueueStorage(Files.createTempDirectory(tempDir, "mt"), 10_000); + @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 = big instanceof InMemoryQueueStorage ? 500 : 50; - List> futures = new ArrayList<>(); + 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++) { - big.enqueue("{}"); + storage.enqueue("{}"); if (i % 10 == 0) { - big.remove(big.peek(1)); + storage.remove(storage.peek(1)); } } return null; })); } start.countDown(); - for (var f : futures) { - f.get(30, TimeUnit.SECONDS); + 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(big.size()).isBetween(8 * perThread - 8 * (perThread / 10), 8 * perThread); + assertThat(storage.size()).isBetween(8 * perThread - 8 * (perThread / 10), 8 * perThread); } - @org.junit.jupiter.api.Test - void fileStorageSurvivesRestart() throws Exception { - Path dir = Files.createTempDirectory(tempDir, "restart"); - FileQueueStorage first = new FileQueueStorage(dir); + @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); + + FileQueueStorage second = new FileQueueStorage(dir, 10, clock); List entries = second.peek(10); assertThat(entries).extracting(QueueEntry::json).containsExactly("{\"a\":1}", "{\"a\":2}"); - assertThat(entries.get(0).attempts()).isEqualTo(1); - assertThat(entries.get(0).enqueuedAt()).isBeforeOrEqualTo(java.time.Instant.now()); + 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(); } - @org.junit.jupiter.api.Test - void fileStorageIgnoresForeignAndTempFiles() throws Exception { - Path dir = Files.createTempDirectory(tempDir, "foreign"); + @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); } - @org.junit.jupiter.api.Test - void keyValueStorage() throws Exception { - Path dir = Files.createTempDirectory(tempDir, "kv"); + @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"); - org.assertj.core.api.Assertions.assertThatThrownBy(() -> storage.write("../escape", "x")) + assertThatThrownBy(() -> storage.write("../escape", "x")) .isInstanceOf(IllegalArgumentException.class); } } From ce8b371af0d8f0ee5c59adc80e2a26c01979c162 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 19 Oct 2026 18:21:15 -0500 Subject: [PATCH 4/9] Share logging-adapter plumbing and modernize integrations and build - Add com.exceptionless.logging.LogEventSupport (exported) and use it from the Logback, Log4j 2 and JUL integrations: one client resolution/owned-client lifecycle, internal-logger/thread filter, recursion guard and event factory; only the first failure after start is reported. - Log4j 2: honour ignoreExceptions, stop(timeout), MapMessage fields. - Servlet filter: extend HttpFilter, resolve the started default client per request, register the request plugin once per client, void setter. - Starter: @ConditionalOnBooleanProperty, LogLevel/Path properties, Logback registration re-attaches after a context reset, WebFlux query params from getQueryParams(), class-presence tests, StepVerifier. - Tests: share FakeServer/TestClients via the core test-jar, Awaitility, parameterized level mapping tests. - Build/CI: requirePluginVersions, JaCoCo 0.8.15, central Automatic-Module-Name, SHA-pinned actions, release job permissions and concurrency, timeouts. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/exceptionless-java/SKILL.md | 9 +- .github/dependabot.yml | 7 +- .github/workflows/build.yml | 17 +- .github/workflows/release.yml | 16 +- .github/workflows/stale.yml | 10 +- MIGRATION.md | 2 +- README.md | 25 +- exceptionless-core/pom.xml | 35 ++ .../logging/LogEventSupport.java | 389 ++++++++++++++++++ .../exceptionless/logging/package-info.java | 10 + .../src/main/java/module-info.java | 1 + .../java/com/exceptionless/TestClients.java | 99 ++++- .../logging/LogEventSupportTest.java | 288 +++++++++++++ exceptionless-e2e-tests/pom.xml | 6 + .../com/exceptionless/e2e/support/Await.java | 66 ++- exceptionless-jul/pom.xml | 31 +- .../jul/ExceptionlessHandler.java | 242 ++++------- .../jul/CapturingSubmissionClient.java | 75 ---- .../jul/ExceptionlessHandlerTest.java | 74 +++- .../com/exceptionless/jul/FakeServer.java | 169 -------- exceptionless-log4j2/pom.xml | 28 +- .../log4j2/ExceptionlessAppender.java | 214 +++------- .../log4j2/CapturingSubmissionClient.java | 75 ---- .../log4j2/ExceptionlessAppenderTest.java | 169 +++++++- .../com/exceptionless/log4j2/FakeServer.java | 169 -------- exceptionless-logback/pom.xml | 34 +- .../logback/ExceptionlessAppender.java | 192 ++------- .../logback/CapturingSubmissionClient.java | 75 ---- .../logback/ExceptionlessAppenderTest.java | 76 +++- .../com/exceptionless/logback/FakeServer.java | 169 -------- exceptionless-servlet/pom.xml | 28 +- .../servlet/ExceptionlessFilter.java | 90 ++-- .../servlet/ServletRequestInfoFactory.java | 2 +- .../servlet/ExceptionlessFilterTest.java | 65 ++- .../servlet/RecordingSubmissionClient.java | 93 ----- exceptionless-spring-boot-starter/pom.xml | 47 ++- .../boot/ExceptionlessAutoConfiguration.java | 37 +- .../spring/boot/ExceptionlessProperties.java | 14 +- .../spring/boot/ExceptionlessWebFilter.java | 26 +- .../boot/LogbackAppenderRegistration.java | 143 +++++-- .../boot/ServerHttpRequestInfoFactory.java | 35 +- ...itional-spring-configuration-metadata.json | 16 - .../ExceptionlessAutoConfigurationTest.java | 130 +++++- .../boot/ExceptionlessWebFilterTest.java | 111 +++-- .../exceptionless/spring/boot/FakeServer.java | 169 -------- .../boot/RecordingSubmissionClient.java | 93 ----- .../spring/boot/mvc/MvcIntegrationTest.java | 53 +-- pom.xml | 48 ++- samples/console/pom.xml | 1 + .../java/com/example/console/ConsoleApp.java | 5 +- samples/spring-boot/pom.xml | 1 + .../example/springboot/OrderController.java | 25 +- 52 files changed, 1976 insertions(+), 2028 deletions(-) create mode 100644 exceptionless-core/src/main/java/com/exceptionless/logging/LogEventSupport.java create mode 100644 exceptionless-core/src/main/java/com/exceptionless/logging/package-info.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/logging/LogEventSupportTest.java delete mode 100644 exceptionless-jul/src/test/java/com/exceptionless/jul/CapturingSubmissionClient.java delete mode 100644 exceptionless-jul/src/test/java/com/exceptionless/jul/FakeServer.java delete mode 100644 exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/CapturingSubmissionClient.java delete mode 100644 exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/FakeServer.java delete mode 100644 exceptionless-logback/src/test/java/com/exceptionless/logback/CapturingSubmissionClient.java delete mode 100644 exceptionless-logback/src/test/java/com/exceptionless/logback/FakeServer.java delete mode 100644 exceptionless-servlet/src/test/java/com/exceptionless/servlet/RecordingSubmissionClient.java delete mode 100644 exceptionless-spring-boot-starter/src/main/resources/META-INF/additional-spring-configuration-metadata.json delete mode 100644 exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/FakeServer.java delete mode 100644 exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/RecordingSubmissionClient.java diff --git a/.agents/skills/exceptionless-java/SKILL.md b/.agents/skills/exceptionless-java/SKILL.md index d85f782..0db1659 100644 --- a/.agents/skills/exceptionless-java/SKILL.md +++ b/.agents/skills/exceptionless-java/SKILL.md @@ -13,7 +13,7 @@ version 2.x, Java 17+). Keep answers compact and copyable, with imports. | 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(...)` | +| 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 | @@ -28,6 +28,10 @@ Always recommend importing `com.exceptionless:exceptionless-bom` to align versio (`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. @@ -41,6 +45,8 @@ Always recommend importing `com.exceptionless:exceptionless-bom` to align versio - 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 @@ -83,5 +89,6 @@ Verify behavior in: - `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/.github/dependabot.yml b/.github/dependabot.yml index 2f496c2..d662421 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,11 +6,12 @@ updates: interval: weekly groups: test-dependencies: - patterns: ["org.junit*", "org.assertj*", "org.testcontainers*", "org.eclipse.jetty*"] + 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, servlet, Spring Boot) stay at the oldest supported - # version on purpose; consumers bring their own. + # 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:*" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ac3445..d24598e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,6 +17,7 @@ jobs: build: name: JDK ${{ matrix.java }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -29,10 +30,10 @@ jobs: java: 21 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@v6 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: temurin java-version: ${{ matrix.java }} @@ -43,7 +44,7 @@ jobs: - name: Upload test reports if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-reports-${{ matrix.os }}-jdk${{ matrix.java }} path: '**/target/surefire-reports/' @@ -51,9 +52,10 @@ jobs: spring-boot-4: name: Spring Boot 4 compatibility runs-on: ubuntu-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: temurin java-version: 21 @@ -64,10 +66,11 @@ jobs: e2e: name: End-to-end (real Exceptionless server) runs-on: ubuntu-latest + timeout-minutes: 45 if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'e2e') steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: temurin java-version: 21 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7cfaac..0babbb4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,18 +7,26 @@ on: push: tags: ['v*'] -permissions: - contents: write +permissions: {} + +# Never cancel a release that is publishing; queue the next one instead. +concurrency: + group: release + cancel-in-progress: false jobs: release: runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + # gh release create + contents: write steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up JDK - uses: actions/setup-java@v6 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: temurin java-version: 21 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 0beb0e4..cd07e5e 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -4,15 +4,17 @@ on: schedule: - cron: "30 1 * * *" -permissions: - issues: write - pull-requests: write +permissions: {} jobs: stale: runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + issues: write + pull-requests: write steps: - - uses: actions/stale@v11 + - 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." diff --git a/MIGRATION.md b/MIGRATION.md index 6d42ee4..1e273c1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -26,7 +26,7 @@ Remove any OkHttp version pins you added for the old client. | `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`). | +| `LogCapturerIF` | Use a logging integration (`exceptionless-logback`, `-log4j2`, `-jul`), or write one with `com.exceptionless.logging.LogEventSupport`. | ## Behavior changes diff --git a/README.md b/README.md index f101889..41241f3 100644 --- a/README.md +++ b/README.md @@ -102,14 +102,21 @@ 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 # log messages sent from Logback + # 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 @@ -233,8 +240,16 @@ handlers=java.util.logging.ConsoleHandler, com.exceptionless.jul.ExceptionlessHa com.exceptionless.jul.ExceptionlessHandler.level=WARNING ``` -MDC / ThreadContext values become event properties, markers become tags, and logged exceptions -become error events. +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 @@ -247,6 +262,10 @@ Unhandled exceptions and 404s are reported with the request (method, path, heade 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 ```java diff --git a/exceptionless-core/pom.xml b/exceptionless-core/pom.xml index 9ac7869..c7897a6 100644 --- a/exceptionless-core/pom.xml +++ b/exceptionless-core/pom.xml @@ -12,6 +12,10 @@ Exceptionless Core Dependency-free Exceptionless client for Java: errors, logs, feature usage, 404s and sessions. + + com.exceptionless.core + + org.jspecify @@ -28,6 +32,11 @@ assertj-core test + + org.awaitility + awaitility + test + @@ -47,6 +56,32 @@ + + + org.apache.maven.plugins + maven-jar-plugin + + + test-jar + + test-jar + + + + com/exceptionless/FakeServer*.class + com/exceptionless/TestClients*.class + + + + com.exceptionless.core.testing + + + + + + org.apache.maven.plugins maven-surefire-plugin 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..7cba15f --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/logging/LogEventSupport.java @@ -0,0 +1,389 @@ +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 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 = "exceptionless-"; + + /** 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 = trimToNull(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 = trimToNull(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); + } + } + + 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-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/module-info.java b/exceptionless-core/src/main/java/module-info.java index 5dadf60..0520e95 100644 --- a/exceptionless-core/src/main/java/module-info.java +++ b/exceptionless-core/src/main/java/module-info.java @@ -7,6 +7,7 @@ exports com.exceptionless; exports com.exceptionless.json; + exports com.exceptionless.logging; exports com.exceptionless.models; exports com.exceptionless.plugins; exports com.exceptionless.queue; diff --git a/exceptionless-core/src/test/java/com/exceptionless/TestClients.java b/exceptionless-core/src/test/java/com/exceptionless/TestClients.java index 47ea405..32f85f4 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/TestClients.java +++ b/exceptionless-core/src/test/java/com/exceptionless/TestClients.java @@ -1,20 +1,32 @@ 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. */ +/** + * 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. */ @@ -37,16 +49,33 @@ public static final class CapturingSubmissionClient implements SubmissionClient 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 + "=" + com.exceptionless.json.JsonWriter.toJson(description)); + userDescriptions.add(referenceId + "=" + JsonWriter.toJson(description)); return new SubmissionResponse(202, null, null); } @@ -71,6 +100,70 @@ public List> events() { } 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}. */ 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-e2e-tests/pom.xml b/exceptionless-e2e-tests/pom.xml index 8c8b0bc..fc6db62 100644 --- a/exceptionless-e2e-tests/pom.xml +++ b/exceptionless-e2e-tests/pom.xml @@ -17,6 +17,7 @@ + com.exceptionless.e2e 2.0.5 exceptionless/exceptionless:8.10.0 @@ -45,6 +46,11 @@ assertj-core test + + org.awaitility + awaitility + test + org.testcontainers testcontainers 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 index 6d345c1..2539c1c 100644 --- 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 @@ -1,59 +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; -/** Polls until the server has processed events (it does so asynchronously through queues). */ +/** + * 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); + value(description, timeout, condition::getAsBoolean, Boolean::booleanValue); } - /** Returns the first value matching {@code accept}, failing after {@code timeout}. */ + /** + * 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) { - long deadline = System.nanoTime() + timeout.toNanos(); - T last = null; - RuntimeException lastError = null; - long sleep = 250; - while (true) { - try { - last = supplier.get(); - lastError = null; - if (last != null && accept.test(last)) { - return last; - } - } catch (RuntimeException | AssertionError e) { - lastError = e instanceof RuntimeException re ? re : new RuntimeException(e); - } - if (System.nanoTime() > deadline) { - AssertionError error = - new AssertionError( - "Timed out after " - + timeout.toSeconds() - + "s waiting for " - + description - + "; last value: " - + last); - if (lastError != null) { - error.initCause(lastError); - } - throw error; - } - try { - Thread.sleep(sleep); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(e); - } - sleep = Math.min(2000, sleep * 2); - } + 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-jul/pom.xml b/exceptionless-jul/pom.xml index fb6f58d..0ce3c2b 100644 --- a/exceptionless-jul/pom.xml +++ b/exceptionless-jul/pom.xml @@ -13,12 +13,27 @@ Exceptionless java.util.logging java.util.logging handler that sends log messages and exceptions to Exceptionless. + + com.exceptionless.jul + + com.exceptionless exceptionless-core + + com.exceptionless + exceptionless-core + test-jar + test + + + org.awaitility + awaitility + test + org.junit.jupiter junit-jupiter @@ -30,20 +45,4 @@ test - - - - - org.apache.maven.plugins - maven-jar-plugin - - - - com.exceptionless.jul - - - - - - diff --git a/exceptionless-jul/src/main/java/com/exceptionless/jul/ExceptionlessHandler.java b/exceptionless-jul/src/main/java/com/exceptionless/jul/ExceptionlessHandler.java index 0de96fb..6f1ac2d 100644 --- a/exceptionless-jul/src/main/java/com/exceptionless/jul/ExceptionlessHandler.java +++ b/exceptionless-jul/src/main/java/com/exceptionless/jul/ExceptionlessHandler.java @@ -1,10 +1,13 @@ package com.exceptionless.jul; -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.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; @@ -51,29 +54,25 @@ * 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. + * 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. + * 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 String INTERNAL_LOGGER_PREFIX = "com.exceptionless"; - private static final String INTERNAL_THREAD_PREFIX = "exceptionless-"; - private static final ThreadLocal PUBLISHING = new ThreadLocal<>(); private static final Formatter DEFAULT_FORMATTER = new SimpleFormatter(); - private final Object clientLock = new Object(); - 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 clientFailed; - private volatile boolean closed; + 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: @@ -82,18 +81,19 @@ public final class ExceptionlessHandler extends Handler { */ 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(); - this.client = client; + support.setClient(client); } private void configure(String prefix) { LogManager manager = LogManager.getLogManager(); - apiKey = trimToNull(manager.getProperty(prefix + ".apiKey")); - serverUrl = trimToNull(manager.getProperty(prefix + ".serverUrl")); + support.setApiKey(manager.getProperty(prefix + ".apiKey")); + support.setServerUrl(manager.getProperty(prefix + ".serverUrl")); String minimum = trimToNull(manager.getProperty(prefix + ".minimumLevel")); if (minimum != null) { setMinimumLevel(minimum); @@ -123,21 +123,47 @@ private void configure(String prefix) { } } + /** + * 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) { - try { - Class loaded = Class.forName(className, true, ClassLoader.getSystemClassLoader()); - return type.cast(loaded.getDeclaredConstructor().newInstance()); - } catch (ReflectiveOperationException | RuntimeException | LinkageError e) { - reportError( - "Unable to create " + type.getSimpleName() + " " + className, - asException(e), - ErrorManager.GENERIC_FAILURE); - return null; + 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 apiKey; + return support.getApiKey(); } /** @@ -145,22 +171,20 @@ private void configure(String prefix) { * a client the handler already created. */ public void setApiKey(@Nullable String apiKey) { - this.apiKey = trimToNull(apiKey); - resetOwnedClient(); + support.setApiKey(apiKey); } public @Nullable String getServerUrl() { - return serverUrl; + return support.getServerUrl(); } /** The server URL for self hosted installs. Only used together with {@link #setApiKey}. */ public void setServerUrl(@Nullable String serverUrl) { - this.serverUrl = trimToNull(serverUrl); - resetOwnedClient(); + support.setServerUrl(serverUrl); } public LogLevel getMinimumLevel() { - return minimumLevel; + return support.getMinimumLevel(); } /** @@ -168,7 +192,7 @@ public LogLevel getMinimumLevel() { * LogLevel#WARN}). {@link LogLevel#OFF} sends only what the project settings enable. */ public void setMinimumLevel(LogLevel minimumLevel) { - this.minimumLevel = minimumLevel; + support.setMinimumLevel(minimumLevel); } /** @@ -176,15 +200,12 @@ public void setMinimumLevel(LogLevel minimumLevel) { * such as {@code warn}, {@code warning} or {@code severe}. */ public void setMinimumLevel(String minimumLevel) { - LogLevel level = LogLevel.parse(minimumLevel); - if (level == null) { + if (!support.setMinimumLevel(minimumLevel)) { reportError( - "Unknown minimumLevel '" + minimumLevel + "'; using " + this.minimumLevel.value(), + "Unknown minimumLevel '" + minimumLevel + "'; using " + support.getMinimumLevel(), null, ErrorManager.GENERIC_FAILURE); - return; } - this.minimumLevel = level; } /** @@ -193,66 +214,29 @@ public void setMinimumLevel(String minimumLevel) { * yet. */ public @Nullable ExceptionlessClient getClient() { - ExceptionlessClient explicit = client; - return explicit != null ? explicit : ownedClient; + 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) { - this.client = client; + support.setClient(client); } @Override public void publish(@Nullable LogRecord record) { - if (record == null || closed || PUBLISHING.get() != null) { - return; - } - if (isInternal(record.getLoggerName(), Thread.currentThread().getName()) - || !isLoggable(record)) { + if (record == null || !support.isStarted() || !isLoggable(record)) { return; } - PUBLISHING.set(Boolean.TRUE); - try { - ExceptionlessClient target = resolveClient(); - if (target == null) { - return; - } - String loggerName = record.getLoggerName(); - LogLevel level = toLogLevel(record.getLevel()); - if (!target.isLogLevelEnabled(loggerName, level, minimumLevel) - || !target.getConfiguration().isEnabled()) { - return; - } - createEvent(target, record, level).submit(); - } catch (RuntimeException | LinkageError e) { - reportError( - "Unable to send a log record to Exceptionless", - asException(e), - ErrorManager.WRITE_FAILURE); - } finally { - PUBLISHING.remove(); - } - } - - private EventBuilder createEvent(ExceptionlessClient target, LogRecord record, LogLevel level) { String loggerName = record.getLoggerName(); - String message = formatMessage(record); - Throwable exception = record.getThrown(); - - EventBuilder builder; - if (exception != null) { - builder = - target - .createException(exception) - .setSource(loggerName) - .setMessage(message) - .setLevel(level); - } else { - builder = target.createLog(loggerName, message, level); - } - return builder - .setSubmissionMethod(SUBMISSION_METHOD) - .setProperty("thread", Thread.currentThread().getName()); + 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) { @@ -266,62 +250,12 @@ private String formatMessage(LogRecord record) { return message != null ? message : ""; } - private @Nullable ExceptionlessClient resolveClient() { - ExceptionlessClient explicit = client; - if (explicit != null) { - return explicit; - } - String key = apiKey; - if (key == null) { - if (!Exceptionless.hasDefault()) { - return null; - } - ExceptionlessClient defaultClient = Exceptionless.getDefault(); - return defaultClient.isStarted() ? defaultClient : null; - } - ExceptionlessClient owned = ownedClient; - if (owned != null || clientFailed) { - return owned; - } - synchronized (clientLock) { - if (ownedClient == null && !clientFailed && !closed) { - 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) { - clientFailed = true; - reportError("Unable to create the Exceptionless client", e, ErrorManager.OPEN_FAILURE); - } - } - return ownedClient; - } - } - - private void resetOwnedClient() { - ExceptionlessClient owned; - synchronized (clientLock) { - owned = ownedClient; - ownedClient = null; - clientFailed = false; - } - if (owned != null) { - owned.close(); - } - } - /** Starts sending queued events, without waiting. */ @Override public void flush() { ExceptionlessClient target = getClient(); if (target != null) { - target.processQueue(); + CompletableFuture unused = target.processQueue(); } } @@ -331,22 +265,13 @@ public void flush() { */ @Override public void close() { - ExceptionlessClient owned; - synchronized (clientLock) { - closed = true; - owned = ownedClient; - ownedClient = null; - } - if (owned != null) { - try { - owned.close(); - } catch (RuntimeException e) { - reportError("Unable to close the Exceptionless client", e, ErrorManager.CLOSE_FAILURE); - } - } + support.close(); } - /** Maps a {@code java.util.logging} level to an Exceptionless level. */ + /** + * 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; @@ -367,17 +292,6 @@ static LogLevel toLogLevel(@Nullable Level level) { return LogLevel.TRACE; } - /** True for the Exceptionless client's own loggers and background threads. */ - static boolean isInternal(@Nullable String loggerName, @Nullable String threadName) { - if (threadName != null && threadName.startsWith(INTERNAL_THREAD_PREFIX)) { - return true; - } - return loggerName != null - && loggerName.startsWith(INTERNAL_LOGGER_PREFIX) - && (loggerName.length() == INTERNAL_LOGGER_PREFIX.length() - || loggerName.charAt(INTERNAL_LOGGER_PREFIX.length()) == '.'); - } - private static Exception asException(Throwable error) { return error instanceof Exception e ? e : new RuntimeException(error); } diff --git a/exceptionless-jul/src/test/java/com/exceptionless/jul/CapturingSubmissionClient.java b/exceptionless-jul/src/test/java/com/exceptionless/jul/CapturingSubmissionClient.java deleted file mode 100644 index abc6db8..0000000 --- a/exceptionless-jul/src/test/java/com/exceptionless/jul/CapturingSubmissionClient.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.exceptionless.jul; - -import com.exceptionless.ExceptionlessClient; -import com.exceptionless.ExceptionlessConfiguration; -import com.exceptionless.json.JsonParser; -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; - -/** Captures the events a client posts, parsed from the JSON it would send. */ -final class CapturingSubmissionClient implements SubmissionClient { - static final String API_KEY = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; - - private final List> events = new CopyOnWriteArrayList<>(); - - /** A started client that sends to this fake. */ - ExceptionlessClient newClient() { - return new ExceptionlessClient(newConfiguration()).startup(); - } - - ExceptionlessConfiguration newConfiguration() { - return new ExceptionlessConfiguration() - .apiKey(API_KEY) - .submissionClient(this) - .captureUncaughtExceptions(false) - .registerShutdownHook(false) - .includeModules(false); - } - - /** Sends the client's queued events and returns everything posted so far. */ - List> flush(ExceptionlessClient client) { - if (!client.flush(Duration.ofSeconds(10))) { - throw new AssertionError("Queued events were not sent"); - } - return events(); - } - - List> events() { - return new ArrayList<>(events); - } - - @Override - @SuppressWarnings("unchecked") - public SubmissionResponse postEvents(String eventsJson) { - events.addAll((List>) JsonParser.parse(eventsJson)); - return new SubmissionResponse(202, null, null); - } - - @Override - public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { - return new SubmissionResponse(202, null, null); - } - - @Override - public SettingsResponse getSettings(int version) { - return new SettingsResponse.NotModified(); - } - - @Override - public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { - return new SubmissionResponse(200, null, null); - } - - @SuppressWarnings("unchecked") - static Map data(Map event) { - Object data = event.get("data"); - return data == null ? Map.of() : (Map) data; - } -} diff --git a/exceptionless-jul/src/test/java/com/exceptionless/jul/ExceptionlessHandlerTest.java b/exceptionless-jul/src/test/java/com/exceptionless/jul/ExceptionlessHandlerTest.java index 75665f7..404dfff 100644 --- a/exceptionless-jul/src/test/java/com/exceptionless/jul/ExceptionlessHandlerTest.java +++ b/exceptionless-jul/src/test/java/com/exceptionless/jul/ExceptionlessHandlerTest.java @@ -1,13 +1,16 @@ package com.exceptionless.jul; -import static com.exceptionless.jul.CapturingSubmissionClient.data; +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; @@ -16,7 +19,9 @@ 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; @@ -26,6 +31,8 @@ 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(); @@ -81,17 +88,24 @@ 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 mapsLevels() { - assertThat(ExceptionlessHandler.toLogLevel(Level.FINEST)).isEqualTo(LogLevel.TRACE); - assertThat(ExceptionlessHandler.toLogLevel(Level.FINER)).isEqualTo(LogLevel.TRACE); - assertThat(ExceptionlessHandler.toLogLevel(Level.FINE)).isEqualTo(LogLevel.DEBUG); - assertThat(ExceptionlessHandler.toLogLevel(Level.CONFIG)).isEqualTo(LogLevel.DEBUG); - assertThat(ExceptionlessHandler.toLogLevel(Level.INFO)).isEqualTo(LogLevel.INFO); - assertThat(ExceptionlessHandler.toLogLevel(Level.WARNING)).isEqualTo(LogLevel.WARN); - assertThat(ExceptionlessHandler.toLogLevel(Level.SEVERE)).isEqualTo(LogLevel.ERROR); - assertThat(ExceptionlessHandler.toLogLevel(Level.ALL)).isEqualTo(LogLevel.TRACE); - assertThat(ExceptionlessHandler.toLogLevel(Level.OFF)).isEqualTo(LogLevel.ERROR); + void mapsAMissingLevelToInfo() { assertThat(ExceptionlessHandler.toLogLevel(null)).isEqualTo(LogLevel.INFO); } @@ -327,6 +341,36 @@ void neverThrowsForAClosedClient() { 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(); @@ -345,7 +389,7 @@ void ownsAndClosesTheClientForItsApiKey() throws Exception { try (FakeServer fake = new FakeServer()) { ExceptionlessHandler handler = new ExceptionlessHandler(); handlers.add(handler); - handler.setApiKey(CapturingSubmissionClient.API_KEY); + handler.setApiKey(TestClients.API_KEY); handler.setServerUrl(fake.url()); assertThat(handler.getClient()).isNull(); Logger logger = logger("com.example.jul.Owned", handler); @@ -373,7 +417,7 @@ void ownsAndClosesTheClientForItsApiKey() throws Exception { void changingTheApiKeyReplacesTheOwnedClient() { ExceptionlessHandler handler = new ExceptionlessHandler(); handlers.add(handler); - handler.setApiKey(CapturingSubmissionClient.API_KEY); + 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"); @@ -403,7 +447,7 @@ void readsLogManagerProperties() throws Exception { withLogManagerProperties( prefix + ".apiKey = " - + CapturingSubmissionClient.API_KEY + + TestClients.API_KEY + "\n" + prefix + ".serverUrl = http://127.0.0.1:9/\n" @@ -420,7 +464,7 @@ void readsLogManagerProperties() throws Exception { () -> { ExceptionlessHandler handler = new ExceptionlessHandler(); handlers.add(handler); - assertThat(handler.getApiKey()).isEqualTo(CapturingSubmissionClient.API_KEY); + 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); diff --git a/exceptionless-jul/src/test/java/com/exceptionless/jul/FakeServer.java b/exceptionless-jul/src/test/java/com/exceptionless/jul/FakeServer.java deleted file mode 100644 index 1bea8f3..0000000 --- a/exceptionless-jul/src/test/java/com/exceptionless/jul/FakeServer.java +++ /dev/null @@ -1,169 +0,0 @@ -package com.exceptionless.jul; - -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. */ -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-log4j2/pom.xml b/exceptionless-log4j2/pom.xml index d66d4a1..91f6a09 100644 --- a/exceptionless-log4j2/pom.xml +++ b/exceptionless-log4j2/pom.xml @@ -13,12 +13,16 @@ 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 @@ -32,6 +36,17 @@ provided + + com.exceptionless + exceptionless-core + test-jar + test + + + org.awaitility + awaitility + test + org.junit.jupiter junit-jupiter @@ -65,17 +80,6 @@ - - org.apache.maven.plugins - maven-jar-plugin - - - - com.exceptionless.log4j2 - - - - diff --git a/exceptionless-log4j2/src/main/java/com/exceptionless/log4j2/ExceptionlessAppender.java b/exceptionless-log4j2/src/main/java/com/exceptionless/log4j2/ExceptionlessAppender.java index 2750aae..ecfc164 100644 --- a/exceptionless-log4j2/src/main/java/com/exceptionless/log4j2/ExceptionlessAppender.java +++ b/exceptionless-log4j2/src/main/java/com/exceptionless/log4j2/ExceptionlessAppender.java @@ -5,6 +5,8 @@ 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; @@ -13,11 +15,14 @@ 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; @@ -42,22 +47,27 @@ * } * *

Events with an exception become error events; the rest become log events whose source is the - * logger name. Thread context (MDC) entries 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. + * 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. + * 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 and never throws. + * 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, @@ -71,19 +81,8 @@ public final class ExceptionlessAppender extends AbstractAppender { /** The submission method recorded on events sent by this appender. */ public static final String SUBMISSION_METHOD = "log4j2"; - private static final String INTERNAL_LOGGER_PREFIX = "com.exceptionless"; - private static final String INTERNAL_THREAD_PREFIX = "exceptionless-"; - private static final ThreadLocal APPENDING = new ThreadLocal<>(); - - private final @Nullable String apiKey; - private final @Nullable String serverUrl; - private final LogLevel minimumLevel; + private final LogEventSupport support = new LogEventSupport(SUBMISSION_METHOD, this::error); private final boolean includeMdc; - private final @Nullable ExceptionlessClient client; - - private final Object clientLock = new Object(); - private volatile @Nullable ExceptionlessClient ownedClient; - private volatile boolean clientFailed; private ExceptionlessAppender( String name, @@ -96,11 +95,11 @@ private ExceptionlessAppender( boolean includeMdc, @Nullable ExceptionlessClient client) { super(name, filter, null, ignoreExceptions, properties); - this.apiKey = apiKey; - this.serverUrl = serverUrl; - this.minimumLevel = minimumLevel; + support.setApiKey(apiKey); + support.setServerUrl(serverUrl); + support.setMinimumLevel(minimumLevel); + support.setClient(client); this.includeMdc = includeMdc; - this.client = client; } /** Creates a builder; used by Log4j to create the appender from configuration. */ @@ -110,15 +109,15 @@ public static > B newBuilder() { } public @Nullable String getApiKey() { - return apiKey; + return support.getApiKey(); } public @Nullable String getServerUrl() { - return serverUrl; + return support.getServerUrl(); } public LogLevel getMinimumLevel() { - return minimumLevel; + return support.getMinimumLevel(); } public boolean isIncludeMdc() { @@ -131,66 +130,50 @@ public boolean isIncludeMdc() { * anything yet. */ public @Nullable ExceptionlessClient getClient() { - ExceptionlessClient explicit = client; - return explicit != null ? explicit : ownedClient; + return support.getClient(); } @Override - public void append(LogEvent event) { - if (APPENDING.get() != null || isInternal(event.getLoggerName(), event.getThreadName())) { - return; - } - APPENDING.set(Boolean.TRUE); - try { - ExceptionlessClient target = resolveClient(); - if (target == null) { - return; - } - LogLevel level = toLogLevel(event.getLevel()); - if (!target.isLogLevelEnabled(event.getLoggerName(), level, minimumLevel) - || !target.getConfiguration().isEnabled()) { - return; - } - createEvent(target, event, level).submit(); - } catch (RuntimeException | LinkageError e) { - error("Unable to send a log event to Exceptionless", event, e); - } finally { - APPENDING.remove(); - } + public void start() { + support.start(); + super.start(); } - private EventBuilder createEvent(ExceptionlessClient target, LogEvent event, LogLevel level) { - String loggerName = event.getLoggerName(); - Message logMessage = event.getMessage(); - String message = logMessage != null ? logMessage.getFormattedMessage() : null; - if (message == null) { - message = ""; + @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); } - Throwable exception = event.getThrown(); + } - EventBuilder builder; - if (exception != null) { - builder = - target - .createException(exception) - .setSource(loggerName) - .setMessage(message) - .setLevel(level); + 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 { - builder = target.createLog(loggerName, message, level); + 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); } - builder.setSubmissionMethod(SUBMISSION_METHOD); - if (includeMdc) { ReadOnlyStringMap contextData = event.getContextData(); if (contextData != null && !contextData.isEmpty()) { contextData.forEach((key, value) -> builder.setProperty(key, value)); } } - String thread = event.getThreadName(); - if (thread != null) { - builder.setProperty("thread", thread); - } Marker marker = event.getMarker(); if (marker != null) { builder.addTags(marker.getName()); @@ -198,68 +181,23 @@ private EventBuilder createEvent(ExceptionlessClient target, LogEvent event, Log return builder; } - private @Nullable ExceptionlessClient resolveClient() { - ExceptionlessClient explicit = client; - if (explicit != null) { - return explicit; - } - String key = apiKey; - if (key == null) { - if (!Exceptionless.hasDefault()) { - return null; - } - ExceptionlessClient defaultClient = Exceptionless.getDefault(); - return defaultClient.isStarted() ? defaultClient : null; - } - ExceptionlessClient owned = ownedClient; - if (owned != null || clientFailed) { - return owned; - } - synchronized (clientLock) { - if (ownedClient == null && !clientFailed && isStarted()) { - 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) { - clientFailed = true; - error("Unable to create the Exceptionless client", e); - } - } - return ownedClient; - } - } - - /** Stops the appender and closes the client it created, sending queued events. */ + /** + * 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); - ExceptionlessClient owned; - synchronized (clientLock) { - owned = ownedClient; - ownedClient = null; - } - if (owned != null) { - try { - owned.close(); - } catch (RuntimeException e) { - error("Unable to close the Exceptionless client", e); - } - } + 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 and {@code ALL} to trace. + * 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) { @@ -284,25 +222,6 @@ static LogLevel toLogLevel(@Nullable Level level) { return LogLevel.TRACE; } - /** True for the Exceptionless client's own loggers and background threads. */ - static boolean isInternal(@Nullable String loggerName, @Nullable String threadName) { - if (threadName != null && threadName.startsWith(INTERNAL_THREAD_PREFIX)) { - return true; - } - return loggerName != null - && loggerName.startsWith(INTERNAL_LOGGER_PREFIX) - && (loggerName.length() == INTERNAL_LOGGER_PREFIX.length() - || loggerName.charAt(INTERNAL_LOGGER_PREFIX.length()) == '.'); - } - - private static @Nullable String trimToNull(@Nullable String value) { - if (value == null) { - return null; - } - String trimmed = value.trim(); - return trimmed.isEmpty() ? null : trimmed; - } - /** * Builds an {@link ExceptionlessAppender}. Besides {@code name}, {@code filter} and {@code * ignoreExceptions}, the configuration attributes are {@code apiKey}, {@code serverUrl}, {@code @@ -368,12 +287,11 @@ public B setClient(@Nullable ExceptionlessClient client) { return null; } LogLevel minimum = LogLevel.WARN; - String configured = trimToNull(minimumLevel); - if (configured != null) { - LogLevel parsed = LogLevel.parse(configured); + if (minimumLevel != null && !minimumLevel.isBlank()) { + LogLevel parsed = LogLevel.parse(minimumLevel); if (parsed == null) { LOGGER.warn( - "Unknown minimumLevel '{}' for the Exceptionless appender; using warn", configured); + "Unknown minimumLevel '{}' for the Exceptionless appender; using warn", minimumLevel); } else { minimum = parsed; } @@ -383,8 +301,8 @@ public B setClient(@Nullable ExceptionlessClient client) { getFilter(), isIgnoreExceptions(), getPropertyArray(), - trimToNull(apiKey), - trimToNull(serverUrl), + apiKey, + serverUrl, minimum, includeMdc, client); diff --git a/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/CapturingSubmissionClient.java b/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/CapturingSubmissionClient.java deleted file mode 100644 index ecacf01..0000000 --- a/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/CapturingSubmissionClient.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.exceptionless.log4j2; - -import com.exceptionless.ExceptionlessClient; -import com.exceptionless.ExceptionlessConfiguration; -import com.exceptionless.json.JsonParser; -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; - -/** Captures the events a client posts, parsed from the JSON it would send. */ -final class CapturingSubmissionClient implements SubmissionClient { - static final String API_KEY = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; - - private final List> events = new CopyOnWriteArrayList<>(); - - /** A started client that sends to this fake. */ - ExceptionlessClient newClient() { - return new ExceptionlessClient(newConfiguration()).startup(); - } - - ExceptionlessConfiguration newConfiguration() { - return new ExceptionlessConfiguration() - .apiKey(API_KEY) - .submissionClient(this) - .captureUncaughtExceptions(false) - .registerShutdownHook(false) - .includeModules(false); - } - - /** Sends the client's queued events and returns everything posted so far. */ - List> flush(ExceptionlessClient client) { - if (!client.flush(Duration.ofSeconds(10))) { - throw new AssertionError("Queued events were not sent"); - } - return events(); - } - - List> events() { - return new ArrayList<>(events); - } - - @Override - @SuppressWarnings("unchecked") - public SubmissionResponse postEvents(String eventsJson) { - events.addAll((List>) JsonParser.parse(eventsJson)); - return new SubmissionResponse(202, null, null); - } - - @Override - public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { - return new SubmissionResponse(202, null, null); - } - - @Override - public SettingsResponse getSettings(int version) { - return new SettingsResponse.NotModified(); - } - - @Override - public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { - return new SubmissionResponse(200, null, null); - } - - @SuppressWarnings("unchecked") - static Map data(Map event) { - Object data = event.get("data"); - return data == null ? Map.of() : (Map) data; - } -} diff --git a/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/ExceptionlessAppenderTest.java b/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/ExceptionlessAppenderTest.java index 2577676..3f4944b 100644 --- a/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/ExceptionlessAppenderTest.java +++ b/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/ExceptionlessAppenderTest.java @@ -1,28 +1,42 @@ package com.exceptionless.log4j2; -import static com.exceptionless.log4j2.CapturingSubmissionClient.data; +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(); @@ -66,16 +80,23 @@ 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 mapsLevels() { - assertThat(ExceptionlessAppender.toLogLevel(Level.TRACE)).isEqualTo(LogLevel.TRACE); - assertThat(ExceptionlessAppender.toLogLevel(Level.DEBUG)).isEqualTo(LogLevel.DEBUG); - assertThat(ExceptionlessAppender.toLogLevel(Level.INFO)).isEqualTo(LogLevel.INFO); - assertThat(ExceptionlessAppender.toLogLevel(Level.WARN)).isEqualTo(LogLevel.WARN); - assertThat(ExceptionlessAppender.toLogLevel(Level.ERROR)).isEqualTo(LogLevel.ERROR); - assertThat(ExceptionlessAppender.toLogLevel(Level.FATAL)).isEqualTo(LogLevel.FATAL); - assertThat(ExceptionlessAppender.toLogLevel(Level.OFF)).isEqualTo(LogLevel.FATAL); - assertThat(ExceptionlessAppender.toLogLevel(Level.ALL)).isEqualTo(LogLevel.TRACE); + void mapsCustomLevelsByIntensityAndAMissingLevelToInfo() { assertThat(ExceptionlessAppender.toLogLevel(Level.forName("NOTICE", 350))) .isEqualTo(LogLevel.INFO); assertThat(ExceptionlessAppender.toLogLevel(null)).isEqualTo(LogLevel.INFO); @@ -178,6 +199,25 @@ void addsThreadContextAndMarker() { 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(); @@ -281,13 +321,118 @@ void neverThrowsForAClosedClient() { 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(CapturingSubmissionClient.API_KEY) + .setApiKey(TestClients.API_KEY) .setServerUrl(fake.url()) .build(); Logger logger = logger("com.example.Orders", appender); @@ -329,7 +474,7 @@ void isDiscoveredByNameFromLog4j2Xml() throws Exception { assertThat(configured).isInstanceOf(ExceptionlessAppender.class); ExceptionlessAppender appender = (ExceptionlessAppender) configured; assertThat(appender.isStarted()).isTrue(); - assertThat(appender.getApiKey()).isEqualTo(CapturingSubmissionClient.API_KEY); + assertThat(appender.getApiKey()).isEqualTo(TestClients.API_KEY); assertThat(appender.getServerUrl()).isEqualTo(fake.url()); assertThat(appender.getMinimumLevel()).isEqualTo(LogLevel.INFO); assertThat(appender.isIncludeMdc()).isFalse(); diff --git a/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/FakeServer.java b/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/FakeServer.java deleted file mode 100644 index 729c5ad..0000000 --- a/exceptionless-log4j2/src/test/java/com/exceptionless/log4j2/FakeServer.java +++ /dev/null @@ -1,169 +0,0 @@ -package com.exceptionless.log4j2; - -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. */ -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-logback/pom.xml b/exceptionless-logback/pom.xml index 554c12d..14cd5ab 100644 --- a/exceptionless-logback/pom.xml +++ b/exceptionless-logback/pom.xml @@ -13,12 +13,17 @@ Exceptionless Logback Logback appender that sends log messages and exceptions to Exceptionless. + + com.exceptionless.logback + + com.exceptionless exceptionless-core - + ch.qos.logback logback-classic @@ -26,6 +31,17 @@ provided + + com.exceptionless + exceptionless-core + test-jar + test + + + org.awaitility + awaitility + test + org.junit.jupiter junit-jupiter @@ -37,20 +53,4 @@ test - - - - - org.apache.maven.plugins - maven-jar-plugin - - - - com.exceptionless.logback - - - - - - diff --git a/exceptionless-logback/src/main/java/com/exceptionless/logback/ExceptionlessAppender.java b/exceptionless-logback/src/main/java/com/exceptionless/logback/ExceptionlessAppender.java index a9a7ef7..725df80 100644 --- a/exceptionless-logback/src/main/java/com/exceptionless/logback/ExceptionlessAppender.java +++ b/exceptionless-logback/src/main/java/com/exceptionless/logback/ExceptionlessAppender.java @@ -11,6 +11,7 @@ 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; @@ -50,64 +51,52 @@ * 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. + * 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. + * Appending never blocks on the network and never throws; the first failure after the appender + * starts is reported to Logback's status manager. */ -public class ExceptionlessAppender extends UnsynchronizedAppenderBase { +public final class ExceptionlessAppender extends UnsynchronizedAppenderBase { /** The submission method recorded on events sent by this appender. */ public static final String SUBMISSION_METHOD = "logback"; - private static final String INTERNAL_LOGGER_PREFIX = "com.exceptionless"; - private static final String INTERNAL_THREAD_PREFIX = "exceptionless-"; - private static final ThreadLocal APPENDING = new ThreadLocal<>(); - - private final Object clientLock = new Object(); - private volatile @Nullable String apiKey; - private volatile @Nullable String serverUrl; - private volatile LogLevel minimumLevel = LogLevel.WARN; + private final LogEventSupport support = new LogEventSupport(SUBMISSION_METHOD, this::addError); private volatile boolean includeMdc = true; - private volatile @Nullable ExceptionlessClient client; - private volatile @Nullable ExceptionlessClient ownedClient; - private volatile boolean clientFailed; - private volatile boolean reportedFailure; public @Nullable String getApiKey() { - return apiKey; + return support.getApiKey(); } /** The project API key. When set, the appender creates and owns its own client. */ public void setApiKey(@Nullable String apiKey) { - this.apiKey = trimToNull(apiKey); + support.setApiKey(apiKey); } public @Nullable String getServerUrl() { - return serverUrl; + return support.getServerUrl(); } /** The server URL for self hosted installs. Only used together with {@link #setApiKey}. */ public void setServerUrl(@Nullable String serverUrl) { - this.serverUrl = trimToNull(serverUrl); + support.setServerUrl(serverUrl); } - public String getMinimumLevel() { - return minimumLevel.value(); + 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. + * settings enable. A String (not {@link LogLevel}) so it can be set from {@code logback.xml}. */ public void setMinimumLevel(@Nullable String minimumLevel) { - LogLevel level = LogLevel.parse(minimumLevel); - if (level == null) { - addWarn("Unknown minimumLevel '" + minimumLevel + "'; using " + this.minimumLevel.value()); - return; + if (!support.setMinimumLevel(minimumLevel)) { + addWarn("Unknown minimumLevel '" + minimumLevel + "'; using " + support.getMinimumLevel()); } - this.minimumLevel = level; } public boolean isIncludeMdc() { @@ -125,94 +114,51 @@ public void setIncludeMdc(boolean includeMdc) { * yet. */ public @Nullable ExceptionlessClient getClient() { - ExceptionlessClient explicit = client; - return explicit != null ? explicit : ownedClient; + 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) { - this.client = client; + support.setClient(client); } @Override public void start() { - clientFailed = false; + support.start(); super.start(); } + /** Stops the appender and closes the client it created, sending queued events. */ @Override public void stop() { super.stop(); - ExceptionlessClient owned; - synchronized (clientLock) { - owned = ownedClient; - ownedClient = null; - } - if (owned != null) { - try { - owned.close(); - } catch (RuntimeException e) { - addError("Unable to close the Exceptionless client", e); - } - } + support.close(); } @Override protected void append(ILoggingEvent event) { - if (APPENDING.get() != null || isInternal(event.getLoggerName(), event.getThreadName())) { - return; - } - APPENDING.set(Boolean.TRUE); - try { - ExceptionlessClient target = resolveClient(); - if (target == null) { - return; - } - String loggerName = event.getLoggerName(); - LogLevel level = toLogLevel(event.getLevel()); - if (!target.isLogLevelEnabled(loggerName, level, minimumLevel) - || !target.getConfiguration().isEnabled()) { - return; - } - createEvent(target, event, level).submit(); - } catch (RuntimeException | LinkageError e) { - if (!reportedFailure) { - reportedFailure = true; - addError("Unable to send a log event to Exceptionless", e); - } - } finally { - APPENDING.remove(); - } + LogLevel level = toLogLevel(event.getLevel()); + support.submit( + event.getLoggerName(), event.getThreadName(), level, client -> createEvent(client, event)); } - private EventBuilder createEvent( - ExceptionlessClient target, ILoggingEvent event, LogLevel level) { - String loggerName = event.getLoggerName(); - String message = event.getFormattedMessage(); - if (message == null) { - message = ""; - } + private EventBuilder createEvent(ExceptionlessClient client, ILoggingEvent event) { IThrowableProxy proxy = event.getThrowableProxy(); Throwable exception = proxy instanceof ThrowableProxy p ? p.getThrowable() : null; - - EventBuilder builder; - if (exception != null) { - builder = - target - .createException(exception) - .setSource(loggerName) - .setMessage(message) - .setLevel(level); - } else { - builder = target.createLog(loggerName, message, level); - if (proxy != null) { - // Deserialized events (e.g. from a socket appender) only carry the formatted stack trace. - builder.setProperty("exception", ThrowableProxyUtil.asString(proxy)); - } + 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)); } - builder.setSubmissionMethod(SUBMISSION_METHOD); if (includeMdc) { Map mdc = event.getMDCPropertyMap(); @@ -228,10 +174,6 @@ private EventBuilder createEvent( } } } - String thread = event.getThreadName(); - if (thread != null) { - builder.setProperty("thread", thread); - } List markers = event.getMarkerList(); if (markers != null) { for (Marker marker : markers) { @@ -243,45 +185,10 @@ private EventBuilder createEvent( return builder; } - private @Nullable ExceptionlessClient resolveClient() { - ExceptionlessClient explicit = client; - if (explicit != null) { - return explicit; - } - String key = apiKey; - if (key == null) { - if (!Exceptionless.hasDefault()) { - return null; - } - ExceptionlessClient defaultClient = Exceptionless.getDefault(); - return defaultClient.isStarted() ? defaultClient : null; - } - ExceptionlessClient owned = ownedClient; - if (owned != null || clientFailed || !isStarted()) { - return owned; - } - synchronized (clientLock) { - if (ownedClient == null && !clientFailed && isStarted()) { - 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) { - clientFailed = true; - addError("Unable to create the Exceptionless client", e); - } - } - return ownedClient; - } - } - - /** Maps a Logback level to an Exceptionless level. */ + /** + * 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; @@ -301,23 +208,4 @@ static LogLevel toLogLevel(@Nullable Level level) { } return LogLevel.TRACE; } - - /** True for the Exceptionless client's own loggers and background threads. */ - static boolean isInternal(@Nullable String loggerName, @Nullable String threadName) { - if (threadName != null && threadName.startsWith(INTERNAL_THREAD_PREFIX)) { - return true; - } - return loggerName != null - && loggerName.startsWith(INTERNAL_LOGGER_PREFIX) - && (loggerName.length() == INTERNAL_LOGGER_PREFIX.length() - || loggerName.charAt(INTERNAL_LOGGER_PREFIX.length()) == '.'); - } - - 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-logback/src/test/java/com/exceptionless/logback/CapturingSubmissionClient.java b/exceptionless-logback/src/test/java/com/exceptionless/logback/CapturingSubmissionClient.java deleted file mode 100644 index 19b313a..0000000 --- a/exceptionless-logback/src/test/java/com/exceptionless/logback/CapturingSubmissionClient.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.exceptionless.logback; - -import com.exceptionless.ExceptionlessClient; -import com.exceptionless.ExceptionlessConfiguration; -import com.exceptionless.json.JsonParser; -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; - -/** Captures the events a client posts, parsed from the JSON it would send. */ -final class CapturingSubmissionClient implements SubmissionClient { - static final String API_KEY = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; - - private final List> events = new CopyOnWriteArrayList<>(); - - /** A started client that sends to this fake. */ - ExceptionlessClient newClient() { - return new ExceptionlessClient(newConfiguration()).startup(); - } - - ExceptionlessConfiguration newConfiguration() { - return new ExceptionlessConfiguration() - .apiKey(API_KEY) - .submissionClient(this) - .captureUncaughtExceptions(false) - .registerShutdownHook(false) - .includeModules(false); - } - - /** Sends the client's queued events and returns everything posted so far. */ - List> flush(ExceptionlessClient client) { - if (!client.flush(Duration.ofSeconds(10))) { - throw new AssertionError("Queued events were not sent"); - } - return events(); - } - - List> events() { - return new ArrayList<>(events); - } - - @Override - @SuppressWarnings("unchecked") - public SubmissionResponse postEvents(String eventsJson) { - events.addAll((List>) JsonParser.parse(eventsJson)); - return new SubmissionResponse(202, null, null); - } - - @Override - public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { - return new SubmissionResponse(202, null, null); - } - - @Override - public SettingsResponse getSettings(int version) { - return new SettingsResponse.NotModified(); - } - - @Override - public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { - return new SubmissionResponse(200, null, null); - } - - @SuppressWarnings("unchecked") - static Map data(Map event) { - Object data = event.get("data"); - return data == null ? Map.of() : (Map) data; - } -} diff --git a/exceptionless-logback/src/test/java/com/exceptionless/logback/ExceptionlessAppenderTest.java b/exceptionless-logback/src/test/java/com/exceptionless/logback/ExceptionlessAppenderTest.java index ff1c19f..da11034 100644 --- a/exceptionless-logback/src/test/java/com/exceptionless/logback/ExceptionlessAppenderTest.java +++ b/exceptionless-logback/src/test/java/com/exceptionless/logback/ExceptionlessAppenderTest.java @@ -1,6 +1,6 @@ package com.exceptionless.logback; -import static com.exceptionless.logback.CapturingSubmissionClient.data; +import static com.exceptionless.TestClients.data; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; @@ -8,10 +8,15 @@ 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; @@ -19,6 +24,8 @@ 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; @@ -61,13 +68,22 @@ private Logger logger(String name, ExceptionlessAppender 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 mapsLevels() { - assertThat(ExceptionlessAppender.toLogLevel(Level.TRACE)).isEqualTo(LogLevel.TRACE); - assertThat(ExceptionlessAppender.toLogLevel(Level.DEBUG)).isEqualTo(LogLevel.DEBUG); - assertThat(ExceptionlessAppender.toLogLevel(Level.INFO)).isEqualTo(LogLevel.INFO); - assertThat(ExceptionlessAppender.toLogLevel(Level.WARN)).isEqualTo(LogLevel.WARN); - assertThat(ExceptionlessAppender.toLogLevel(Level.ERROR)).isEqualTo(LogLevel.ERROR); + void mapsAMissingLevelToInfo() { assertThat(ExceptionlessAppender.toLogLevel(null)).isEqualTo(LogLevel.INFO); } @@ -101,9 +117,9 @@ void minimumLevelIsConfigurable() { client = server.newClient(); ExceptionlessAppender appender = appender(client); appender.setMinimumLevel("info"); - assertThat(appender.getMinimumLevel()).isEqualTo("info"); + assertThat(appender.getMinimumLevel()).isEqualTo(LogLevel.INFO); appender.setMinimumLevel("bogus"); - assertThat(appender.getMinimumLevel()).isEqualTo("info"); + assertThat(appender.getMinimumLevel()).isEqualTo(LogLevel.INFO); Logger logger = logger("com.example.Orders", appender); logger.debug("debug"); @@ -297,12 +313,48 @@ void neverThrowsForAClosedClient() { 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(CapturingSubmissionClient.API_KEY); + appender.setApiKey(TestClients.API_KEY); appender.setServerUrl(fake.url()); appender.start(); assertThat(appender.getClient()).isNull(); @@ -338,9 +390,9 @@ void isConfiguredFromLogbackXml() throws Exception { context.getLogger(Logger.ROOT_LOGGER_NAME).getAppender("EXCEPTIONLESS"); assertThat(appender).isNotNull(); assertThat(appender.isStarted()).isTrue(); - assertThat(appender.getApiKey()).isEqualTo(CapturingSubmissionClient.API_KEY); + assertThat(appender.getApiKey()).isEqualTo(TestClients.API_KEY); assertThat(appender.getServerUrl()).isEqualTo(fake.url()); - assertThat(appender.getMinimumLevel()).isEqualTo("info"); + assertThat(appender.getMinimumLevel()).isEqualTo(LogLevel.INFO); assertThat(appender.isIncludeMdc()).isFalse(); org.slf4j.Logger logger = context.getLogger("com.example.Xml"); diff --git a/exceptionless-logback/src/test/java/com/exceptionless/logback/FakeServer.java b/exceptionless-logback/src/test/java/com/exceptionless/logback/FakeServer.java deleted file mode 100644 index 4748000..0000000 --- a/exceptionless-logback/src/test/java/com/exceptionless/logback/FakeServer.java +++ /dev/null @@ -1,169 +0,0 @@ -package com.exceptionless.logback; - -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. */ -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-servlet/pom.xml b/exceptionless-servlet/pom.xml index 8272e75..73e5cd2 100644 --- a/exceptionless-servlet/pom.xml +++ b/exceptionless-servlet/pom.xml @@ -14,6 +14,7 @@ Jakarta Servlet filter that reports unhandled exceptions and 404s with request details to Exceptionless. + com.exceptionless.servlet 12.1.14 @@ -33,6 +34,17 @@ provided + + com.exceptionless + exceptionless-core + test-jar + test + + + org.awaitility + awaitility + test + org.junit.jupiter junit-jupiter @@ -50,20 +62,4 @@ test - - - - - org.apache.maven.plugins - maven-jar-plugin - - - - com.exceptionless.servlet - - - - - - diff --git a/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ExceptionlessFilter.java b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ExceptionlessFilter.java index 850ea9f..9ae1b4b 100644 --- a/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ExceptionlessFilter.java +++ b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ExceptionlessFilter.java @@ -4,16 +4,14 @@ 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.Filter; import jakarta.servlet.FilterChain; -import jakarta.servlet.FilterConfig; import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpFilter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @@ -55,8 +53,10 @@ * .addMappingForUrlPatterns(null, false, "/*"); * } * - *

Without a client, the filter uses {@link Exceptionless#getDefault()}, looked up on the first - * request. Exceptions are always rethrown unchanged, so the container's error handling still runs. + *

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 @@ -64,7 +64,7 @@ * async processing don't get request details, because the request is only tracked on the container * threads that run the filter. */ -public class ExceptionlessFilter implements Filter { +public class ExceptionlessFilter extends HttpFilter { /** The {@code @submission_method} of the events reported by this filter. */ public static final String SUBMISSION_METHOD = "servletFilter"; @@ -75,20 +75,29 @@ public class ExceptionlessFilter implements Filter { private static final String HANDLED_ATTRIBUTE = ExceptionlessFilter.class.getName() + ".HANDLED"; private static final String ASYNC_ATTRIBUTE = ExceptionlessFilter.class.getName() + ".ASYNC"; - private volatile @Nullable ExceptionlessClient client; + 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()}. */ - public ExceptionlessFilter() {} + /** 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(FilterConfig filterConfig) { - String value = filterConfig.getInitParameter(CAPTURE_NOT_FOUND_PARAM); + public void init() { + String value = getInitParameter(CAPTURE_NOT_FOUND_PARAM); if (value != null && !value.isBlank()) { captureNotFound = Boolean.parseBoolean(value.trim()); } @@ -100,45 +109,47 @@ public boolean isCaptureNotFound() { } /** Turns 404 reporting on (the default) or off. */ - public ExceptionlessFilter setCaptureNotFound(boolean captureNotFound) { + public void setCaptureNotFound(boolean captureNotFound) { this.captureNotFound = captureNotFound; - return this; } - /** The client events are reported to. */ - public ExceptionlessClient getClient() { - ExceptionlessClient current = client; - if (current == null) { - current = Exceptionless.getDefault(); - client = current; - } - registerPlugin(current.getConfiguration()); - return current; + /** + * 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(); } - private static void registerPlugin(ExceptionlessConfiguration config) { - if (!config.hasPlugin(ServletRequestInfoPlugin.NAME)) { - config.addPlugin(new ServletRequestInfoPlugin()); + /** 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 - public void doFilter( - ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) + protected void doFilter( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { - if (!(servletRequest instanceof HttpServletRequest request) - || !(servletResponse instanceof HttpServletResponse response)) { - chain.doFilter(servletRequest, servletResponse); + ExceptionlessClient exceptionless = getClient(); + if (exceptionless == null) { + chain.doFilter(request, response); return; } + registerPlugin(exceptionless); - ExceptionlessClient exceptionless = getClient(); // 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, Boolean.TRUE); + request.setAttribute(HANDLED_ATTRIBUTE, true); } HttpServletRequest previous = ServletRequestInfoPlugin.setCurrentRequest(request); @@ -166,7 +177,7 @@ private void listenForAsyncCompletion( if (request.getAttribute(ASYNC_ATTRIBUTE) != null) { return; } - request.setAttribute(ASYNC_ATTRIBUTE, Boolean.TRUE); + request.setAttribute(ASYNC_ATTRIBUTE, true); try { request .getAsyncContext() @@ -223,11 +234,9 @@ private static void setUser( */ static Throwable unwrap(Throwable error) { Throwable current = error; - for (int depth = 0; - depth < 10 && current instanceof ServletException servletException; - depth++) { - Throwable cause = servletException.getRootCause(); - if (cause == null || cause == current) { + for (int depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) { + Throwable cause = current instanceof ServletException e ? e.getRootCause() : null; + if (cause == null) { break; } current = cause; @@ -235,9 +244,6 @@ static Throwable unwrap(Throwable error) { return current; } - @Override - public void destroy() {} - private final class CompletionListener implements AsyncListener { private final ExceptionlessClient exceptionless; private volatile boolean failed; diff --git a/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoFactory.java b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoFactory.java index c4509c9..e73a2c9 100644 --- a/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoFactory.java +++ b/exceptionless-servlet/src/main/java/com/exceptionless/servlet/ServletRequestInfoFactory.java @@ -121,7 +121,7 @@ static Map parseQueryString(@Nullable String queryString) { if (queryString == null || queryString.isEmpty()) { return values; } - for (String pair : queryString.split("&")) { + for (String pair : queryString.split("&", -1)) { if (pair.isEmpty()) { continue; } diff --git a/exceptionless-servlet/src/test/java/com/exceptionless/servlet/ExceptionlessFilterTest.java b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/ExceptionlessFilterTest.java index 028975c..17c1c98 100644 --- a/exceptionless-servlet/src/test/java/com/exceptionless/servlet/ExceptionlessFilterTest.java +++ b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/ExceptionlessFilterTest.java @@ -1,13 +1,14 @@ package com.exceptionless.servlet; -import static com.exceptionless.servlet.RecordingSubmissionClient.data; -import static com.exceptionless.servlet.RecordingSubmissionClient.error; -import static com.exceptionless.servlet.RecordingSubmissionClient.request; +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; @@ -27,7 +28,7 @@ import org.junit.jupiter.api.Test; class ExceptionlessFilterTest { - private final RecordingSubmissionClient recorder = new RecordingSubmissionClient(); + private final CapturingSubmissionClient recorder = new CapturingSubmissionClient(); private ExceptionlessClient client; private JettyServer server; @@ -43,7 +44,7 @@ void tearDown() throws Exception { private void start(Consumer configure, Consumer filters) throws Exception { - ExceptionlessConfiguration config = RecordingSubmissionClient.configuration(recorder); + ExceptionlessConfiguration config = recorder.newConfiguration(); configure.accept(config); client = new ExceptionlessClient(config).startup(); server = new JettyServer(); @@ -371,6 +372,60 @@ void usesTheDefaultClientWhenNoneIsGiven() throws Exception { } } + @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(); diff --git a/exceptionless-servlet/src/test/java/com/exceptionless/servlet/RecordingSubmissionClient.java b/exceptionless-servlet/src/test/java/com/exceptionless/servlet/RecordingSubmissionClient.java deleted file mode 100644 index 3f09277..0000000 --- a/exceptionless-servlet/src/test/java/com/exceptionless/servlet/RecordingSubmissionClient.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.exceptionless.servlet; - -import com.exceptionless.ExceptionlessClient; -import com.exceptionless.ExceptionlessConfiguration; -import com.exceptionless.json.JsonParser; -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; - -/** Captures the events a client posts, for tests. */ -final class RecordingSubmissionClient implements SubmissionClient { - static final String API_KEY = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; - - private final List> events = new CopyOnWriteArrayList<>(); - - static ExceptionlessConfiguration configuration(RecordingSubmissionClient submissionClient) { - return new ExceptionlessConfiguration() - .apiKey(API_KEY) - .submissionClient(submissionClient) - .captureUncaughtExceptions(false) - .registerShutdownHook(false) - .updateSettingsWhenIdleInterval(Duration.ZERO); - } - - @Override - @SuppressWarnings("unchecked") - public SubmissionResponse postEvents(String eventsJson) { - events.addAll((List>) JsonParser.parse(eventsJson)); - return new SubmissionResponse(202, null, null); - } - - @Override - public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { - return new SubmissionResponse(202, null, null); - } - - @Override - public SettingsResponse getSettings(int version) { - return new SettingsResponse.NotModified(); - } - - @Override - public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { - return new SubmissionResponse(200, null, null); - } - - List> events() { - return new ArrayList<>(events); - } - - /** Flushes the client until at least {@code count} events were posted, or fails. */ - List> awaitEvents(ExceptionlessClient client, int count) - throws InterruptedException { - long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); - while (events.size() < count) { - if (System.nanoTime() > deadline) { - throw new AssertionError("Expected " + count + " events, got " + events); - } - client.flush(Duration.ofSeconds(2)); - if (events.size() < count) { - Thread.sleep(25); - } - } - return events(); - } - - /** Flushes the client and returns everything posted so far. */ - List> flush(ExceptionlessClient client) { - client.flush(Duration.ofSeconds(5)); - return events(); - } - - @SuppressWarnings("unchecked") - static Map data(Map event) { - return (Map) event.get("data"); - } - - @SuppressWarnings("unchecked") - static Map request(Map event) { - return (Map) data(event).get("@request"); - } - - @SuppressWarnings("unchecked") - static Map error(Map event) { - return (Map) data(event).get("@error"); - } -} diff --git a/exceptionless-spring-boot-starter/pom.xml b/exceptionless-spring-boot-starter/pom.xml index 27585f0..cdfec44 100644 --- a/exceptionless-spring-boot-starter/pom.xml +++ b/exceptionless-spring-boot-starter/pom.xml @@ -18,6 +18,11 @@ application's Spring Boot version is used. The tests also run against Spring Boot 4 with -Pspring-boot-4. --> + + com.exceptionless.spring.boot + spring-boot-starter-web + + @@ -79,14 +84,31 @@ true + + com.exceptionless + exceptionless-core + test-jar + test + org.springframework.boot spring-boot-starter-test test + org.awaitility + awaitility + test + + + io.projectreactor + reactor-test + test + + + org.springframework.boot - spring-boot-starter-web + ${spring-boot-starter-webmvc.artifactId} test @@ -98,6 +120,15 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + + @{argLine} -XX:+EnableDynamicAgentLoading + + org.apache.maven.plugins maven-compiler-plugin @@ -111,17 +142,6 @@ - - org.apache.maven.plugins - maven-jar-plugin - - - - com.exceptionless.spring.boot - - - - @@ -130,8 +150,11 @@ 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 index cff8270..8ce4274 100644 --- 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 @@ -5,13 +5,12 @@ import com.exceptionless.ExceptionlessConfiguration; import com.exceptionless.servlet.ExceptionlessFilter; import java.nio.file.Path; -import java.util.Map; 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.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.info.BuildProperties; @@ -41,18 +40,14 @@ * 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. + * 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 -@ConditionalOnClass(ExceptionlessClient.class) -@ConditionalOnProperty( - prefix = "exceptionless", - name = "enabled", - havingValue = "true", - matchIfMissing = true) +@ConditionalOnBooleanProperty(name = "exceptionless.enabled", matchIfMissing = true) @EnableConfigurationProperties(ExceptionlessProperties.class) public class ExceptionlessAutoConfiguration { @@ -75,9 +70,7 @@ public ExceptionlessClient exceptionlessClient( config.registerShutdownHook(false); customizers.orderedStream().forEach(customizer -> customizer.customize(config)); - ExceptionlessClient client = new ExceptionlessClient(config); - Exceptionless.setDefault(client); - return client.startup(); + return new ExceptionlessClient(config).startup(); } static void apply(ExceptionlessProperties properties, ExceptionlessConfiguration config) { @@ -91,15 +84,15 @@ static void apply(ExceptionlessProperties properties, ExceptionlessConfiguration config.version(properties.getVersion()); } config.addDefaultTags(properties.getDefaultTags().toArray(String[]::new)); - for (Map.Entry entry : properties.getDefaultData().entrySet()) { - config.addDefaultData(entry.getKey(), entry.getValue()); - } + properties.getDefaultData().forEach(config::addDefaultData); properties.getSettings().forEach(config::setting); - if (properties.getIncludePrivateInformation() != null) { - config.includePrivateInformation(properties.getIncludePrivateInformation()); + Boolean includePrivateInformation = properties.getIncludePrivateInformation(); + if (includePrivateInformation != null) { + config.includePrivateInformation(includePrivateInformation); } - if (StringUtils.hasText(properties.getStoragePath())) { - config.useFileStorage(Path.of(properties.getStoragePath())); + Path storagePath = properties.getStoragePath(); + if (storagePath != null) { + config.useFileStorage(storagePath); } if (properties.isUseSessions()) { config.useSessions(); @@ -170,11 +163,7 @@ ExceptionlessWebFilter exceptionlessWebFilter( "ch.qos.logback.classic.LoggerContext", "com.exceptionless.logback.ExceptionlessAppender" }) - @ConditionalOnProperty( - prefix = "exceptionless.logging", - name = "enabled", - havingValue = "true", - matchIfMissing = true) + @ConditionalOnBooleanProperty(name = "exceptionless.logging.enabled", matchIfMissing = true) static class LogbackConfiguration { /** Adds the Exceptionless appender to the Logback root logger. */ @Bean 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 index 39a2a86..be66037 100644 --- 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 @@ -1,5 +1,7 @@ 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; @@ -54,7 +56,7 @@ public class ExceptionlessProperties { private @Nullable Boolean includePrivateInformation; /** A directory to persist queued events and settings in, so events survive restarts. */ - private @Nullable String storagePath; + private @Nullable Path storagePath; /** Whether to track sessions, with heartbeats. */ private boolean useSessions; @@ -137,11 +139,11 @@ public void setIncludePrivateInformation(@Nullable Boolean includePrivateInforma this.includePrivateInformation = includePrivateInformation; } - public @Nullable String getStoragePath() { + public @Nullable Path getStoragePath() { return storagePath; } - public void setStoragePath(@Nullable String storagePath) { + public void setStoragePath(@Nullable Path storagePath) { this.storagePath = storagePath; } @@ -190,7 +192,7 @@ public static class Logging { * The minimum level sent when the project has no matching @@log setting: trace, debug, info, * warn, error, fatal or off. */ - private String minimumLevel = "warn"; + private LogLevel minimumLevel = LogLevel.WARN; public boolean isEnabled() { return enabled; @@ -200,11 +202,11 @@ public void setEnabled(boolean enabled) { this.enabled = enabled; } - public String getMinimumLevel() { + public LogLevel getMinimumLevel() { return minimumLevel; } - public void setMinimumLevel(String 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 index 52decba..35e1f6a 100644 --- 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 @@ -3,7 +3,6 @@ import com.exceptionless.EventBuilder; import com.exceptionless.ExceptionlessClient; import java.security.Principal; -import java.util.Optional; import java.util.function.Function; import org.jspecify.annotations.Nullable; import org.springframework.core.Ordered; @@ -100,25 +99,22 @@ private static boolean isNotFound(@Nullable HttpStatusCode status) { return status != null && status.value() == 404; } - private Mono withUser( - ServerWebExchange exchange, Function, Mono> action) { - if (!client.getConfiguration().isIncludeUserName()) { - return Mono.defer(() -> action.apply(Optional.empty())); - } - return exchange - .getPrincipal() - .map(Principal::getName) - .map(Optional::of) - .defaultIfEmpty(Optional.empty()) - .onErrorReturn(Optional.empty()) - .flatMap(action); + /** 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, Optional user, String failure) { + EventBuilder builder, ServerWebExchange exchange, String user, String failure) { try { builder.setRequestInfo(ServerHttpRequestInfoFactory.create(exchange.getRequest())); - user.filter(name -> !name.isBlank()).ifPresent(builder::setUserIdentity); + if (!user.isBlank()) { + builder.setUserIdentity(user); + } builder.submit(); } catch (RuntimeException | LinkageError e) { LOG.log(System.Logger.Level.WARNING, failure, e); 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 index 0410877..bb32da1 100644 --- 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 @@ -1,77 +1,152 @@ 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.ILoggerFactory; 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 - * while the application context runs. 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}). + * 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. */ -public final class LogbackAppenderRegistration implements DisposableBean { +final class LogbackAppenderRegistration + implements SmartInitializingSingleton, DisposableBean, LoggerContextListener { /** The name of the appender added to the root logger. */ - public static final String APPENDER_NAME = "EXCEPTIONLESS"; + 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 @Nullable ExceptionlessAppender appender; - private final @Nullable Logger root; + private final ExceptionlessClient client; + private final LogLevel minimumLevel; + private final Object lock = new Object(); + private @Nullable LoggerContext context; + private @Nullable ExceptionlessAppender appender; - public LogbackAppenderRegistration(ExceptionlessClient client, String minimumLevel) { - ILoggerFactory factory = LoggerFactory.getILoggerFactory(); - if (!(factory instanceof LoggerContext context)) { - this.appender = null; - this.root = null; + 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; } - Logger rootLogger = context.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); - for (Iterator> it = rootLogger.iteratorForAppenders(); it.hasNext(); ) { - Appender existing = it.next(); - if (existing instanceof ExceptionlessAppender && !REGISTERED.contains(existing)) { - // Configured explicitly; leave it alone. - this.appender = null; - this.root = null; + 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 + 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 + 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(context); + added.setContext(loggerContext); added.setName(APPENDER_NAME); added.setClient(client); - added.setMinimumLevel(minimumLevel); + added.setMinimumLevel(minimumLevel.value()); added.start(); REGISTERED.add(added); - rootLogger.addAppender(added); - this.appender = added; - this.root = rootLogger; + root(loggerContext).addAppender(added); + appender = added; } - /** The appender added to the root logger, or {@code null} when none was added. */ - public @Nullable ExceptionlessAppender getAppender() { - return appender; + private void detach(LoggerContext loggerContext) { + ExceptionlessAppender added = appender; + if (added != null) { + root(loggerContext).detachAppender(added); + added.stop(); + REGISTERED.remove(added); + appender = null; + } } - @Override - public void destroy() { - if (appender != null && root != null) { - root.detachAppender(appender); - appender.stop(); - REGISTERED.remove(appender); + 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/ServerHttpRequestInfoFactory.java b/exceptionless-spring-boot-starter/src/main/java/com/exceptionless/spring/boot/ServerHttpRequestInfoFactory.java index 000e8d1..a7c8a3a 100644 --- 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 @@ -3,12 +3,9 @@ import com.exceptionless.models.RequestInfo; import java.net.InetSocketAddress; import java.net.URI; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import org.jspecify.annotations.Nullable; import org.springframework.http.HttpCookie; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpRequest; @@ -79,34 +76,16 @@ public static RequestInfo create(ServerHttpRequest request) { info.setCookies(cookies); } - Map query = parseQueryString(uri.getRawQuery()); + // 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; } - - /** URL-decoded query string names and values; the first value wins when a name repeats. */ - static Map parseQueryString(@Nullable String rawQuery) { - Map values = new LinkedHashMap<>(); - if (rawQuery == null || rawQuery.isEmpty()) { - return values; - } - for (String pair : rawQuery.split("&")) { - int equals = pair.indexOf('='); - String name = decode(equals < 0 ? pair : pair.substring(0, equals)); - if (!name.isEmpty()) { - values.putIfAbsent(name, equals < 0 ? "" : decode(pair.substring(equals + 1))); - } - } - return values; - } - - private static String decode(String value) { - try { - return URLDecoder.decode(value, StandardCharsets.UTF_8); - } catch (IllegalArgumentException e) { - return value; - } - } } diff --git a/exceptionless-spring-boot-starter/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/exceptionless-spring-boot-starter/src/main/resources/META-INF/additional-spring-configuration-metadata.json deleted file mode 100644 index ce24fc2..0000000 --- a/exceptionless-spring-boot-starter/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "hints": [ - { - "name": "exceptionless.logging.minimum-level", - "values": [ - { "value": "trace" }, - { "value": "debug" }, - { "value": "info" }, - { "value": "warn" }, - { "value": "error" }, - { "value": "fatal" }, - { "value": "off" } - ] - } - ] -} 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 index 3d59d9d..d9e9736 100644 --- 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 @@ -5,12 +5,17 @@ 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; @@ -19,26 +24,29 @@ 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 RecordingSubmissionClient recorder = new RecordingSubmissionClient(); + private final CapturingSubmissionClient recorder = new CapturingSubmissionClient(); private final ApplicationContextRunner runner = new ApplicationContextRunner() .withConfiguration(AUTO_CONFIGURATION) .withPropertyValues( - "exceptionless.api-key=" + RecordingSubmissionClient.API_KEY, + "exceptionless.api-key=" + TestClients.API_KEY, "exceptionless.capture-uncaught-exceptions=false") .withBean( "recordingCustomizer", @@ -51,9 +59,10 @@ void resetDefault() { } @Test - void createsStartedClientFromProperties() { + 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", @@ -77,7 +86,10 @@ void createsStartedClientFromProperties() { ExceptionlessClient client = context.getBean(ExceptionlessClient.class); assertThat(client.isStarted()).isTrue(); ExceptionlessConfiguration config = client.getConfiguration(); - assertThat(config.getApiKey()).isEqualTo(RecordingSubmissionClient.API_KEY); + 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"); @@ -167,8 +179,7 @@ void disabledTurnsEverythingOff() { new WebApplicationContextRunner() .withConfiguration(AUTO_CONFIGURATION) .withPropertyValues( - "exceptionless.enabled=false", - "exceptionless.api-key=" + RecordingSubmissionClient.API_KEY) + "exceptionless.enabled=false", "exceptionless.api-key=" + TestClients.API_KEY) .run( context -> { assertThat(context).doesNotHaveBean(ExceptionlessClient.class); @@ -183,7 +194,7 @@ void registersServletFilterInServletApplications() { new WebApplicationContextRunner() .withConfiguration(AUTO_CONFIGURATION) .withPropertyValues( - "exceptionless.api-key=" + RecordingSubmissionClient.API_KEY, + "exceptionless.api-key=" + TestClients.API_KEY, "exceptionless.capture-uncaught-exceptions=false", "exceptionless.capture-not-found=false", "exceptionless.filter-order=42") @@ -203,7 +214,7 @@ void servletFilterDefaultsRunAfterSpringSecurity() { new WebApplicationContextRunner() .withConfiguration(AUTO_CONFIGURATION) .withPropertyValues( - "exceptionless.api-key=" + RecordingSubmissionClient.API_KEY, + "exceptionless.api-key=" + TestClients.API_KEY, "exceptionless.capture-uncaught-exceptions=false") .run( context -> { @@ -218,7 +229,7 @@ void registersWebFilterInReactiveApplications() { new ReactiveWebApplicationContextRunner() .withConfiguration(AUTO_CONFIGURATION) .withPropertyValues( - "exceptionless.api-key=" + RecordingSubmissionClient.API_KEY, + "exceptionless.api-key=" + TestClients.API_KEY, "exceptionless.capture-uncaught-exceptions=false") .run( context -> { @@ -228,6 +239,65 @@ void registersWebFilterInReactiveApplications() { }); } + @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( @@ -248,7 +318,7 @@ void addsLogbackAppenderWhileTheContextRuns() { appender.set(context.getBean(LogbackAppenderRegistration.class).getAppender()); assertThat(appender.get()).isNotNull(); assertThat(appender.get().getClient()).isSameAs(client); - assertThat(appender.get().getMinimumLevel()).isEqualTo("info"); + assertThat(appender.get().getMinimumLevel()).isEqualTo(LogLevel.INFO); assertThat(rootAppenders()).contains(appender.get()); LoggerFactory.getLogger("com.example.Checkout") @@ -260,9 +330,7 @@ void addsLogbackAppenderWhileTheContextRuns() { List> events = recorder.flush(client); assertThat(events) .anySatisfy( - e -> - assertThat(RecordingSubmissionClient.error(e)) - .containsEntry("message", "declined")) + e -> assertThat(TestClients.error(e)).containsEntry("message", "declined")) .anySatisfy( e -> assertThat(e) @@ -274,6 +342,42 @@ void addsLogbackAppenderWhileTheContextRuns() { assertThat(appender.get().isStarted()).isFalse(); } + @Test + void addsTheAppenderAgainWhenLogbackIsReset() throws Exception { + LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + AtomicReference registration = new AtomicReference<>(); + try { + runner.run( + context -> { + ExceptionlessClient client = context.getBean(ExceptionlessClient.class); + registration.set(context.getBean(LogbackAppenderRegistration.class)); + ExceptionlessAppender original = registration.get().getAppender(); + 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); + 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"); + }); + assertThat(rootAppenders()) + .noneMatch(a -> LogbackAppenderRegistration.APPENDER_NAME.equals(a.getName())); + 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 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 index df1c871..e271651 100644 --- 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 @@ -1,13 +1,13 @@ package com.exceptionless.spring.boot; -import static com.exceptionless.spring.boot.RecordingSubmissionClient.data; -import static com.exceptionless.spring.boot.RecordingSubmissionClient.error; -import static com.exceptionless.spring.boot.RecordingSubmissionClient.request; +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.assertj.core.api.Assertions.assertThatThrownBy; 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; @@ -24,9 +24,10 @@ 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 RecordingSubmissionClient recorder = new RecordingSubmissionClient(); + private final CapturingSubmissionClient recorder = new CapturingSubmissionClient(); private ExceptionlessClient client; @AfterEach @@ -38,7 +39,7 @@ void close() { private ExceptionlessWebFilter filter( boolean captureNotFound, Consumer configure) { - ExceptionlessConfiguration config = RecordingSubmissionClient.configuration(recorder); + ExceptionlessConfiguration config = recorder.newConfiguration(); configure.accept(config); client = new ExceptionlessClient(config).startup(); return new ExceptionlessWebFilter(client, captureNotFound, 0); @@ -54,17 +55,16 @@ private static MockServerWebExchange exchange(String uri) { } @Test - void reportsErrorsWithRequestInfoAndPassesThemOn() throws Exception { + void reportsErrorsWithRequestInfoAndPassesThemOn() { ExceptionlessWebFilter filter = filter(true, c -> {}); IllegalStateException boom = new IllegalStateException("boom"); - assertThatThrownBy( - () -> - filter - .filter( - exchange("http://example.com:8080/orders?id=42&name=a%20b"), failing(boom)) - .block()) - .isSameAs(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"); @@ -78,7 +78,7 @@ void reportsErrorsWithRequestInfoAndPassesThemOn() throws Exception { .containsEntry("port", 8080L) .containsEntry("is_secure", false) .containsEntry("user_agent", "TestAgent/1.0") - .containsEntry("query_string", Map.of("id", "42", "name", "a b")) + .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"); @@ -87,17 +87,13 @@ void reportsErrorsWithRequestInfoAndPassesThemOn() throws Exception { } @Test - void reportsNotFoundErrorsAsNotFound() throws Exception { + void reportsNotFoundErrorsAsNotFound() { ExceptionlessWebFilter filter = filter(true, c -> {}); - assertThatThrownBy( - () -> - filter - .filter( - exchange("/missing"), - failing(new ResponseStatusException(HttpStatus.NOT_FOUND))) - .block()) - .isInstanceOf(ResponseStatusException.class); + 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"); @@ -105,10 +101,11 @@ void reportsNotFoundErrorsAsNotFound() throws Exception { } @Test - void reportsNotFoundResponses() throws Exception { + void reportsNotFoundResponses() { ExceptionlessWebFilter filter = filter(true, c -> {}); - filter.filter(exchange("/gone"), respondWith(HttpStatus.NOT_FOUND)).block(); + StepVerifier.create(filter.filter(exchange("/gone"), respondWith(HttpStatus.NOT_FOUND))) + .verifyComplete(); assertThat(recorder.awaitEvents(client, 1).get(0)) .containsEntry("type", "404") @@ -116,27 +113,21 @@ void reportsNotFoundResponses() throws Exception { } @Test - void ignoresClientErrorsSuccessAndDisabledNotFound() throws Exception { + void ignoresClientErrorsSuccessAndDisabledNotFound() { ExceptionlessWebFilter filter = filter(false, c -> {}); - assertThatThrownBy( - () -> - filter - .filter( - exchange("/bad"), - failing(new ResponseStatusException(HttpStatus.BAD_REQUEST))) - .block()) - .isInstanceOf(ResponseStatusException.class); - assertThatThrownBy( - () -> - filter - .filter( - exchange("/missing"), - failing(new ResponseStatusException(HttpStatus.NOT_FOUND))) - .block()) - .isInstanceOf(ResponseStatusException.class); - filter.filter(exchange("/gone"), respondWith(HttpStatus.NOT_FOUND)).block(); - filter.filter(exchange("/ok"), respondWith(HttpStatus.OK)).block(); + 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)) @@ -145,45 +136,39 @@ void ignoresClientErrorsSuccessAndDisabledNotFound() throws Exception { } @Test - void reportsServerErrorStatusExceptions() throws Exception { + void reportsServerErrorStatusExceptions() { ExceptionlessWebFilter filter = filter(true, c -> {}); - assertThatThrownBy( - () -> - filter - .filter( - exchange("/x"), - failing( - new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "down"))) - .block()) - .isInstanceOf(ResponseStatusException.class); + 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() throws Exception { + void includesSignedInUserUnlessUserNamesAreExcluded() { ExceptionlessWebFilter filter = filter(true, c -> {}); Principal alice = () -> "alice"; ServerWebExchange exchange = exchange("/x").mutate().principal(Mono.just(alice)).build(); - assertThatThrownBy( - () -> filter.filter(exchange, failing(new IllegalStateException("a"))).block()) - .isInstanceOf(IllegalStateException.class); + 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() throws Exception { + void omitsSignedInUserWhenExcluded() { ExceptionlessWebFilter filter = filter(true, c -> c.includeUserName(false)); Principal alice = () -> "alice"; ServerWebExchange exchange = exchange("/x").mutate().principal(Mono.just(alice)).build(); - assertThatThrownBy( - () -> filter.filter(exchange, failing(new IllegalStateException("a"))).block()) - .isInstanceOf(IllegalStateException.class); + StepVerifier.create(filter.filter(exchange, failing(new IllegalStateException("a")))) + .verifyError(IllegalStateException.class); assertThat(data(recorder.awaitEvents(client, 1).get(0))).doesNotContainKey("@user"); } diff --git a/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/FakeServer.java b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/FakeServer.java deleted file mode 100644 index 377cc26..0000000 --- a/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/FakeServer.java +++ /dev/null @@ -1,169 +0,0 @@ -package com.exceptionless.spring.boot; - -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-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/RecordingSubmissionClient.java b/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/RecordingSubmissionClient.java deleted file mode 100644 index 583e8ab..0000000 --- a/exceptionless-spring-boot-starter/src/test/java/com/exceptionless/spring/boot/RecordingSubmissionClient.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.exceptionless.spring.boot; - -import com.exceptionless.ExceptionlessClient; -import com.exceptionless.ExceptionlessConfiguration; -import com.exceptionless.json.JsonParser; -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; - -/** Captures the events a client posts, for tests. */ -final class RecordingSubmissionClient implements SubmissionClient { - static final String API_KEY = "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"; - - private final List> events = new CopyOnWriteArrayList<>(); - - static ExceptionlessConfiguration configuration(RecordingSubmissionClient submissionClient) { - return new ExceptionlessConfiguration() - .apiKey(API_KEY) - .submissionClient(submissionClient) - .captureUncaughtExceptions(false) - .registerShutdownHook(false) - .updateSettingsWhenIdleInterval(Duration.ZERO); - } - - @Override - @SuppressWarnings("unchecked") - public SubmissionResponse postEvents(String eventsJson) { - events.addAll((List>) JsonParser.parse(eventsJson)); - return new SubmissionResponse(202, null, null); - } - - @Override - public SubmissionResponse postUserDescription(String referenceId, UserDescription description) { - return new SubmissionResponse(202, null, null); - } - - @Override - public SettingsResponse getSettings(int version) { - return new SettingsResponse.NotModified(); - } - - @Override - public SubmissionResponse sendHeartbeat(String sessionIdOrUserId, boolean closeSession) { - return new SubmissionResponse(200, null, null); - } - - List> events() { - return new ArrayList<>(events); - } - - /** Flushes the client until at least {@code count} events were posted, or fails. */ - List> awaitEvents(ExceptionlessClient client, int count) - throws InterruptedException { - long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); - while (events.size() < count) { - if (System.nanoTime() > deadline) { - throw new AssertionError("Expected " + count + " events, got " + events); - } - client.flush(Duration.ofSeconds(2)); - if (events.size() < count) { - Thread.sleep(25); - } - } - return events(); - } - - /** Flushes the client and returns everything posted so far. */ - List> flush(ExceptionlessClient client) { - client.flush(Duration.ofSeconds(5)); - return events(); - } - - @SuppressWarnings("unchecked") - static Map data(Map event) { - return (Map) event.get("data"); - } - - @SuppressWarnings("unchecked") - static Map request(Map event) { - return (Map) data(event).get("@request"); - } - - @SuppressWarnings("unchecked") - static Map error(Map event) { - return (Map) data(event).get("@error"); - } -} 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 index e8439f5..f29029f 100644 --- 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 @@ -1,9 +1,14 @@ 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.spring.boot.FakeServer; +import com.exceptionless.FakeServer; +import com.exceptionless.TestClients; import java.io.IOException; import java.io.UncheckedIOException; import java.net.URI; @@ -13,6 +18,7 @@ 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; @@ -36,7 +42,7 @@ class MvcIntegrationTest { @DynamicPropertySource static void exceptionlessProperties(DynamicPropertyRegistry registry) { - registry.add("exceptionless.api-key", () -> "LhhP1C9gijpSKCslHHCvwdSIz298twx271nTest"); + 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"); @@ -114,19 +120,18 @@ private HttpResponse get(String path, String... headers) throws Exceptio return http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); } - private Map awaitEvent(Predicate> match) - throws InterruptedException { - long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos(); - while (System.nanoTime() < deadline) { - client.flush(Duration.ofSeconds(2)); - for (Map event : events()) { - if (match.test(event)) { - return event; - } - } - Thread.sleep(50); - } - throw new AssertionError("No matching event; got " + events()); + /** 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() { @@ -136,24 +141,6 @@ private static List> events() { .toList(); } - @SuppressWarnings("unchecked") - private static Map data(Map event) { - Object data = event.get("data"); - return data instanceof Map map ? (Map) map : Map.of(); - } - - @SuppressWarnings("unchecked") - private static Map error(Map event) { - Object error = data(event).get("@error"); - return error instanceof Map map ? (Map) map : Map.of(); - } - - @SuppressWarnings("unchecked") - private static Map request(Map event) { - Object request = data(event).get("@request"); - return request instanceof Map map ? (Map) map : Map.of(); - } - private static FakeServer startServer() { try { return new FakeServer(); diff --git a/pom.xml b/pom.xml index ffb3f2d..8ce878e 100644 --- a/pom.xml +++ b/pom.xml @@ -77,8 +77,12 @@ 5.13.4 3.27.7 + 4.3.0 - + + 3.5.0 + 3.21.0 + 3.6.4 3.14.1 3.5.4 3.4.2 @@ -90,7 +94,7 @@ 3.6.1 3.2.8 1.7.3 - 0.8.13 + 0.8.15 3.10.4 1.35.0 0.9.0 @@ -103,6 +107,14 @@ exceptionless-core ${project.version} + + + com.exceptionless + exceptionless-core + ${project.version} + test-jar + test + com.exceptionless exceptionless-jul @@ -145,6 +157,11 @@ assertj-core ${assertj.version} + + org.awaitility + awaitility + ${awaitility.version} + @@ -156,7 +173,6 @@ maven-compiler-plugin ${maven-compiler-plugin.version} - ${maven.compiler.release} -Xlint:all,-processing,-serial,-options,-missing-explicit-ctor -parameters @@ -169,13 +185,36 @@ ${maven-surefire-plugin.version} false - @{argLine} -XX:+EnableDynamicAgentLoading 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 @@ -244,6 +283,7 @@ [17,) + diff --git a/samples/console/pom.xml b/samples/console/pom.xml index e3796b8..9b08fa2 100644 --- a/samples/console/pom.xml +++ b/samples/console/pom.xml @@ -14,6 +14,7 @@ A plain Java app that reports errors, logs, feature usage and sessions. + com.example.console true true true diff --git a/samples/console/src/main/java/com/example/console/ConsoleApp.java b/samples/console/src/main/java/com/example/console/ConsoleApp.java index 8d3ab4c..b44c1ba 100644 --- a/samples/console/src/main/java/com/example/console/ConsoleApp.java +++ b/samples/console/src/main/java/com/example/console/ConsoleApp.java @@ -5,6 +5,7 @@ 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; @@ -26,7 +27,9 @@ private ConsoleApp() {} public static void main(String[] args) throws Exception { // Route java.util.logging WARNING+ records to Exceptionless (see logging.properties). - try (var config = ConsoleApp.class.getResourceAsStream("/logging.properties")) { + try (var config = + Objects.requireNonNull( + ConsoleApp.class.getResourceAsStream("/logging.properties"), "logging.properties")) { LogManager.getLogManager().readConfiguration(config); } diff --git a/samples/spring-boot/pom.xml b/samples/spring-boot/pom.xml index b2a17ea..98ecfff 100644 --- a/samples/spring-boot/pom.xml +++ b/samples/spring-boot/pom.xml @@ -14,6 +14,7 @@ A Spring Boot web app that reports errors, 404s, logs and feature usage. + com.example.springboot true true 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 index d62b4bb..738cdf9 100644 --- a/samples/spring-boot/src/main/java/com/example/springboot/OrderController.java +++ b/samples/spring-boot/src/main/java/com/example/springboot/OrderController.java @@ -4,7 +4,6 @@ import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.slf4j.MDC; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; @@ -21,21 +20,17 @@ class OrderController { @GetMapping("/orders/{id}") Map order(@PathVariable long id) { - MDC.put("orderId", Long.toString(id)); - try { - 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, with the MDC values as properties. - LOG.warn("Order {} is archived", id); - } - exceptionless.createFeatureUsage("View Order").setProperty("orderId", id).submit(); - return Map.of("id", id, "status", "shipped"); - } finally { - MDC.remove("orderId"); + 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") From 83ad2206fcfc12942cf2cf789961ca00618ed5c5 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 19 Oct 2026 18:32:19 -0500 Subject: [PATCH 5/9] Enforce Error Prone and NullAway; share the client thread name; fix the console sample - errorprone profile (JDK 21+): Error Prone + NullAway (JSpecify mode) with -Werror - LogEventSupport uses the client's thread-name constant and Strings helpers - Console sample runs in a forked JVM (exec:exec) so the JUL handler loads and crashes are reported Co-Authored-By: Claude Opus 5.5 --- .mvn/jvm.config | 10 +++++ CONTRIBUTING.md | 6 +++ .../exceptionless/ExceptionlessClient.java | 9 ++-- .../internal/DaemonThreadFactory.java | 3 ++ .../logging/LogEventSupport.java | 16 +++---- .../storage/FileQueueStorage.java | 5 ++- .../boot/LogbackAppenderRegistration.java | 2 + pom.xml | 43 +++++++++++++++++++ samples/console/pom.xml | 22 +++++++++- .../java/com/example/console/ConsoleApp.java | 9 ++-- 10 files changed, 106 insertions(+), 19 deletions(-) create mode 100644 .mvn/jvm.config 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/CONTRIBUTING.md b/CONTRIBUTING.md index 71999b5..7184901 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,12 @@ Test the Spring Boot starter against Spring Boot 4 with `-Pspring-boot-4`. 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. ## Releasing diff --git a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java index 96d2ebb..301348e 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java +++ b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessClient.java @@ -29,6 +29,7 @@ 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; @@ -101,7 +102,8 @@ private enum State { /** Creates a client. It starts automatically when first used. */ public ExceptionlessClient(ExceptionlessConfiguration configuration) { - this(configuration, Clock.systemDefaultZone()); + // 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. */ @@ -204,7 +206,8 @@ private void initialize() { queue = eventQueue; ScheduledThreadPoolExecutor executor = - new ScheduledThreadPoolExecutor(1, new DaemonThreadFactory("exceptionless")); + new ScheduledThreadPoolExecutor( + 1, new DaemonThreadFactory(DaemonThreadFactory.CLIENT_THREAD_NAME)); executor.setRemoveOnCancelPolicy(true); executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); executor.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); @@ -237,7 +240,7 @@ private void initialize() { shuttingDownFromHook = true; close(); }, - "exceptionless-shutdown"); + DaemonThreadFactory.CLIENT_THREAD_NAME + "-shutdown"); try { Runtime.getRuntime().addShutdownHook(hook); shutdownHook = hook; diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/DaemonThreadFactory.java b/exceptionless-core/src/main/java/com/exceptionless/internal/DaemonThreadFactory.java index 7a8e011..f1e06f2 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/DaemonThreadFactory.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/DaemonThreadFactory.java @@ -5,6 +5,9 @@ /** 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(); diff --git a/exceptionless-core/src/main/java/com/exceptionless/logging/LogEventSupport.java b/exceptionless-core/src/main/java/com/exceptionless/logging/LogEventSupport.java index 7cba15f..356974e 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/logging/LogEventSupport.java +++ b/exceptionless-core/src/main/java/com/exceptionless/logging/LogEventSupport.java @@ -5,6 +5,8 @@ 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; @@ -68,7 +70,7 @@ public final class LogEventSupport implements AutoCloseable { * 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 = "exceptionless-"; + 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<>(); @@ -121,7 +123,7 @@ public String getSubmissionMethod() { * 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 = trimToNull(apiKey); + String value = Strings.stripToNull(apiKey); if (!Objects.equals(value, this.apiKey)) { this.apiKey = value; resetOwnedClient(); @@ -137,7 +139,7 @@ public void setApiKey(@Nullable String apiKey) { * unset. Changing it closes a client created for the old URL. */ public void setServerUrl(@Nullable String serverUrl) { - String value = trimToNull(serverUrl); + String value = Strings.stripToNull(serverUrl); if (!Objects.equals(value, this.serverUrl)) { this.serverUrl = value; resetOwnedClient(); @@ -378,12 +380,4 @@ private void closeOwned(@Nullable ExceptionlessClient owned, @Nullable Duration errorReporter.report("Unable to close the Exceptionless client", e); } } - - 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-core/src/main/java/com/exceptionless/storage/FileQueueStorage.java b/exceptionless-core/src/main/java/com/exceptionless/storage/FileQueueStorage.java index a3fc760..ae421d9 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/storage/FileQueueStorage.java +++ b/exceptionless-core/src/main/java/com/exceptionless/storage/FileQueueStorage.java @@ -222,7 +222,10 @@ public synchronized void remove(Collection entries) { public synchronized void incrementAttempts(Collection entries) { for (QueueEntry entry : entries) { Parsed parsed = Parsed.of(entry.id()); - Stored stored = parsed != null ? index.get(parsed.key()) : null; + if (parsed == null) { + continue; + } + Stored stored = index.get(parsed.key()); if (stored == null) { continue; } 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 index bb32da1..3b5d422 100644 --- 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 @@ -83,6 +83,7 @@ public void destroy() { /** 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()) { @@ -94,6 +95,7 @@ public void onReset(LoggerContext 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) { diff --git a/pom.xml b/pom.xml index 8ce878e..c49d65f 100644 --- a/pom.xml +++ b/pom.xml @@ -78,6 +78,8 @@ 5.13.4 3.27.7 4.3.0 + 2.50.0 + 0.14.2 3.5.0 @@ -452,5 +454,46 @@ + + 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} + + + + + + + + + diff --git a/samples/console/pom.xml b/samples/console/pom.xml index 9b08fa2..0c693c5 100644 --- a/samples/console/pom.xml +++ b/samples/console/pom.xml @@ -18,7 +18,7 @@ true true true - com.example.console.ConsoleApp + @@ -31,4 +31,24 @@ 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 index b44c1ba..22c909e 100644 --- a/samples/console/src/main/java/com/example/console/ConsoleApp.java +++ b/samples/console/src/main/java/com/example/console/ConsoleApp.java @@ -16,8 +16,10 @@ *

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

    - * EXCEPTIONLESS_API_KEY=your-key ./mvnw -pl samples/console -am -q exec:java
    + * ./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 { @@ -71,7 +73,8 @@ public static void main(String[] args) throws Exception { 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"); + .updateUserEmailAndDescription(referenceId, "user@example.com", "I clicked Order twice") + .join(); } // JUL records reach Exceptionless through ExceptionlessHandler (see logging.properties). @@ -99,6 +102,6 @@ private static void placeOrder(Map order) { } private static void chargeCard(Map order) { - throw new UnsupportedOperationException("Card declined"); + throw new UnsupportedOperationException("Card declined for order " + order.get("id")); } } From 564255f5771200c2db791fc50bfd3a7bf0fbcec3 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 19 Oct 2026 22:48:55 -0500 Subject: [PATCH 6/9] Share test helpers as source instead of a published tests jar FakeServer and TestClients move to exceptionless-core/src/testFixtures/java. Modules that need them add that directory as a test source through build-helper-maven-plugin, so exceptionless-core no longer attaches a tests jar that would be deployed to Maven Central. Spotless now formats every src/*/java directory. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 4 +++ exceptionless-core/pom.xml | 26 ++------------ .../java/com/exceptionless/FakeServer.java | 0 .../java/com/exceptionless/TestClients.java | 0 exceptionless-jul/pom.xml | 15 ++++---- exceptionless-log4j2/pom.xml | 10 +++--- exceptionless-logback/pom.xml | 15 ++++---- exceptionless-servlet/pom.xml | 15 ++++---- exceptionless-spring-boot-starter/pom.xml | 10 +++--- pom.xml | 34 ++++++++++++++----- 10 files changed, 67 insertions(+), 62 deletions(-) rename exceptionless-core/src/{test => testFixtures}/java/com/exceptionless/FakeServer.java (100%) rename exceptionless-core/src/{test => testFixtures}/java/com/exceptionless/TestClients.java (100%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7184901..7c99935 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,10 @@ unless you ask for them: 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`, diff --git a/exceptionless-core/pom.xml b/exceptionless-core/pom.xml index c7897a6..b509aad 100644 --- a/exceptionless-core/pom.xml +++ b/exceptionless-core/pom.xml @@ -57,30 +57,8 @@ - - org.apache.maven.plugins - maven-jar-plugin - - - test-jar - - test-jar - - - - com/exceptionless/FakeServer*.class - com/exceptionless/TestClients*.class - - - - com.exceptionless.core.testing - - - - - + org.codehaus.mojo + build-helper-maven-plugin org.apache.maven.plugins diff --git a/exceptionless-core/src/test/java/com/exceptionless/FakeServer.java b/exceptionless-core/src/testFixtures/java/com/exceptionless/FakeServer.java similarity index 100% rename from exceptionless-core/src/test/java/com/exceptionless/FakeServer.java rename to exceptionless-core/src/testFixtures/java/com/exceptionless/FakeServer.java diff --git a/exceptionless-core/src/test/java/com/exceptionless/TestClients.java b/exceptionless-core/src/testFixtures/java/com/exceptionless/TestClients.java similarity index 100% rename from exceptionless-core/src/test/java/com/exceptionless/TestClients.java rename to exceptionless-core/src/testFixtures/java/com/exceptionless/TestClients.java diff --git a/exceptionless-jul/pom.xml b/exceptionless-jul/pom.xml index 0ce3c2b..4d276a8 100644 --- a/exceptionless-jul/pom.xml +++ b/exceptionless-jul/pom.xml @@ -23,12 +23,6 @@ exceptionless-core - - com.exceptionless - exceptionless-core - test-jar - test - org.awaitility awaitility @@ -45,4 +39,13 @@ test + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + diff --git a/exceptionless-log4j2/pom.xml b/exceptionless-log4j2/pom.xml index 91f6a09..6d81b45 100644 --- a/exceptionless-log4j2/pom.xml +++ b/exceptionless-log4j2/pom.xml @@ -36,12 +36,6 @@ provided - - com.exceptionless - exceptionless-core - test-jar - test - org.awaitility awaitility @@ -80,6 +74,10 @@ + + org.codehaus.mojo + build-helper-maven-plugin + diff --git a/exceptionless-logback/pom.xml b/exceptionless-logback/pom.xml index 14cd5ab..71857a7 100644 --- a/exceptionless-logback/pom.xml +++ b/exceptionless-logback/pom.xml @@ -31,12 +31,6 @@ provided - - com.exceptionless - exceptionless-core - test-jar - test - org.awaitility awaitility @@ -53,4 +47,13 @@ test + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + diff --git a/exceptionless-servlet/pom.xml b/exceptionless-servlet/pom.xml index 73e5cd2..b2e625b 100644 --- a/exceptionless-servlet/pom.xml +++ b/exceptionless-servlet/pom.xml @@ -34,12 +34,6 @@ provided - - com.exceptionless - exceptionless-core - test-jar - test - org.awaitility awaitility @@ -62,4 +56,13 @@ test + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + diff --git a/exceptionless-spring-boot-starter/pom.xml b/exceptionless-spring-boot-starter/pom.xml index cdfec44..6b7cf46 100644 --- a/exceptionless-spring-boot-starter/pom.xml +++ b/exceptionless-spring-boot-starter/pom.xml @@ -84,12 +84,6 @@ true - - com.exceptionless - exceptionless-core - test-jar - test - org.springframework.boot spring-boot-starter-test @@ -142,6 +136,10 @@ + + org.codehaus.mojo + build-helper-maven-plugin + diff --git a/pom.xml b/pom.xml index c49d65f..5405fcf 100644 --- a/pom.xml +++ b/pom.xml @@ -100,6 +100,7 @@ 3.10.4 1.35.0 0.9.0 + 3.6.2 @@ -109,14 +110,6 @@ exceptionless-core ${project.version} - - - com.exceptionless - exceptionless-core - ${project.version} - test-jar - test - com.exceptionless exceptionless-jul @@ -257,6 +250,28 @@ 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 @@ -353,6 +368,9 @@ ${spotless-maven-plugin.version} + + src/*/java/**/*.java + ${google-java-format.version} From dfbf7b339ee65437d090fb441e4995f2b64655ac Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 19 Oct 2026 23:01:11 -0500 Subject: [PATCH 7/9] Version and publish from git tags in one Build workflow Match the other Exceptionless builds: MinVer computes the version from v* tags and every job builds with it (-Drevision). A v* tag publishes that version to Maven Central after the tests, end-to-end tests and the Spring Boot 4 job pass; other pushes publish a preview build to GitHub Packages; pull requests only build and test. release.yml is folded into build.yml. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 164 ++++++++++++++++++++++++++++++++-- .github/workflows/release.yml | 50 ----------- CONTRIBUTING.md | 16 +++- 3 files changed, 167 insertions(+), 63 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d24598e..f70d34e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,15 @@ name: Build +# Versions come from git tags (MinVer), like the other Exceptionless builds: +# - A v* tag (v2.0.0, v2.1.0-beta.1) builds that version and publishes it to Maven Central. +# - Other pushes build a pre-release version, such as 2.0.1-preview.0.12 on main or +# 2.0.1-preview.my-branch.0.12 on a branch, and publish it to GitHub Packages. +# - 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: - branches: [main] pull_request: workflow_dispatch: @@ -11,11 +18,61 @@ permissions: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # 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 (2.x, 2.1), + # 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 "preview.${suffix}0" --minimum-major-minor 2.0)" + + # After a pre-release tag (v2.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: @@ -40,7 +97,9 @@ jobs: cache: maven - name: Build and test - run: ./mvnw -B -ntp verify + run: ./mvnw verify -Drevision="$VERSION" + env: + VERSION: ${{ needs.version.outputs.version }} - name: Upload test reports if: failure() @@ -51,29 +110,116 @@ jobs: spring-boot-4: name: Spring Boot 4 compatibility + needs: version runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + - 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 -B -ntp -pl exceptionless-spring-boot-starter -am verify -Pspring-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: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + - 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 -B -ntp -pl exceptionless-e2e-tests -am verify -De2e=true + 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/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 0babbb4..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Release - -# Publishes to Maven Central when a version tag (v2.0.0, v2.1.0-beta.1, ...) is pushed. -# Required secrets: CENTRAL_USERNAME and CENTRAL_PASSWORD (a Central Portal user token), -# GPG_PRIVATE_KEY and GPG_PASSPHRASE. -on: - push: - tags: ['v*'] - -permissions: {} - -# Never cancel a release that is publishing; queue the next one instead. -concurrency: - group: release - cancel-in-progress: false - -jobs: - release: - runs-on: ubuntu-latest - timeout-minutes: 60 - permissions: - # gh release create - contents: 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: 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 -B -ntp -Prelease -Drevision="${GITHUB_REF_NAME#v}" deploy - env: - CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} - CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} - GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - - - name: Create GitHub release - run: gh release create "$GITHUB_REF_NAME" --generate-notes --verify-tag $([[ "$GITHUB_REF_NAME" == *-* ]] && echo --prerelease) - env: - GH_TOKEN: ${{ github.token }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c99935..a2e9d9d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,15 @@ Test helpers shared between modules (`FakeServer`, `TestClients`) live in (`Duration`, `InstantSource`) instead of numbers, immutable collections from getters, and plain JavaBeans (`void` setters) for the mutable models. -## Releasing - -Push a tag like `v2.0.0`. The release workflow builds, signs and publishes every module to Maven Central -and creates a GitHub release. +## 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 `2.0.0-SNAPSHOT`; CI passes the computed version as +`-Drevision`. + +- To release, push a tag such as `v2.0.0` or `v2.1.0-beta.1`, 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: + `2.0.1-preview.0.12` on `main`, `2.0.1-preview.my-branch.0.12` on a branch. +- Pull requests build and test only. Add the `e2e` label to also run the end-to-end tests; pushes + always run them. From decda840b72dc259280290dc405567e43eefc48e Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 19 Oct 2026 23:04:49 -0500 Subject: [PATCH 8/9] Fix the Logback reset test when another test's context is cached When MvcIntegrationTest runs first, its cached application context keeps its own appender on the shared root logger, so asserting that no appender named EXCEPTIONLESS remains failed depending on test-class order (CI vs local). Assert only on the appenders this test's context added. Co-Authored-By: Claude Opus 5.5 --- .../spring/boot/ExceptionlessAutoConfigurationTest.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index d9e9736..b7d6b95 100644 --- 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 @@ -346,12 +346,15 @@ void addsLogbackAppenderWhileTheContextRuns() { 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"). @@ -359,6 +362,7 @@ void addsTheAppenderAgainWhenLogbackIsReset() throws Exception { 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); @@ -368,8 +372,8 @@ void addsTheAppenderAgainWhenLogbackIsReset() throws Exception { .extracting(e -> e.get("message")) .contains("After reset"); }); - assertThat(rootAppenders()) - .noneMatch(a -> LogbackAppenderRegistration.APPENDER_NAME.equals(a.getName())); + // 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). From 926d71c89f31d14d200e32c7265ed14fe4f2b170 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 19 Oct 2026 23:31:42 -0500 Subject: [PATCH 9/9] Target 0.9 on the way to 1.0, with Maven-friendly pre-release labels There has only been a 0.1.0 release, so the rewrite ships as 0.9 (first release v0.9.0-beta.1) ahead of 1.0. CI pre-release builds use alpha instead of preview: Maven orders unknown qualifiers after the release, so 1.0.0-preview.1 would count as newer than 1.0.0. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/exceptionless-java/SKILL.md | 2 +- .github/workflows/build.yml | 14 ++++++++------ CONTRIBUTING.md | 8 +++++--- MIGRATION.md | 8 ++++---- README.md | 6 +++--- .../com/exceptionless/internal/ClientInfo.java | 6 +++--- .../com/exceptionless/internal/ClientInfoTest.java | 2 +- pom.xml | 2 +- 8 files changed, 26 insertions(+), 22 deletions(-) diff --git a/.agents/skills/exceptionless-java/SKILL.md b/.agents/skills/exceptionless-java/SKILL.md index 0db1659..3ef6eb9 100644 --- a/.agents/skills/exceptionless-java/SKILL.md +++ b/.agents/skills/exceptionless-java/SKILL.md @@ -6,7 +6,7 @@ description: Use this skill when a developer wants to install, configure, troubl # Exceptionless Java Client Produce source-accurate setup code and guidance for the Exceptionless Java client (`com.exceptionless:*`, -version 2.x, Java 17+). Keep answers compact and copyable, with imports. +version 0.9 and later, Java 17+). Keep answers compact and copyable, with imports. ## Pick the module diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f70d34e..60b46f8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,9 +1,11 @@ name: Build # Versions come from git tags (MinVer), like the other Exceptionless builds: -# - A v* tag (v2.0.0, v2.1.0-beta.1) builds that version and publishes it to Maven Central. -# - Other pushes build a pre-release version, such as 2.0.1-preview.0.12 on main or -# 2.0.1-preview.my-branch.0.12 on a branch, and publish it to GitHub Packages. +# - 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 @@ -49,7 +51,7 @@ jobs: - name: Compute version id: version run: | - # Pre-release versions from branches other than main, maintenance branches (2.x, 2.1), + # 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 @@ -59,9 +61,9 @@ jobs: suffix="${branch:+$branch.}" dotnet tool install --global minver-cli --version 8.0.0 - version="$(minver --tag-prefix v --default-pre-release-identifiers "preview.${suffix}0" --minimum-major-minor 2.0)" + version="$(minver --tag-prefix v --default-pre-release-identifiers "alpha.${suffix}0" --minimum-major-minor 0.9)" - # After a pre-release tag (v2.1.0-beta.1) MinVer appends only the height; add the branch. + # 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2e9d9d..70c6e0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,12 +47,14 @@ Test helpers shared between modules (`FakeServer`, `TestClients`) live in ## 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 `2.0.0-SNAPSHOT`; CI passes the computed version as +other Exceptionless builds. Local builds are `0.9.0-SNAPSHOT`; CI passes the computed version as `-Drevision`. -- To release, push a tag such as `v2.0.0` or `v2.1.0-beta.1`, or create a GitHub release with one. The +- 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: - `2.0.1-preview.0.12` on `main`, `2.0.1-preview.my-branch.0.12` on a branch. + `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 index 1e273c1..42b77c3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,12 +1,12 @@ -# Migrating from 0.1 to 2.0 +# Migrating from 0.1 to 0.9 -2.0 is a rewrite. The 0.1 client sent data in a format the current Exceptionless server no longer +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 | 2.0 | +| 0.1 | 0.9 | | --- | --- | | `com.exceptionless:exceptionless-client` | `com.exceptionless:exceptionless-core` (plus integrations, see the README) | | Java 11 | Java 17 | @@ -16,7 +16,7 @@ Remove any OkHttp version pins you added for the old client. ## Packages and types -| 0.1 | 2.0 | +| 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(...)` | diff --git a/README.md b/README.md index 41241f3..03a41b1 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Use the BOM to keep module versions aligned: com.exceptionless exceptionless-bom - 2.0.0 + 0.9.0-beta.1 pom import @@ -50,7 +50,7 @@ Use the BOM to keep module versions aligned: Gradle: ```kotlin -implementation(platform("com.exceptionless:exceptionless-bom:2.0.0")) +implementation(platform("com.exceptionless:exceptionless-bom:0.9.0-beta.1")) implementation("com.exceptionless:exceptionless-core") ``` @@ -282,7 +282,7 @@ config.addEventExclusion(event -> event.isNotFound() && event.getSource().endsWi ## Upgrading from 0.1 -2.0 is a rewrite; see [MIGRATION.md](MIGRATION.md). +0.9 is a rewrite; see [MIGRATION.md](MIGRATION.md). ## Samples diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/ClientInfo.java b/exceptionless-core/src/main/java/com/exceptionless/internal/ClientInfo.java index 7747d90..2c3929c 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/ClientInfo.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/ClientInfo.java @@ -9,7 +9,7 @@ public final class ClientInfo { public static final String NAME = "exceptionless-java"; - /** The full version, e.g. {@code 2.0.0-SNAPSHOT}. */ + /** The full version, e.g. {@code 0.9.0-SNAPSHOT}. */ public static final String VERSION = loadVersion(); /** @@ -35,13 +35,13 @@ private static String loadVersion() { } catch (Exception e) { // Fall through to the default. } - return "2.0.0"; + return "0.9.0"; } static String numericVersion(String version) { Matcher matcher = Pattern.compile("^(\\d+)(\\.\\d+)?(\\.\\d+)?(\\.\\d+)?").matcher(version); if (!matcher.find()) { - return "2.0.0"; + return "0.9.0"; } String result = matcher.group(); return result.contains(".") ? result : result + ".0"; diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/ClientInfoTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/ClientInfoTest.java index d80bba1..f5cc972 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/internal/ClientInfoTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/ClientInfoTest.java @@ -15,7 +15,7 @@ class ClientInfoTest { "3, 3.0", "1.2.3.4.5, 1.2.3.4", "2.0.0-rc.1+abc, 2.0.0", - "garbage, 2.0.0" + "garbage, 0.9.0" }) void numericVersionIsParsableByTheServer(String version, String expected) { assertThat(ClientInfo.numericVersion(version)).isEqualTo(expected); diff --git a/pom.xml b/pom.xml index 5405fcf..b628817 100644 --- a/pom.xml +++ b/pom.xml @@ -59,7 +59,7 @@ - 2.0.0-SNAPSHOT + 0.9.0-SNAPSHOT 17 UTF-8 UTF-8