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,66 @@ 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, 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) {
+ 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..10998cf
--- /dev/null
+++ b/exceptionless-core/src/main/java/com/exceptionless/internal/DeploymentEnvironment.java
@@ -0,0 +1,50 @@
+package com.exceptionless.internal;
+
+import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Validates deployment environment names the way the server does, so the client never sends a value
+ * the server would discard.
+ */
+public final class DeploymentEnvironment {
+ /** 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 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) {
+ 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--;
+ }
+ String name = value.substring(start, end);
+ return VALID_NAME.matcher(name).matches() ? name : null;
+ }
+
+ /**
+ * 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') {
+ 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..8224037 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, 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);
+ }
+
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..f30a2c7 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 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));
+ }
+
+ 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..6bf5210
--- /dev/null
+++ b/exceptionless-core/src/test/java/com/exceptionless/EventEnvironmentTest.java
@@ -0,0 +1,274 @@
+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