Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .agents/skills/exceptionless-java/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Exceptionless />`
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
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.
- `submitException` / `createException` take any `Throwable`; causes and suppressed exceptions are included.
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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`. |
Expand All @@ -191,6 +194,28 @@ 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 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.

### Project settings

These settings in your project's **Settings → Client Configuration** apply to running applications
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ 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 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(environment != null);
return this;
}

public EventBuilder setType(@Nullable String type) {
if (!Strings.isBlank(type)) {
event.setType(type);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<String> defaultTags = new CopyOnWriteArraySet<>();
private final Map<String, Object> defaultData = new ConcurrentHashMap<>();
private final Map<String, String> settings = new ConcurrentHashMap<>();
Expand Down Expand Up @@ -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();
}
Expand All @@ -144,9 +157,13 @@ public ExceptionlessConfiguration() {
* variables and {@code exceptionless.*} system properties.
*
* <p>Supported keys: {@code apiKey}, {@code serverUrl}, {@code configServerUrl}, {@code
* heartbeatServerUrl}, {@code enabled}, {@code version}, {@code storagePath}, {@code
* includePrivateInformation}, {@code defaultTags} and {@code settings.<name>}. 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.<name>}. Environment variables use the upper snake case form, e.g. {@code
* EXCEPTIONLESS_API_KEY} and {@code EXCEPTIONLESS_ENVIRONMENT}.
*
* <p>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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
* <p>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<String> getDefaultTags() {
return Collections.unmodifiableSet(new LinkedHashSet<>(defaultTags));
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -29,6 +31,24 @@ public final class Event implements JsonSerializable {
private final Map<String, Object> 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;
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<String, Object> defaults = config.getDefaultData();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> values = new LinkedHashMap<>();

Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading
Loading