From 32c7d2f3f052ad7e6e03c7f30e5d690a03981c82 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 28 Oct 2026 08:07:55 -0500 Subject: [PATCH 1/3] Add deployment environments to events Events get an optional root `environment` property, separate from the `@environment` machine data. The client configuration holds a default (`environment(String)`, `exceptionless.environment`, `EXCEPTIONLESS_ENVIRONMENT`), `EventBuilder.setEnvironment` overrides it per event, and the Spring Boot starter falls back to the only active profile. Names are normalized like the server does: trimmed, and unspecified when empty, over 64 characters or containing control characters. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/exceptionless-java/SKILL.md | 6 + README.md | 24 ++ .../java/com/exceptionless/EventBuilder.java | 12 + .../ExceptionlessConfiguration.java | 87 ++++++- .../internal/DeploymentEnvironment.java | 57 +++++ .../java/com/exceptionless/models/Event.java | 21 ++ .../plugins/ConfigurationDefaultsPlugin.java | 9 +- .../exceptionless/plugins/ContextData.java | 13 ++ .../plugins/DuplicateCheckerPlugin.java | 2 +- .../exceptionless/EventEnvironmentTest.java | 214 ++++++++++++++++++ .../ExceptionlessConfigurationTest.java | 166 ++++++++++++++ .../internal/DeploymentEnvironmentTest.java | 72 ++++++ .../models/WireContractTest.java | 3 + .../plugins/DefaultPluginsTest.java | 25 ++ .../plugins/DuplicateCheckerPluginTest.java | 24 ++ .../boot/ExceptionlessAutoConfiguration.java | 22 +- .../spring/boot/ExceptionlessProperties.java | 14 ++ .../ExceptionlessAutoConfigurationTest.java | 94 ++++++++ 18 files changed, 858 insertions(+), 7 deletions(-) create mode 100644 exceptionless-core/src/main/java/com/exceptionless/internal/DeploymentEnvironment.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/EventEnvironmentTest.java create mode 100644 exceptionless-core/src/test/java/com/exceptionless/internal/DeploymentEnvironmentTest.java diff --git a/.agents/skills/exceptionless-java/SKILL.md b/.agents/skills/exceptionless-java/SKILL.md index 3ef6eb9..d50a8ee 100644 --- a/.agents/skills/exceptionless-java/SKILL.md +++ b/.agents/skills/exceptionless-java/SKILL.md @@ -32,6 +32,12 @@ Always recommend importing `com.exceptionless:exceptionless-bom` to align versio `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. +- Deployment environment: `config.environment("production")` is the default, `builder.setEnvironment("staging")` + overrides it per event. Sent as the root `environment` property, separate from `data.@environment`. + Names are trimmed (casing kept); empty, over 64 characters or with control characters are + unspecified. A null or invalid per-event name sends the event without an environment instead of the + default. Code beats `EXCEPTIONLESS_ENVIRONMENT`, `exceptionless.environment` and + `exceptionless.properties`; the Spring starter's only active profile is the last fallback. - `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. diff --git a/README.md b/README.md index 03a41b1..3d06eff 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ Add `exceptionless-spring-boot-starter` and set your API key: exceptionless: api-key: ${EXCEPTIONLESS_API_KEY} # server-url: https://exceptionless.example.com # self hosted + # environment: production # defaults to the only active profile # use-sessions: true # storage-path: /var/lib/myapp/exceptionless # persist queued events across restarts # logging: @@ -162,6 +163,7 @@ 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 + .environment("production") // deployment environment .addDefaultTags("backend") .addDefaultData("region", "us-east-1") .userIdentity("service-account") // desktop/CLI apps @@ -176,6 +178,7 @@ Exceptionless.startup(config -> config | `apiKey` | | Required. | | `serverUrl` | `https://collector.exceptionless.io` | Also sets the config and heartbeat URLs. | | `version` | jar `Implementation-Version` | Sent as `@version`. | +| `environment` | none | Deployment environment sent with every event. See below. | | `enabled` | `true` | | | `includePrivateInformation` | `true` | Master switch; `includeHeaders`, `includeCookies`, `includeIpAddress`, ... individually. | | `addDataExclusions` | | Wildcards (`*password*`). Merged with the project's `@@DataExclusions`. | @@ -191,6 +194,27 @@ Exceptionless.startup(config -> config | `registerShutdownHook` | `true` | Send queued events on JVM exit. | | `proxy`, `sslContext`, `httpClient` | | Network customization. | +### Deployment environments + +Set the deployment environment once at startup, with an optional override on each event: + +```java +Exceptionless.startup(config -> config.apiKey("YOUR_API_KEY").environment("production")); +Exceptionless.createLog("Deployment complete").setEnvironment("staging").submit(); +``` + +You can also set `environment` in `exceptionless.properties`, the `exceptionless.environment` system +property or the `EXCEPTIONLESS_ENVIRONMENT` environment variable (or `exceptionless.environment` in +Spring Boot). A value set in code takes precedence; external configuration applies only when code has +not set one. The Spring Boot starter uses the active profile only when no environment is configured +and exactly one profile is active; a configured empty or invalid value leaves events unspecified +instead. Names are trimmed and limited to 64 characters, preserving the supplied casing. The server +filters case-insensitively and normalizes aggregation keys. Missing or invalid names remain +unspecified, and an invalid or null per-event name does not fall back to the configured default. The +top-level event `environment` is separate from machine diagnostics in `data.@environment`; stacks and +fixed versions remain shared across environments. Older self hosted servers ignore the property, so +upgrade the server first. + ### Project settings These settings in your project's **Settings → Client Configuration** apply to running applications diff --git a/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java b/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java index 1f1307c..dd43ac4 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java +++ b/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java @@ -54,6 +54,18 @@ public ContextData getContextData() { return contextData; } + /** + * Overrides the deployment environment for this event, instead of the configured default. The + * name is trimmed and keeps its casing. A null or invalid name (empty, longer than 64 characters + * or with control characters) clears the environment: the event is sent without one and does not + * fall back to the default. + */ + public EventBuilder setEnvironment(@Nullable String environment) { + event.setEnvironment(environment); + contextData.setEnvironmentOverride(true); + return this; + } + public EventBuilder setType(@Nullable String type) { if (!Strings.isBlank(type)) { event.setType(type); diff --git a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java index e3c329d..2914ecb 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.DeploymentEnvironment; import com.exceptionless.internal.Exclusions; import com.exceptionless.internal.Log; import com.exceptionless.internal.Strings; @@ -82,6 +83,9 @@ public final class ExceptionlessConfiguration { private volatile boolean locked; private volatile @Nullable String version; + private final Object environmentLock = new Object(); + private volatile EnvironmentSetting environment = EnvironmentSetting.UNSET; + private final Set defaultTags = new CopyOnWriteArraySet<>(); private final Map defaultData = new ConcurrentHashMap<>(); private final Map settings = new ConcurrentHashMap<>(); @@ -134,6 +138,15 @@ public final class ExceptionlessConfiguration { private volatile boolean registerShutdownHook = true; private volatile Duration shutdownTimeout = Duration.ofSeconds(5); + /** + * The default deployment environment and where it came from. {@code configured} is true for a + * value from code or external configuration, even an invalid one that leaves events without an + * environment; only a fallback is replaced by later sources. + */ + private record EnvironmentSetting(@Nullable String name, boolean configured, boolean setInCode) { + static final EnvironmentSetting UNSET = new EnvironmentSetting(null, false, false); + } + public ExceptionlessConfiguration() { addDefaultPlugins(); } @@ -144,9 +157,13 @@ public ExceptionlessConfiguration() { * 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}. + * heartbeatServerUrl}, {@code enabled}, {@code version}, {@code environment}, {@code + * storagePath}, {@code includePrivateInformation}, {@code defaultTags} and {@code + * settings.}. Environment variables use the upper snake case form, e.g. {@code + * EXCEPTIONLESS_API_KEY} and {@code EXCEPTIONLESS_ENVIRONMENT}. + * + *

A deployment environment set in code with {@link #environment(String)} takes precedence over + * these sources, whichever is applied first. */ public static ExceptionlessConfiguration fromEnvironment() { ExceptionlessConfiguration config = new ExceptionlessConfiguration(); @@ -203,6 +220,11 @@ public ExceptionlessConfiguration readFromValues( apply(values, keyMapper, "heartbeatServerUrl", this::heartbeatServerUrl); apply(values, keyMapper, "enabled", value -> enabled(Boolean.parseBoolean(value))); apply(values, keyMapper, "version", this::version); + // An empty value is still a choice: it leaves events without an environment. + String configuredEnvironment = values.get(keyMapper.apply("environment")); + if (configuredEnvironment != null) { + applyConfiguredEnvironment(configuredEnvironment); + } apply(values, keyMapper, "storagePath", value -> useFileStorage(Path.of(value))); apply( values, @@ -379,6 +401,65 @@ public ExceptionlessConfiguration version(@Nullable String version) { return this; } + /** {@return the default deployment environment sent with events, or null when unspecified} */ + public @Nullable String getEnvironment() { + return environment.name(); + } + + /** + * Whether a deployment environment was configured in code or by external configuration. An + * invalid configured value counts: events are then sent without an environment, and {@link + * #environmentFallback(String)} has no effect. + */ + public boolean isEnvironmentConfigured() { + return environment.configured(); + } + + /** + * Sets the default deployment environment, such as {@code production} or {@code staging}, for + * events that don't set their own with {@link EventBuilder#setEnvironment}. A value set here wins + * over {@code exceptionless.properties}, system properties and environment variables, whichever + * is read last. + * + *

The name is trimmed and keeps its casing. Empty names, names longer than 64 characters and + * names with control characters leave events without an environment (and still win over external + * configuration). Pass null to forget a value set here. + */ + public ExceptionlessConfiguration environment(@Nullable String environment) { + synchronized (environmentLock) { + this.environment = + environment == null + ? EnvironmentSetting.UNSET + : new EnvironmentSetting(DeploymentEnvironment.normalize(environment), true, true); + } + return this; + } + + /** + * Sets a deployment environment that applies only when none is configured, in code or by external + * configuration. Framework integrations use it for the environment name the framework already + * has, such as a Spring profile. It has no effect once an environment is configured, and a later + * configured value replaces it. + */ + public ExceptionlessConfiguration environmentFallback(@Nullable String environment) { + synchronized (environmentLock) { + if (!this.environment.configured()) { + this.environment = + new EnvironmentSetting(DeploymentEnvironment.normalize(environment), false, false); + } + } + return this; + } + + private void applyConfiguredEnvironment(String environment) { + synchronized (environmentLock) { + if (!this.environment.setInCode()) { + this.environment = + new EnvironmentSetting(DeploymentEnvironment.normalize(environment), true, false); + } + } + } + /** {@return a snapshot of the tags added to every event} */ public Set getDefaultTags() { return Collections.unmodifiableSet(new LinkedHashSet<>(defaultTags)); diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/DeploymentEnvironment.java b/exceptionless-core/src/main/java/com/exceptionless/internal/DeploymentEnvironment.java new file mode 100644 index 0000000..f451d2a --- /dev/null +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/DeploymentEnvironment.java @@ -0,0 +1,57 @@ +package com.exceptionless.internal; + +import org.jspecify.annotations.Nullable; + +/** + * Normalizes deployment environment names the way the server does, so the client never sends a + * value the server would discard. + */ +public final class DeploymentEnvironment { + /** The longest name the server accepts. */ + public static final int MAX_LENGTH = 64; + + private DeploymentEnvironment() {} + + /** + * Trims the name and returns it with its casing preserved, or null when the server would treat it + * as unspecified: empty, longer than {@value #MAX_LENGTH} characters or containing a control + * character. + */ + public static @Nullable String normalize(@Nullable String value) { + if (value == null) { + return null; + } + int start = 0; + int end = value.length(); + while (start < end && isTrimmable(value.charAt(start))) { + start++; + } + while (end > start && isTrimmable(value.charAt(end - 1))) { + end--; + } + if (start == end || end - start > MAX_LENGTH) { + return null; + } + for (int i = start; i < end; i++) { + if (Character.isISOControl(value.charAt(i))) { + return null; + } + } + return value.substring(start, end); + } + + /** + * Whether the server's trim removes the character: white space plus the Unicode space, line and + * paragraph separators. This is wider than {@link String#strip()}, which keeps non-breaking + * spaces. + */ + private static boolean isTrimmable(char c) { + if ((c >= '\t' && c <= '\r') || c == ' ' || c == '\u0085') { + return true; + } + int type = Character.getType(c); + return type == Character.SPACE_SEPARATOR + || type == Character.LINE_SEPARATOR + || type == Character.PARAGRAPH_SEPARATOR; + } +} 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 9586565..0fbc34a 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/Event.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/Event.java @@ -1,5 +1,6 @@ package com.exceptionless.models; +import com.exceptionless.internal.DeploymentEnvironment; import com.exceptionless.json.JsonSerializable; import com.exceptionless.json.JsonWriter; import java.math.BigDecimal; @@ -18,6 +19,7 @@ * serialized when they are queued. */ public final class Event implements JsonSerializable { + private @Nullable String environment; private @Nullable String type; private @Nullable String source; private @Nullable OffsetDateTime date; @@ -29,6 +31,24 @@ public final class Event implements JsonSerializable { private final Map data = new LinkedHashMap<>(); private @Nullable String referenceId; + /** + * The deployment environment, such as {@code production} or {@code staging}. This is the event's + * root {@code environment} property, not the machine details in {@code @environment}. See {@link + * #getEnvironmentInfo()} for those. + */ + public @Nullable String getEnvironment() { + return environment; + } + + /** + * Sets the deployment environment. The name is trimmed and keeps its casing. Empty names, names + * longer than 64 characters and names with control characters are not valid and clear the + * environment, so the event is sent without one. + */ + public void setEnvironment(@Nullable String environment) { + this.environment = DeploymentEnvironment.normalize(environment); + } + public @Nullable String getType() { return type; } @@ -264,6 +284,7 @@ private void putOrRemove(String key, @Nullable Object value) { public void toJson(JsonWriter writer) { writer .beginObject() + .fieldIfNotEmpty("environment", environment) .fieldIfNotEmpty("type", type) .fieldIfNotEmpty("source", source) .field("date", date) 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 f7afd22..d2ef78e 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/ConfigurationDefaultsPlugin.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/ConfigurationDefaultsPlugin.java @@ -7,7 +7,11 @@ import java.util.Map; import java.util.function.Predicate; -/** Adds the configured default tags and data to every event. */ +/** + * Adds the configured default deployment environment, tags and data to every event. An event that + * already has an environment, or whose environment was chosen with {@link + * com.exceptionless.EventBuilder#setEnvironment}, keeps it. + */ public final class ConfigurationDefaultsPlugin implements EventPlugin { public static final int PRIORITY = 10; @@ -20,6 +24,9 @@ public int getPriority() { public void run(EventPluginContext context) { ExceptionlessConfiguration config = context.getConfiguration(); Event event = context.getEvent(); + if (event.getEnvironment() == null && !context.getContextData().hasEnvironmentOverride()) { + event.setEnvironment(config.getEnvironment()); + } event.addTags(config.getDefaultTags()); Map defaults = config.getDefaultData(); 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 d69adea..5312c99 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/ContextData.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/ContextData.java @@ -14,6 +14,7 @@ public final class ContextData { private static final String IS_UNHANDLED = "@@_IsUnhandledError"; private static final String SUBMISSION_METHOD = "@@_SubmissionMethod"; private static final String REQUEST_INFO = "@@_RequestInfo"; + private static final String ENVIRONMENT_OVERRIDE = "@@_EnvironmentOverride"; private final Map values = new LinkedHashMap<>(); @@ -60,6 +61,18 @@ public ContextData setSubmissionMethod(@Nullable String submissionMethod) { return put(SUBMISSION_METHOD, submissionMethod); } + /** + * Whether the event's deployment environment was chosen for this event, even if it was cleared, + * so the configured default must not replace it. + */ + public boolean hasEnvironmentOverride() { + return Boolean.TRUE.equals(values.get(ENVIRONMENT_OVERRIDE)); + } + + public ContextData setEnvironmentOverride(boolean override) { + return put(ENVIRONMENT_OVERRIDE, override ? true : null); + } + public @Nullable RequestInfo getRequestInfo() { return values.get(REQUEST_INFO) instanceof RequestInfo request ? request : null; } 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 a8dd21f..6f0b115 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/DuplicateCheckerPlugin.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/DuplicateCheckerPlugin.java @@ -130,7 +130,7 @@ private static int countOf(Event event) { } static int hash(Event event) { - int hash = Objects.hash(event.getType(), event.getSource()); + int hash = Objects.hash(event.getEnvironment(), event.getType(), event.getSource()); ErrorInfo error = event.getError(); while (error != null) { hash = 31 * hash + Objects.hashCode(error.getType()); diff --git a/exceptionless-core/src/test/java/com/exceptionless/EventEnvironmentTest.java b/exceptionless-core/src/test/java/com/exceptionless/EventEnvironmentTest.java new file mode 100644 index 0000000..ab723e1 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/EventEnvironmentTest.java @@ -0,0 +1,214 @@ +package com.exceptionless; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.exceptionless.TestClients.CapturingSubmissionClient; +import com.exceptionless.json.JsonWriter; +import com.exceptionless.models.Event; +import java.time.Duration; +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.ValueSource; + +/** The deployment environment: the root {@code environment} property of an event. */ +class EventEnvironmentTest { + 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 appliesTheConfiguredDefaultToEveryEventType() { + config.environment(" Production "); + + client.submitLog("log"); + client.submitException(new IllegalStateException("boom")); + client.submitFeatureUsage("feature"); + client.submitNotFound("/missing"); + + assertThat(sent()) + .hasSize(4) + .allSatisfy(e -> assertThat(e).containsEntry("environment", "Production")); + } + + @Test + void sendsEventsWithoutAnEnvironmentWhenNoneIsConfigured() { + client.submitLog("log"); + + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e).doesNotContainKey("environment")); + } + + @Test + void builderOverridesTheDefault() { + config.environment("production"); + + client.createLog("deploy").setEnvironment(" Staging ").submit(); + client.createLog("other").submit(); + + assertThat(sent()) + .extracting(e -> e.get("environment")) + .containsExactlyInAnyOrder("Staging", "production"); + } + + @Test + void builderOverrideWorksWithoutADefault() { + client.createLog("deploy").setEnvironment("staging").submit(); + + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e).containsEntry("environment", "staging")); + } + + @Test + void nullOverrideSendsTheEventUnspecifiedInsteadOfTheDefault() { + config.environment("production"); + + client.createLog("cleared").setEnvironment("staging").setEnvironment(null).submit(); + + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e).doesNotContainKey("environment")); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "stag\ning"}) + void invalidOverrideSendsTheEventUnspecifiedInsteadOfTheDefault(String invalid) { + config.environment("production"); + + client.createLog("invalid").setEnvironment(invalid).submit(); + + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e).doesNotContainKey("environment")); + } + + @Test + void tooLongOverrideSendsTheEventUnspecifiedInsteadOfTheDefault() { + config.environment("production"); + + client.createLog("long").setEnvironment("x".repeat(65)).submit(); + client.createLog("max").setEnvironment("x".repeat(64)).submit(); + + assertThat(sent()) + .extracting(e -> e.get("environment")) + .containsExactlyInAnyOrder(null, "x".repeat(64)); + } + + @Test + void invalidDefaultSendsEventsUnspecified() { + config.environment("x".repeat(65)); + + client.submitLog("log"); + + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e).doesNotContainKey("environment")); + } + + @Test + void anEventThatAlreadyHasAnEnvironmentKeepsIt() { + config.environment("production"); + Event event = new Event(); + event.setType("log"); + event.setEnvironment("qa"); + + client.submitEvent(event, null); + + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e).containsEntry("environment", "qa")); + } + + @Test + void pluginsThatSetAnInvalidEnvironmentStillProduceAValidPayload() { + config.environment("production"); + config.addPlugin("late", 5000, ctx -> ctx.getEvent().setEnvironment("bad\u0000value")); + + client.submitLog("plugin"); + + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e).doesNotContainKey("environment")); + } + + @Test + void pluginsThatSetAPaddedEnvironmentSendItTrimmed() { + config.addPlugin("late", 5000, ctx -> ctx.getEvent().setEnvironment(" Qa ")); + + client.submitLog("plugin"); + + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e).containsEntry("environment", "Qa")); + } + + @Test + void submittingHandlersCannotQueueAnInvalidEnvironment() { + client.addSubmittingEventHandler( + submitting -> submitting.getEvent().setEnvironment("x".repeat(100))); + + client.submitLog("handler"); + + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e).doesNotContainKey("environment")); + } + + @Test + void isSeparateFromTheMachineEnvironmentData() { + config.environment("production"); + + client.submitLog("log"); + + Map event = sent().get(0); + assertThat(event).containsEntry("environment", "production"); + // @environment keeps describing the machine, never the deployment environment. + assertThat(TestClients.data(event)).containsKey("@environment"); + assertThat(TestClients.path(event, "data.@environment.o_s_name")).isNotNull(); + assertThat(TestClients.data(event).get("@environment")).isNotEqualTo("production"); + } + + @Test + void serializesTheEnvironmentAtTheEventRoot() { + Event event = new Event(); + event.setType("log"); + event.setEnvironment("production"); + + assertThat(JsonWriter.toJson(event)).contains("\"environment\":\"production\""); + } + + @Test + void omitsTheEnvironmentWhenUnset() { + Event event = new Event(); + event.setType("log"); + assertThat(JsonWriter.toJson(event)).doesNotContain("environment"); + + event.setEnvironment("production"); + event.setEnvironment(null); + assertThat(JsonWriter.toJson(event)).doesNotContain("environment"); + } + + @Test + void eventSetterNormalizes() { + Event event = new Event(); + event.setEnvironment(" Production "); + assertThat(event.getEnvironment()).isEqualTo("Production"); + event.setEnvironment("x".repeat(65)); + assertThat(event.getEnvironment()).isNull(); + } +} diff --git a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java index 75ed94d..bddb596 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java @@ -134,8 +134,174 @@ void readsValuesFromMaps() { assertThat(config.getSettings().getString("FEATURE_X")).isEqualTo("on"); } + @Test + void environmentIsUnspecifiedByDefault() { + ExceptionlessConfiguration config = new ExceptionlessConfiguration(); + assertThat(config.getEnvironment()).isNull(); + assertThat(config.isEnvironmentConfigured()).isFalse(); + } + + @Test + void environmentIsNormalizedAndKeepsCasing() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration().environment(" Production "); + assertThat(config.getEnvironment()).isEqualTo("Production"); + assertThat(config.isEnvironmentConfigured()).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "prod\nuction"}) + void invalidEnvironmentIsUnspecifiedButConfigured(String value) { + ExceptionlessConfiguration config = new ExceptionlessConfiguration().environment(value); + assertThat(config.getEnvironment()).isNull(); + assertThat(config.isEnvironmentConfigured()).isTrue(); + } + + @Test + void environmentLongerThanSixtyFourCharactersIsUnspecified() { + assertThat(new ExceptionlessConfiguration().environment("x".repeat(64)).getEnvironment()) + .hasSize(64); + assertThat(new ExceptionlessConfiguration().environment("x".repeat(65)).getEnvironment()) + .isNull(); + } + + @Test + void environmentSetInCodeBeatsExternalConfigurationReadAfterwards() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration() + .environment("code") + .readFromValues( + Map.of("EXCEPTIONLESS_ENVIRONMENT", "external"), + ExceptionlessConfiguration::environmentName); + assertThat(config.getEnvironment()).isEqualTo("code"); + } + + @Test + void environmentSetInCodeBeatsExternalConfigurationReadBefore() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration() + .readFromValues( + Map.of("EXCEPTIONLESS_ENVIRONMENT", "external"), + ExceptionlessConfiguration::environmentName) + .environment("code"); + assertThat(config.getEnvironment()).isEqualTo("code"); + } + + @Test + void invalidEnvironmentSetInCodeStillBeatsExternalConfiguration() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration() + .environment(" ") + .readFromValues( + Map.of("EXCEPTIONLESS_ENVIRONMENT", "external"), + ExceptionlessConfiguration::environmentName); + assertThat(config.getEnvironment()).isNull(); + assertThat(config.isEnvironmentConfigured()).isTrue(); + } + + @Test + void externalConfigurationAppliesWhenCodeHasNotSetAnEnvironment() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration() + .readFromValues( + Map.of("EXCEPTIONLESS_ENVIRONMENT", " Staging "), + ExceptionlessConfiguration::environmentName); + assertThat(config.getEnvironment()).isEqualTo("Staging"); + assertThat(config.isEnvironmentConfigured()).isTrue(); + } + + @Test + void laterExternalSourcesReplaceEarlierOnes() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration() + .readFromValues( + Map.of("exceptionless.environment", "file"), key -> "exceptionless." + key) + .readFromValues( + Map.of("EXCEPTIONLESS_ENVIRONMENT", "variable"), + ExceptionlessConfiguration::environmentName); + assertThat(config.getEnvironment()).isEqualTo("variable"); + } + + @Test + void emptyExternalEnvironmentLeavesEventsUnspecified() { + ExceptionlessConfiguration config = + new ExceptionlessConfiguration() + .readFromValues( + Map.of("EXCEPTIONLESS_ENVIRONMENT", " "), + ExceptionlessConfiguration::environmentName) + .environmentFallback("fallback"); + assertThat(config.getEnvironment()).isNull(); + assertThat(config.isEnvironmentConfigured()).isTrue(); + } + + @Test + void clearingTheEnvironmentInCodeLetsExternalConfigurationApplyAgain() { + ExceptionlessConfiguration config = new ExceptionlessConfiguration().environment("code"); + config.environment(null); + assertThat(config.isEnvironmentConfigured()).isFalse(); + config.readFromValues( + Map.of("EXCEPTIONLESS_ENVIRONMENT", "external"), + ExceptionlessConfiguration::environmentName); + assertThat(config.getEnvironment()).isEqualTo("external"); + } + + @Test + void environmentFallbackOnlyAppliesWhenNothingIsConfigured() { + ExceptionlessConfiguration config = new ExceptionlessConfiguration().environmentFallback("dev"); + assertThat(config.getEnvironment()).isEqualTo("dev"); + assertThat(config.isEnvironmentConfigured()).isFalse(); + + config.environment("code").environmentFallback("other"); + assertThat(config.getEnvironment()).isEqualTo("code"); + + ExceptionlessConfiguration external = + new ExceptionlessConfiguration() + .environmentFallback("dev") + .readFromValues( + Map.of("EXCEPTIONLESS_ENVIRONMENT", "external"), + ExceptionlessConfiguration::environmentName) + .environmentFallback("dev"); + assertThat(external.getEnvironment()).isEqualTo("external"); + } + + @Test + void invalidFallbackLeavesEnvironmentUnspecified() { + assertThat( + new ExceptionlessConfiguration().environmentFallback("x".repeat(65)).getEnvironment()) + .isNull(); + assertThat(new ExceptionlessConfiguration().environmentFallback(null).getEnvironment()) + .isNull(); + } + + @Test + void readsTheEnvironmentFromSystemProperties() { + System.setProperty("exceptionless.environment", "from-system-property"); + try { + assertThat(ExceptionlessConfiguration.fromEnvironment().getEnvironment()) + .isEqualTo("from-system-property"); + } finally { + System.clearProperty("exceptionless.environment"); + } + } + + @Test + void systemPropertiesDoNotOverrideAnEnvironmentSetInCode() { + System.setProperty("exceptionless.environment", "from-system-property"); + try { + assertThat( + new ExceptionlessConfiguration() + .environment("code") + .readFromEnvironment() + .getEnvironment()) + .isEqualTo("code"); + } finally { + System.clearProperty("exceptionless.environment"); + } + } + @ParameterizedTest @CsvSource({ + "environment, EXCEPTIONLESS_ENVIRONMENT", "apiKey, EXCEPTIONLESS_API_KEY", "serverUrl, EXCEPTIONLESS_SERVER_URL", "settings., EXCEPTIONLESS_SETTINGS_", diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/DeploymentEnvironmentTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/DeploymentEnvironmentTest.java new file mode 100644 index 0000000..2000945 --- /dev/null +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/DeploymentEnvironmentTest.java @@ -0,0 +1,72 @@ +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; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class DeploymentEnvironmentTest { + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "production|production", + " Production |Production", + "Prod West|Prod West", + }) + void trimsSurroundingWhiteSpaceAndKeepsCasing(String value, String expected) { + assertThat(DeploymentEnvironment.normalize(value)).isEqualTo(expected); + } + + @Test + void trimsControlWhiteSpaceAndUnicodeSpaces() { + // The server trims non-breaking and Unicode spaces too, which String.strip() keeps. + assertThat(DeploymentEnvironment.normalize("\tProduction\n")).isEqualTo("Production"); + assertThat(DeploymentEnvironment.normalize("\u00a0Production\u00a0")).isEqualTo("Production"); + assertThat(DeploymentEnvironment.normalize("\u2003Production\u2028")).isEqualTo("Production"); + assertThat(DeploymentEnvironment.normalize("\u0085Production")).isEqualTo("Production"); + } + + @Test + void keepsInnerCasingAndSpacing() { + assertThat(DeploymentEnvironment.normalize(" Prod West ")).isEqualTo("Prod West"); + assertThat(DeploymentEnvironment.normalize(" QA ")).isEqualTo("QA"); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " ", "\t\n", " ", " 
"}) + void emptyIsUnspecified(String value) { + assertThat(DeploymentEnvironment.normalize(value)).isNull(); + } + + @Test + void allowsSixtyFourCharactersAndRejectsSixtyFive() { + assertThat(DeploymentEnvironment.normalize("x".repeat(64))).isEqualTo("x".repeat(64)); + assertThat(DeploymentEnvironment.normalize("x".repeat(65))).isNull(); + } + + @Test + void measuresTheLengthAfterTrimming() { + assertThat(DeploymentEnvironment.normalize(" " + "x".repeat(64) + " ")) + .isEqualTo("x".repeat(64)); + assertThat(DeploymentEnvironment.normalize(" " + "x".repeat(65) + " ")).isNull(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "prod\ninvalid", + "prod\tinvalid", + "pro\u0000d", + "prod\u007f", + "pro\u0085d", + "a\u009fb" + }) + void controlCharactersAreUnspecified(String value) { + assertThat(DeploymentEnvironment.normalize(value)).isNull(); + } +} 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 85a1e3e..c38c153 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/models/WireContractTest.java @@ -21,6 +21,7 @@ class WireContractTest { private static final Set EVENT = Set.of( + "environment", "type", "source", "date", @@ -114,6 +115,7 @@ void everyPropertyExistsOnTheServerModel() { .setUserIdentity("id", "name") .setUserDescription("a@b.c", "d") .setManualStackingKey("key", "title") + .setEnvironment("production") .setGeo(1, 2) .setValue(3) .setCount(2) @@ -153,6 +155,7 @@ void everyPropertyExistsOnTheServerModel() { assertThat(environment.get("process_id")).isInstanceOf(String.class); assertThat(environment.get("thread_id")).isInstanceOf(String.class); assertThat(event.get("count")).isEqualTo(2L); + assertThat(event.get("environment")).isEqualTo("production"); 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)"); 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 b4d9930..bfd2475 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/plugins/DefaultPluginsTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/DefaultPluginsTest.java @@ -72,6 +72,31 @@ void configurationDefaultsAddTagsAndDataWithoutOverwriting() { .doesNotContainKey("password"); } + @Test + void configurationDefaultsAddTheEnvironmentWithoutOverwritingIt() { + config.environment(" Production "); + Event event = new Event(); + PluginTestSupport.run(new ConfigurationDefaultsPlugin(), client, event); + assertThat(event.getEnvironment()).isEqualTo("Production"); + + Event own = new Event(); + own.setEnvironment("qa"); + PluginTestSupport.run(new ConfigurationDefaultsPlugin(), client, own); + assertThat(own.getEnvironment()).isEqualTo("qa"); + } + + @Test + void configurationDefaultsKeepAnEnvironmentThatWasClearedOnPurpose() { + config.environment("production"); + Event event = new Event(); + PluginTestSupport.run( + new ConfigurationDefaultsPlugin(), + client, + event, + new ContextData().setEnvironmentOverride(true)); + assertThat(event.getEnvironment()).isNull(); + } + @Test void configurationDefaultsNormalizeObjects() { config.addDefaultData("info", Map.of("nested", List.of(1, 2))); 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 9ee2059..d4a59d0 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/plugins/DuplicateCheckerPluginTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/DuplicateCheckerPluginTest.java @@ -44,6 +44,30 @@ private static List sameErrors(int count) { return events; } + @Test + void sameErrorInDifferentEnvironmentsIsNotMerged() { + plugin.startup(client); + List events = sameErrors(4); + events.get(0).setEnvironment("production"); + events.get(1).setEnvironment("staging"); + events.get(2).setEnvironment("production"); + // events.get(3) has no environment. + assertThat(PluginTestSupport.run(plugin, client, events.get(0)).isCancelled()).isFalse(); + assertThat(PluginTestSupport.run(plugin, client, events.get(1)).isCancelled()).isFalse(); + assertThat(PluginTestSupport.run(plugin, client, events.get(3)).isCancelled()).isFalse(); + // The same error in an environment that was already seen is a duplicate. + assertThat(PluginTestSupport.run(plugin, client, events.get(2)).isCancelled()).isTrue(); + plugin.flush(); + client.flush(Duration.ofSeconds(5)); + assertThat(submission.events()) + .singleElement() + .satisfies( + e -> { + assertThat(e.get("environment")).isEqualTo("production"); + assertThat(e.get("count")).isEqualTo(1L); + }); + } + @Test void countsDuplicatesWithinTheWindowAndSendsOneMergedEvent() { plugin.startup(client); 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 8ce4274..04b15a6 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,6 +5,7 @@ import com.exceptionless.ExceptionlessConfiguration; import com.exceptionless.servlet.ExceptionlessFilter; import java.nio.file.Path; +import org.jspecify.annotations.Nullable; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -16,6 +17,7 @@ import org.springframework.boot.info.BuildProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; import org.springframework.util.StringUtils; /** @@ -29,7 +31,8 @@ * *

    *
  • an {@link ExceptionlessClient} bean, configured from the {@link ExceptionlessProperties - * exceptionless.*} properties and any {@link ExceptionlessConfigurationCustomizer} beans. It + * exceptionless.*} properties and any {@link ExceptionlessConfigurationCustomizer} beans. + * When no deployment environment is configured, the only active Spring profile is used. 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; @@ -57,7 +60,8 @@ public class ExceptionlessAutoConfiguration { public ExceptionlessClient exceptionlessClient( ExceptionlessProperties properties, ObjectProvider customizers, - ObjectProvider buildProperties) { + ObjectProvider buildProperties, + Environment environment) { ExceptionlessConfiguration config = ExceptionlessConfiguration.fromEnvironment(); apply(properties, config); if (config.getVersion() == null) { @@ -69,10 +73,20 @@ public ExceptionlessClient exceptionlessClient( // The application context closes the client. config.registerShutdownHook(false); customizers.orderedStream().forEach(customizer -> customizer.customize(config)); + config.environmentFallback(onlyActiveProfile(environment)); return new ExceptionlessClient(config).startup(); } + /** + * The active profile when there is exactly one. Several profiles don't name a deployment + * environment (such as {@code prod,metrics}), so none is guessed. + */ + static @Nullable String onlyActiveProfile(Environment environment) { + String[] profiles = environment.getActiveProfiles(); + return profiles.length == 1 ? profiles[0] : null; + } + static void apply(ExceptionlessProperties properties, ExceptionlessConfiguration config) { if (StringUtils.hasText(properties.getApiKey())) { config.apiKey(properties.getApiKey()); @@ -83,6 +97,10 @@ static void apply(ExceptionlessProperties properties, ExceptionlessConfiguration if (StringUtils.hasText(properties.getVersion())) { config.version(properties.getVersion()); } + // An empty value is a choice too: it leaves events without an environment. + if (properties.getEnvironment() != null) { + config.environment(properties.getEnvironment()); + } config.addDefaultTags(properties.getDefaultTags().toArray(String[]::new)); properties.getDefaultData().forEach(config::addDefaultData); properties.getSettings().forEach(config::setting); 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 be66037..14c9438 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 @@ -36,6 +36,12 @@ public class ExceptionlessProperties { /** The application version. Defaults to the version from build-info (BuildProperties). */ private @Nullable String version; + /** + * The default deployment environment, such as production or staging. When unset, the only active + * Spring profile is used. + */ + private @Nullable String environment; + /** Tags added to every event. */ private List defaultTags = new ArrayList<>(); @@ -107,6 +113,14 @@ public void setVersion(@Nullable String version) { this.version = version; } + public @Nullable String getEnvironment() { + return environment; + } + + public void setEnvironment(@Nullable String environment) { + this.environment = environment; + } + public List getDefaultTags() { return defaultTags; } 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 b7d6b95..dfa5a53 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 @@ -32,6 +32,7 @@ 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.context.ApplicationContext; import org.springframework.core.annotation.Order; import org.springframework.core.env.MapPropertySource; import reactor.core.publisher.Mono; @@ -135,6 +136,99 @@ void usesBuildVersionWhenNoVersionIsSet() { .isEqualTo("1.0.0")); } + private static String environmentOf(ApplicationContext context) { + return context.getBean(ExceptionlessClient.class).getConfiguration().getEnvironment(); + } + + @Test + void usesTheEnvironmentProperty() { + runner + .withPropertyValues("exceptionless.environment= Staging ") + .run(context -> assertThat(environmentOf(context)).isEqualTo("Staging")); + } + + @Test + void leavesTheEnvironmentUnspecifiedWithoutPropertyOrProfile() { + runner.run( + context -> { + assertThat(environmentOf(context)).isNull(); + assertThat( + context + .getBean(ExceptionlessClient.class) + .getConfiguration() + .isEnvironmentConfigured()) + .isFalse(); + }); + } + + @Test + void fallsBackToTheOnlyActiveProfile() { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod")) + .run(context -> assertThat(environmentOf(context)).isEqualTo("prod")); + } + + @Test + void doesNotGuessAnEnvironmentFromSeveralProfiles() { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod", "metrics")) + .run(context -> assertThat(environmentOf(context)).isNull()); + } + + @Test + void environmentPropertyBeatsTheActiveProfile() { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues("exceptionless.environment=staging") + .run(context -> assertThat(environmentOf(context)).isEqualTo("staging")); + } + + @Test + void emptyEnvironmentPropertyLeavesEventsUnspecifiedInsteadOfUsingTheProfile() { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues("exceptionless.environment=") + .run(context -> assertThat(environmentOf(context)).isNull()); + } + + @Test + void externalEnvironmentConfigurationBeatsTheActiveProfile() { + System.setProperty("exceptionless.environment", "from-system-property"); + try { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod")) + .run(context -> assertThat(environmentOf(context)).isEqualTo("from-system-property")); + } finally { + System.clearProperty("exceptionless.environment"); + } + } + + @Test + void customizersBeatThePropertyAndTheProfile() { + runner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("prod")) + .withPropertyValues("exceptionless.environment=staging") + .withBean( + "environmentCustomizer", + ExceptionlessConfigurationCustomizer.class, + () -> config -> config.environment("from-code")) + .run(context -> assertThat(environmentOf(context)).isEqualTo("from-code")); + } + + @Test + void sendsTheConfiguredEnvironmentWithEvents() { + runner + .withPropertyValues("exceptionless.environment=staging") + .run( + context -> { + ExceptionlessClient client = context.getBean(ExceptionlessClient.class); + client.submitLog("hello"); + assertThat(recorder.flush(client)) + .singleElement() + .satisfies(e -> assertThat(e).containsEntry("environment", "staging")); + }); + } + @Test void appliesCustomizersInOrder() { runner From deba42a146849b3699495a72f8b7c926d7383e32 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 28 Oct 2026 08:16:58 -0500 Subject: [PATCH 2/3] Treat a null per-event environment as no override EventBuilder.setEnvironment(null) now clears any earlier override on the builder so the configured default applies, matching the .NET and JavaScript clients. Only a non-null value that is not valid (empty or white space, over 64 characters, control characters) is an explicit override that sends the event without an environment. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/exceptionless-java/SKILL.md | 4 +- README.md | 3 +- .../java/com/exceptionless/EventBuilder.java | 9 ++-- .../exceptionless/plugins/ContextData.java | 4 +- .../exceptionless/EventEnvironmentTest.java | 44 ++++++++++++++++++- .../plugins/DefaultPluginsTest.java | 2 +- 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/.agents/skills/exceptionless-java/SKILL.md b/.agents/skills/exceptionless-java/SKILL.md index d50a8ee..0c02bbf 100644 --- a/.agents/skills/exceptionless-java/SKILL.md +++ b/.agents/skills/exceptionless-java/SKILL.md @@ -35,8 +35,8 @@ Always recommend importing `com.exceptionless:exceptionless-bom` to align versio - Deployment environment: `config.environment("production")` is the default, `builder.setEnvironment("staging")` overrides it per event. Sent as the root `environment` property, separate from `data.@environment`. Names are trimmed (casing kept); empty, over 64 characters or with control characters are - unspecified. A null or invalid per-event name sends the event without an environment instead of the - default. Code beats `EXCEPTIONLESS_ENVIRONMENT`, `exceptionless.environment` and + unspecified. An invalid non-null per-event name (e.g. `" "`) sends the event without an environment + instead of the default; `setEnvironment(null)` is no override and clears an earlier call, so the default applies. Code beats `EXCEPTIONLESS_ENVIRONMENT`, `exceptionless.environment` and `exceptionless.properties`; the Spring starter's only active profile is the last fallback. - `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. diff --git a/README.md b/README.md index 3d06eff..3819656 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,8 @@ not set one. The Spring Boot starter uses the active profile only when no enviro and exactly one profile is active; a configured empty or invalid value leaves events unspecified instead. Names are trimmed and limited to 64 characters, preserving the supplied casing. The server filters case-insensitively and normalizes aggregation keys. Missing or invalid names remain -unspecified, and an invalid or null per-event name does not fall back to the configured default. The +unspecified, and an invalid per-event name (non-null but empty, too long or with control characters) +does not fall back to the configured default; pass `null` to `setEnvironment` to use the default. The top-level event `environment` is separate from machine diagnostics in `data.@environment`; stacks and fixed versions remain shared across environments. Older self hosted servers ignore the property, so upgrade the server first. diff --git a/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java b/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java index dd43ac4..5d7bcba 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java +++ b/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java @@ -56,13 +56,14 @@ public ContextData getContextData() { /** * Overrides the deployment environment for this event, instead of the configured default. The - * name is trimmed and keeps its casing. A null or invalid name (empty, longer than 64 characters - * or with control characters) clears the environment: the event is sent without one and does not - * fall back to the default. + * name is trimmed and keeps its casing. A name that is not valid (empty or white space, longer + * than 64 characters or with control characters) is still an override: the event is sent without + * an environment and does not fall back to the default. Null means no override: it clears any + * earlier call on this builder, and the configured default applies. */ public EventBuilder setEnvironment(@Nullable String environment) { event.setEnvironment(environment); - contextData.setEnvironmentOverride(true); + contextData.setEnvironmentOverride(environment != null); return this; } 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 5312c99..f30a2c7 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/plugins/ContextData.java +++ b/exceptionless-core/src/main/java/com/exceptionless/plugins/ContextData.java @@ -62,8 +62,8 @@ public ContextData setSubmissionMethod(@Nullable String submissionMethod) { } /** - * Whether the event's deployment environment was chosen for this event, even if it was cleared, - * so the configured default must not replace it. + * Whether a non-null deployment environment was chosen for this event, even one that is not valid + * and leaves the event unspecified, so the configured default must not replace it. */ public boolean hasEnvironmentOverride() { return Boolean.TRUE.equals(values.get(ENVIRONMENT_OVERRIDE)); diff --git a/exceptionless-core/src/test/java/com/exceptionless/EventEnvironmentTest.java b/exceptionless-core/src/test/java/com/exceptionless/EventEnvironmentTest.java index ab723e1..166c5c7 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/EventEnvironmentTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/EventEnvironmentTest.java @@ -75,16 +75,58 @@ void builderOverrideWorksWithoutADefault() { } @Test - void nullOverrideSendsTheEventUnspecifiedInsteadOfTheDefault() { + void nullOverrideMeansNoOverrideAndTheDefaultApplies() { + config.environment("production"); + + client.createLog("null").setEnvironment(null).submit(); + + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e).containsEntry("environment", "production")); + } + + @Test + void nullClearsAnEarlierOverrideSoTheDefaultApplies() { config.environment("production"); client.createLog("cleared").setEnvironment("staging").setEnvironment(null).submit(); + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e).containsEntry("environment", "production")); + } + + @Test + void nullAfterAnInvalidOverrideLetsTheDefaultApplyAgain() { + config.environment("production"); + + client.createLog("cleared").setEnvironment(" ").setEnvironment(null).submit(); + + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e).containsEntry("environment", "production")); + } + + @Test + void nullWithoutADefaultSendsTheEventUnspecified() { + client.createLog("none").setEnvironment("staging").setEnvironment(null).submit(); + assertThat(sent()) .singleElement() .satisfies(e -> assertThat(e).doesNotContainKey("environment")); } + @Test + void validOverrideAfterAnInvalidOneIsSent() { + config.environment("production"); + + client.createLog("again").setEnvironment(" ").setEnvironment("qa").submit(); + + assertThat(sent()) + .singleElement() + .satisfies(e -> assertThat(e).containsEntry("environment", "qa")); + } + @ParameterizedTest @ValueSource(strings = {"", " ", "stag\ning"}) void invalidOverrideSendsTheEventUnspecifiedInsteadOfTheDefault(String invalid) { 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 bfd2475..23d6e6c 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/plugins/DefaultPluginsTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/plugins/DefaultPluginsTest.java @@ -86,7 +86,7 @@ void configurationDefaultsAddTheEnvironmentWithoutOverwritingIt() { } @Test - void configurationDefaultsKeepAnEnvironmentThatWasClearedOnPurpose() { + void configurationDefaultsKeepAnInvalidEnvironmentOverride() { config.environment("production"); Event event = new Event(); PluginTestSupport.run( From b832ea3fec5dffef4e213d39a611ab07ba7a60b5 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 2 Nov 2026 18:05:46 -0600 Subject: [PATCH 3/3] Restrict environment names to a strict identifier Names are trimmed, then must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$. Anything else is unspecified and never rewritten. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/exceptionless-java/SKILL.md | 4 +- README.md | 7 +-- .../java/com/exceptionless/EventBuilder.java | 9 ++-- .../ExceptionlessConfiguration.java | 7 +-- .../internal/DeploymentEnvironment.java | 31 +++++-------- .../java/com/exceptionless/models/Event.java | 6 +-- .../exceptionless/EventEnvironmentTest.java | 18 ++++++++ .../ExceptionlessConfigurationTest.java | 11 ++++- .../internal/DeploymentEnvironmentTest.java | 44 ++++++++++++++----- 9 files changed, 90 insertions(+), 47 deletions(-) diff --git a/.agents/skills/exceptionless-java/SKILL.md b/.agents/skills/exceptionless-java/SKILL.md index 0c02bbf..f210fe5 100644 --- a/.agents/skills/exceptionless-java/SKILL.md +++ b/.agents/skills/exceptionless-java/SKILL.md @@ -34,8 +34,8 @@ Always recommend importing `com.exceptionless:exceptionless-bom` to align versio appender to `log4j2.xml`; without `apiKey` it uses the starter's client. - Deployment environment: `config.environment("production")` is the default, `builder.setEnvironment("staging")` overrides it per event. Sent as the root `environment` property, separate from `data.@environment`. - Names are trimmed (casing kept); empty, over 64 characters or with control characters are - unspecified. An invalid non-null per-event name (e.g. `" "`) sends the event without an environment + Names are trimmed (casing kept), then must match `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`; anything + else (`Staging West`, `prod/eu`, `produção`, empty, over 64 characters) is unspecified and never rewritten. An invalid non-null per-event name (e.g. `" "`) sends the event without an environment instead of the default; `setEnvironment(null)` is no override and clears an earlier call, so the default applies. Code beats `EXCEPTIONLESS_ENVIRONMENT`, `exceptionless.environment` and `exceptionless.properties`; the Spring starter's only active profile is the last fallback. - `serverUrl(...)` also sets the config and heartbeat URLs; override those after it. diff --git a/README.md b/README.md index 3819656..0a63968 100644 --- a/README.md +++ b/README.md @@ -208,10 +208,11 @@ property or the `EXCEPTIONLESS_ENVIRONMENT` environment variable (or `exceptionl Spring Boot). A value set in code takes precedence; external configuration applies only when code has not set one. The Spring Boot starter uses the active profile only when no environment is configured and exactly one profile is active; a configured empty or invalid value leaves events unspecified -instead. Names are trimmed and limited to 64 characters, preserving the supplied casing. The server +instead. Names are trimmed, then must match `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`: ASCII letters, digits, +`.`, `_` and `-`, starting with a letter or digit, at most 64 characters. Casing is preserved and +names are never rewritten, so `Staging West`, `prod/eu` or `produção` are invalid. The server filters case-insensitively and normalizes aggregation keys. Missing or invalid names remain -unspecified, and an invalid per-event name (non-null but empty, too long or with control characters) -does not fall back to the configured default; pass `null` to `setEnvironment` to use the default. The +unspecified, and an invalid non-null per-event name does not fall back to the configured default; pass `null` to `setEnvironment` to use the default. The top-level event `environment` is separate from machine diagnostics in `data.@environment`; stacks and fixed versions remain shared across environments. Older self hosted servers ignore the property, so upgrade the server first. diff --git a/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java b/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java index 5d7bcba..ed20cc0 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java +++ b/exceptionless-core/src/main/java/com/exceptionless/EventBuilder.java @@ -56,10 +56,11 @@ public ContextData getContextData() { /** * Overrides the deployment environment for this event, instead of the configured default. The - * name is trimmed and keeps its casing. A name that is not valid (empty or white space, longer - * than 64 characters or with control characters) is still an override: the event is sent without - * an environment and does not fall back to the default. Null means no override: it clears any - * earlier call on this builder, and the configured default applies. + * name is trimmed and keeps its casing, then must match {@code + * ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$}. A name that is not valid (such as {@code "Staging West"} or + * an empty one) is still an override: the event is sent without an environment and does not fall + * back to the default. Null means no override: it clears any earlier call on this builder, and + * the configured default applies. */ public EventBuilder setEnvironment(@Nullable String environment) { event.setEnvironment(environment); diff --git a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java index 2914ecb..646d83d 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java +++ b/exceptionless-core/src/main/java/com/exceptionless/ExceptionlessConfiguration.java @@ -421,9 +421,10 @@ public boolean isEnvironmentConfigured() { * over {@code exceptionless.properties}, system properties and environment variables, whichever * is read last. * - *

    The name is trimmed and keeps its casing. Empty names, names longer than 64 characters and - * names with control characters leave events without an environment (and still win over external - * configuration). Pass null to forget a value set here. + *

    The name is trimmed and keeps its casing, then must match {@code + * ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$}. Any other name leaves events without an environment (and + * still wins over external configuration); names are never rewritten. Pass null to forget a value + * set here. */ public ExceptionlessConfiguration environment(@Nullable String environment) { synchronized (environmentLock) { diff --git a/exceptionless-core/src/main/java/com/exceptionless/internal/DeploymentEnvironment.java b/exceptionless-core/src/main/java/com/exceptionless/internal/DeploymentEnvironment.java index f451d2a..10998cf 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/internal/DeploymentEnvironment.java +++ b/exceptionless-core/src/main/java/com/exceptionless/internal/DeploymentEnvironment.java @@ -1,21 +1,22 @@ package com.exceptionless.internal; +import java.util.regex.Pattern; import org.jspecify.annotations.Nullable; /** - * Normalizes deployment environment names the way the server does, so the client never sends a - * value the server would discard. + * Validates deployment environment names the way the server does, so the client never sends a value + * the server would discard. */ public final class DeploymentEnvironment { - /** The longest name the server accepts. */ - public static final int MAX_LENGTH = 64; + /** ASCII letters, digits, {@code .}, {@code _} and {@code -}; starts with a letter or digit. */ + private static final Pattern VALID_NAME = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"); private DeploymentEnvironment() {} /** - * Trims the name and returns it with its casing preserved, or null when the server would treat it - * as unspecified: empty, longer than {@value #MAX_LENGTH} characters or containing a control - * character. + * Trims the name and returns it with its casing preserved, or null when it is not a valid + * deployment environment name: ASCII letters, digits, {@code .}, {@code _} and {@code -}, + * starting with a letter or digit, at most 64 characters. Names are never rewritten. */ public static @Nullable String normalize(@Nullable String value) { if (value == null) { @@ -29,21 +30,13 @@ private DeploymentEnvironment() {} while (end > start && isTrimmable(value.charAt(end - 1))) { end--; } - if (start == end || end - start > MAX_LENGTH) { - return null; - } - for (int i = start; i < end; i++) { - if (Character.isISOControl(value.charAt(i))) { - return null; - } - } - return value.substring(start, end); + String name = value.substring(start, end); + return VALID_NAME.matcher(name).matches() ? name : null; } /** - * Whether the server's trim removes the character: white space plus the Unicode space, line and - * paragraph separators. This is wider than {@link String#strip()}, which keeps non-breaking - * spaces. + * Whether the trim removes the character: white space plus the Unicode space, line and paragraph + * separators. This is wider than {@link String#strip()}, which keeps non-breaking spaces. */ private static boolean isTrimmable(char c) { if ((c >= '\t' && c <= '\r') || c == ' ' || c == '\u0085') { 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 0fbc34a..8224037 100644 --- a/exceptionless-core/src/main/java/com/exceptionless/models/Event.java +++ b/exceptionless-core/src/main/java/com/exceptionless/models/Event.java @@ -41,9 +41,9 @@ public final class Event implements JsonSerializable { } /** - * Sets the deployment environment. The name is trimmed and keeps its casing. Empty names, names - * longer than 64 characters and names with control characters are not valid and clear the - * environment, so the event is sent without one. + * Sets the deployment environment. The name is trimmed and keeps its casing, then must match + * {@code ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$}. Any other name is not valid and clears the + * environment, so the event is sent without one; names are never rewritten. */ public void setEnvironment(@Nullable String environment) { this.environment = DeploymentEnvironment.normalize(environment); diff --git a/exceptionless-core/src/test/java/com/exceptionless/EventEnvironmentTest.java b/exceptionless-core/src/test/java/com/exceptionless/EventEnvironmentTest.java index 166c5c7..6bf5210 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/EventEnvironmentTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/EventEnvironmentTest.java @@ -151,6 +151,20 @@ void tooLongOverrideSendsTheEventUnspecifiedInsteadOfTheDefault() { .containsExactlyInAnyOrder(null, "x".repeat(64)); } + @Test + void overrideOutsideTheNamingRuleSendsTheEventUnspecifiedInsteadOfTheDefault() { + config.environment("production"); + + client.createLog("space").setEnvironment("Staging West").submit(); + client.createLog("slash").setEnvironment("prod/eu").submit(); + client.createLog("unicode").setEnvironment("produ\u00e7\u00e3o").submit(); + client.createLog("ok").setEnvironment("Staging-EU").submit(); + + assertThat(sent()) + .extracting(e -> e.get("environment")) + .containsExactlyInAnyOrder(null, null, null, "Staging-EU"); + } + @Test void invalidDefaultSendsEventsUnspecified() { config.environment("x".repeat(65)); @@ -252,5 +266,9 @@ void eventSetterNormalizes() { assertThat(event.getEnvironment()).isEqualTo("Production"); event.setEnvironment("x".repeat(65)); assertThat(event.getEnvironment()).isNull(); + event.setEnvironment("Staging-EU"); + assertThat(event.getEnvironment()).isEqualTo("Staging-EU"); + event.setEnvironment("Staging West"); + assertThat(event.getEnvironment()).isNull(); } } diff --git a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java index bddb596..c181620 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/ExceptionlessConfigurationTest.java @@ -150,7 +150,16 @@ void environmentIsNormalizedAndKeepsCasing() { } @ParameterizedTest - @ValueSource(strings = {"", " ", "prod\nuction"}) + @ValueSource( + strings = { + "", + " ", + "prod\nuction", + "Staging West", + "prod/eu", + "-prod", + "produ\u00e7\u00e3o" + }) void invalidEnvironmentIsUnspecifiedButConfigured(String value) { ExceptionlessConfiguration config = new ExceptionlessConfiguration().environment(value); assertThat(config.getEnvironment()).isNull(); diff --git a/exceptionless-core/src/test/java/com/exceptionless/internal/DeploymentEnvironmentTest.java b/exceptionless-core/src/test/java/com/exceptionless/internal/DeploymentEnvironmentTest.java index 2000945..d84f52d 100644 --- a/exceptionless-core/src/test/java/com/exceptionless/internal/DeploymentEnvironmentTest.java +++ b/exceptionless-core/src/test/java/com/exceptionless/internal/DeploymentEnvironmentTest.java @@ -14,39 +14,59 @@ class DeploymentEnvironmentTest { delimiter = '|', value = { "production|production", + "Staging-EU|Staging-EU", + "qa.2|qa.2", + "dev_local|dev_local", + "a|a", + "0|0", " Production |Production", - "Prod West|Prod West", }) - void trimsSurroundingWhiteSpaceAndKeepsCasing(String value, String expected) { + void validNamesAreTrimmedAndKeepTheirCasing(String value, String expected) { assertThat(DeploymentEnvironment.normalize(value)).isEqualTo(expected); } @Test void trimsControlWhiteSpaceAndUnicodeSpaces() { - // The server trims non-breaking and Unicode spaces too, which String.strip() keeps. assertThat(DeploymentEnvironment.normalize("\tProduction\n")).isEqualTo("Production"); assertThat(DeploymentEnvironment.normalize("\u00a0Production\u00a0")).isEqualTo("Production"); assertThat(DeploymentEnvironment.normalize("\u2003Production\u2028")).isEqualTo("Production"); assertThat(DeploymentEnvironment.normalize("\u0085Production")).isEqualTo("Production"); } - @Test - void keepsInnerCasingAndSpacing() { - assertThat(DeploymentEnvironment.normalize(" Prod West ")).isEqualTo("Prod West"); - assertThat(DeploymentEnvironment.normalize(" QA ")).isEqualTo("QA"); - } - @ParameterizedTest @NullSource - @ValueSource(strings = {"", " ", "\t\n", " ", " 
"}) + @ValueSource(strings = {"", " ", "\t\n", "\u00a0", " "}) void emptyIsUnspecified(String value) { assertThat(DeploymentEnvironment.normalize(value)).isNull(); } + @ParameterizedTest + @ValueSource( + strings = { + "Staging West", + " Prod West ", + "prod/eu", + "prod:eu", + "-prod", + ".prod", + "_prod", + "produ\u00e7\u00e3o", + "prod\u200b" + }) + void namesOutsideTheRuleAreUnspecifiedAndNeverRewritten(String value) { + assertThat(DeploymentEnvironment.normalize(value)).isNull(); + } + + @Test + void nonAsciiLettersAreUnspecifiedEvenWithinTheLengthLimit() { + assertThat(DeploymentEnvironment.normalize("\u0130".repeat(64))).isNull(); + } + @Test void allowsSixtyFourCharactersAndRejectsSixtyFive() { - assertThat(DeploymentEnvironment.normalize("x".repeat(64))).isEqualTo("x".repeat(64)); - assertThat(DeploymentEnvironment.normalize("x".repeat(65))).isNull(); + String name = "a" + "b".repeat(63); + assertThat(DeploymentEnvironment.normalize(name)).isEqualTo(name); + assertThat(DeploymentEnvironment.normalize("a".repeat(65))).isNull(); } @Test