From af5bea4accf72b65869c9f5c83402f3d336b147b Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 9 Sep 2026 22:30:47 -0500 Subject: [PATCH 1/7] Add deployment environments to events --- .../skills/exceptionless-javascript/SKILL.md | 2 ++ .../references/configuration.md | 6 +++++- .../references/sending-events.md | 8 ++++++++ README.md | 2 ++ packages/core/src/EventBuilder.ts | 12 +++++++++++- packages/core/src/Utils.ts | 8 ++++++++ .../core/src/configuration/Configuration.ts | 17 ++++++++++++++++- packages/core/src/models/Event.ts | 2 ++ .../default/ConfigurationDefaultsPlugin.ts | 8 +++++++- .../plugins/default/DuplicateCheckerPlugin.ts | 13 ++++++++++--- .../test/configuration/Configuration.test.ts | 15 +++++++++++++++ .../default/ConfigurationDefaultsPlugin.test.ts | 17 +++++++++++++++++ .../default/DuplicateCheckerPlugin.test.ts | 17 +++++++++++++++-- 13 files changed, 118 insertions(+), 9 deletions(-) diff --git a/.agents/skills/exceptionless-javascript/SKILL.md b/.agents/skills/exceptionless-javascript/SKILL.md index b578d037..9d61a39e 100644 --- a/.agents/skills/exceptionless-javascript/SKILL.md +++ b/.agents/skills/exceptionless-javascript/SKILL.md @@ -9,6 +9,8 @@ Use this skill to produce source-accurate setup code, integration guidance, and Keep answers compact. Prefer pointing to official docs for broad product behavior, and use local package READMEs/source to correct stale snippets or repo-specific package details. +Deployment environments use `config.environment` or `config.setEnvironment(name)` as the default, and `builder.setEnvironment(name)` for overrides. They serialize as top-level `environment`, separately from `data.@environment`. See [configuration.md](references/configuration.md). + ## Official Docs Primary docs: diff --git a/.agents/skills/exceptionless-javascript/references/configuration.md b/.agents/skills/exceptionless-javascript/references/configuration.md index 721cc2f9..2666bce7 100644 --- a/.agents/skills/exceptionless-javascript/references/configuration.md +++ b/.agents/skills/exceptionless-javascript/references/configuration.md @@ -23,12 +23,16 @@ import { Exceptionless } from "@exceptionless/browser"; await Exceptionless.startup((config) => { config.apiKey = "API_KEY_HERE"; config.version = "1.2.3"; + config.environment = "production"; config.setUserIdentity("12345678", "Blake"); config.defaultTags.push("Example", "JavaScript"); - config.defaultData["deployment"] = { environment: "production" }; }); ``` +## Deployment environment + +Set `config.environment = "production"` or call `config.setEnvironment("production")`. Per-event `setEnvironment("staging")` overrides the default. Names are trimmed and lowercased; empty names, names longer than 64 characters, and control characters are ignored. Missing values remain unspecified. This property is independent of `data.@environment` runtime metadata and of the application version. It does not change server stack grouping or create per-environment status. + ## Privacy For deeper guidance on PII removal, read [data-exclusions.md](data-exclusions.md). diff --git a/.agents/skills/exceptionless-javascript/references/sending-events.md b/.agents/skills/exceptionless-javascript/references/sending-events.md index a620ae36..c49f66c3 100644 --- a/.agents/skills/exceptionless-javascript/references/sending-events.md +++ b/.agents/skills/exceptionless-javascript/references/sending-events.md @@ -9,6 +9,14 @@ Use the platform package's `Exceptionless` singleton unless the user is building ## Common Events +Deployment environments can be overridden per event: + +```js +await Exceptionless.createLog("Deployment complete").setEnvironment("staging").submit(); +``` + +Without an override, the event uses `config.environment`. Repeated errors from different environments are queued separately; the server still groups the same error into one stack. + ```js import { Exceptionless } from "@exceptionless/browser"; diff --git a/README.md b/README.md index 0dbf5938..90fbf1cf 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ The definition of the word exceptionless is: to be without exception. Exceptionl ## Browser +Set a deployment environment in startup configuration with `config.environment = "production"` (or `config.setEnvironment("production")`). Override it on an event with `Exceptionless.createLog("Example").setEnvironment("staging").submit()`. Names are trimmed, lowercased, and limited to 64 characters. Missing or invalid names remain unspecified. The top-level `environment` is separate from machine/runtime diagnostics in `data.@environment`; stacks and fixed versions remain shared across environments. + You can install the npm package via `npm install @exceptionless/browser --save` or via cdn [`https://unpkg.com/@exceptionless/browser`](https://unpkg.com/@exceptionless/browser). Next, you just need to call startup during your app's startup to automatically diff --git a/packages/core/src/EventBuilder.ts b/packages/core/src/EventBuilder.ts index 187c8dae..b2006e7f 100644 --- a/packages/core/src/EventBuilder.ts +++ b/packages/core/src/EventBuilder.ts @@ -3,10 +3,20 @@ import { Event, EventType, KnownEventDataKeys } from "./models/Event.js"; import { ManualStackingInfo } from "./models/data/ManualStackingInfo.js"; import { UserInfo } from "./models/data/UserInfo.js"; import { EventContext } from "./models/EventContext.js"; -import { isEmpty, stringify } from "./Utils.js"; +import { isEmpty, normalizeEnvironment, stringify } from "./Utils.js"; import { EventPluginContext } from "./plugins/EventPluginContext.js"; export class EventBuilder { + public setEnvironment(value: string | null | undefined): EventBuilder { + const environment = normalizeEnvironment(value); + if (environment) { + this.target.environment = environment; + } else { + delete this.target.environment; + } + return this; + } + public target: Event; public client: ExceptionlessClient; public context: EventContext; diff --git a/packages/core/src/Utils.ts b/packages/core/src/Utils.ts index 1d64be44..be8f6d7a 100644 --- a/packages/core/src/Utils.ts +++ b/packages/core/src/Utils.ts @@ -559,3 +559,11 @@ export function allowProcessToExitWithoutWaitingForTimerOrInterval(timeoutOrInte (timeoutOrIntervalId as { unref: () => ReturnType }).unref(); } } +export function normalizeEnvironment(value: string | null | undefined): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const name = value.trim(); + // eslint-disable-next-line no-control-regex -- Deployment names cannot contain control characters. + return name && name.length <= 64 && !/[\u0000-\u001f\u007f-\u009f]/u.test(name) ? name.toLowerCase() : undefined; +} diff --git a/packages/core/src/configuration/Configuration.ts b/packages/core/src/configuration/Configuration.ts index 1f852b07..5482d21b 100644 --- a/packages/core/src/configuration/Configuration.ts +++ b/packages/core/src/configuration/Configuration.ts @@ -13,7 +13,7 @@ import { DefaultEventQueue } from "../queue/DefaultEventQueue.js"; import { IEventQueue } from "../queue/IEventQueue.js"; import { ISubmissionClient } from "../submission/ISubmissionClient.js"; import { DefaultSubmissionClient } from "../submission/DefaultSubmissionClient.js"; -import { guid } from "../Utils.js"; +import { guid, normalizeEnvironment } from "../Utils.js"; import { KnownEventDataKeys } from "../models/Event.js"; import { InMemoryStorage } from "../storage/InMemoryStorage.js"; import { IStorage } from "../storage/IStorage.js"; @@ -21,6 +21,21 @@ import { LocalStorage } from "../storage/LocalStorage.js"; import { ServerSettings } from "../configuration/SettingsManager.js"; export class Configuration { + private _environment: string | undefined; + + /** The default deployment environment for every event. */ + public get environment(): string | undefined { + return this._environment; + } + + public set environment(value: string | null | undefined) { + this._environment = normalizeEnvironment(value); + } + + public setEnvironment(value: string | null | undefined): void { + this.environment = value; + } + constructor() { this.services = { lastReferenceIdManager: new DefaultLastReferenceIdManager(), diff --git a/packages/core/src/models/Event.ts b/packages/core/src/models/Event.ts index 1c8dd053..f499fe25 100644 --- a/packages/core/src/models/Event.ts +++ b/packages/core/src/models/Event.ts @@ -8,6 +8,8 @@ import { ManualStackingInfo } from "../models/data/ManualStackingInfo.js"; export type EventType = "error" | "usage" | "log" | "404" | "session" | string; export interface Event { + /** The deployment environment, such as production or staging. */ + environment?: string; /** The event type (ie. error, log message, feature usage). */ type?: EventType; /** The event source (ie. machine name, log name, feature name). */ diff --git a/packages/core/src/plugins/default/ConfigurationDefaultsPlugin.ts b/packages/core/src/plugins/default/ConfigurationDefaultsPlugin.ts index 414ca136..6d153603 100644 --- a/packages/core/src/plugins/default/ConfigurationDefaultsPlugin.ts +++ b/packages/core/src/plugins/default/ConfigurationDefaultsPlugin.ts @@ -1,4 +1,4 @@ -import { isEmpty, stringify } from "../../Utils.js"; +import { isEmpty, normalizeEnvironment, stringify } from "../../Utils.js"; import { EventPluginContext } from "../../plugins/EventPluginContext.js"; import { IEventPlugin } from "../../plugins/IEventPlugin.js"; @@ -9,6 +9,12 @@ export class ConfigurationDefaultsPlugin implements IEventPlugin { public run(context: EventPluginContext): Promise { const { dataExclusions, defaultData, defaultTags } = context.client.config; const ev = context.event; + const environment = normalizeEnvironment(ev.environment ?? context.client.config.environment); + if (environment) { + ev.environment = environment; + } else { + delete ev.environment; + } if (defaultTags) { ev.tags = [...(ev.tags || []), ...defaultTags]; diff --git a/packages/core/src/plugins/default/DuplicateCheckerPlugin.ts b/packages/core/src/plugins/default/DuplicateCheckerPlugin.ts index 6213171d..a494d621 100644 --- a/packages/core/src/plugins/default/DuplicateCheckerPlugin.ts +++ b/packages/core/src/plugins/default/DuplicateCheckerPlugin.ts @@ -50,11 +50,12 @@ export class DuplicateCheckerPlugin implements IEventPlugin { const error = context.event.data?.[KnownEventDataKeys.Error]; const hashCode = calculateHashCode(error); + const environment = context.event.environment; if (hashCode) { const count = context.event.count || 1; const now = this._getCurrentTime(); - const merged = this._mergedEvents.filter((s) => s.hashCode === hashCode)[0]; + const merged = this._mergedEvents.find((s) => s.hashCode === hashCode && s.environment === environment); if (merged) { merged.incrementCount(count); merged.updateDate(context.event.date); @@ -62,7 +63,10 @@ export class DuplicateCheckerPlugin implements IEventPlugin { context.cancelled = true; } - if (!context.cancelled && this._processedHashCodes.some((h) => h.hash === hashCode && h.timestamp >= now - this._interval)) { + if ( + !context.cancelled && + this._processedHashCodes.some((h) => h.hash === hashCode && h.environment === environment && h.timestamp >= now - this._interval) + ) { context.log.trace("Adding event with hash: " + hashCode); this._mergedEvents.push(new MergedEvent(hashCode, context, count)); context.cancelled = true; @@ -70,7 +74,7 @@ export class DuplicateCheckerPlugin implements IEventPlugin { if (!context.cancelled) { context.log.trace(`Enqueueing event with hash: ${hashCode} to cache`); - this._processedHashCodes.push({ hash: hashCode, timestamp: now }); + this._processedHashCodes.push({ hash: hashCode, environment, timestamp: now }); // Only keep the last 50 recent errors. while (this._processedHashCodes.length > 50) { @@ -91,16 +95,19 @@ export class DuplicateCheckerPlugin implements IEventPlugin { interface TimestampedHash { hash: number; + environment: string | undefined; timestamp: number; } class MergedEvent { public hashCode: number; + public readonly environment: string | undefined; private _count: number; private _context: EventPluginContext; constructor(hashCode: number, context: EventPluginContext, count: number) { this.hashCode = hashCode; + this.environment = context.event.environment; this._context = context; this._count = count; } diff --git a/packages/core/test/configuration/Configuration.test.ts b/packages/core/test/configuration/Configuration.test.ts index 4c8335cb..a6ecd4ce 100644 --- a/packages/core/test/configuration/Configuration.test.ts +++ b/packages/core/test/configuration/Configuration.test.ts @@ -3,6 +3,21 @@ import { describe, expect, test } from "vitest"; import { Configuration } from "../../src/configuration/Configuration.js"; describe("Configuration", () => { + test("should normalize deployment environments and allow clearing the default", () => { + const config = new Configuration(); + expect(config.environment).toBeUndefined(); + config.setEnvironment(" Production "); + expect(config.environment).toBe("production"); + config.environment = "Staging"; + expect(config.environment).toBe("staging"); + config.setEnvironment(""); + expect(config.environment).toBeUndefined(); + config.setEnvironment("x".repeat(65)); + expect(config.environment).toBeUndefined(); + config.setEnvironment("prod\ninvalid"); + expect(config.environment).toBeUndefined(); + }); + test("should override configuration defaults", () => { let config = new Configuration(); expect(config.apiKey).toEqual(""); diff --git a/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts b/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts index b533db5b..1dcc8401 100644 --- a/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts +++ b/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts @@ -7,6 +7,23 @@ import { EventPluginContext } from "../../../src/plugins/EventPluginContext.js"; import { EventContext } from "../../../src/models/EventContext.js"; describe("ConfigurationDefaultsPlugin", () => { + test("should apply deployment defaults to all event types and preserve overrides and runtime metadata", async () => { + const client = new ExceptionlessClient(); + client.config.setEnvironment(" Production "); + const plugin = new ConfigurationDefaultsPlugin(); + for (const type of ["error", "log", "usage", "session"]) { + const event: Event = { type, data: { "@environment": { machine_name: "worker-1" } } }; + await plugin.run(new EventPluginContext(client, event, new EventContext())); + expect(event.environment).toBe("production"); + expect(event.data?.["@environment"]?.machine_name).toBe("worker-1"); + } + + const builder = client.createLog("test", "message").setEnvironment(" Staging "); + await plugin.run(new EventPluginContext(client, builder.target, new EventContext())); + expect(builder.target.environment).toBe("staging"); + expect(JSON.parse(JSON.stringify(builder.target)).environment).toBe("staging"); + }); + describe("should add default", () => { const userDataKey: string = "user"; const user = { diff --git a/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts b/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts index ad758afc..dad8087c 100644 --- a/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts +++ b/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { DuplicateCheckerPlugin } from "../../../src/plugins/default/DuplicateCheckerPlugin.js"; import { ExceptionlessClient } from "../../../src/ExceptionlessClient.js"; @@ -51,7 +51,7 @@ describe("DuplicateCheckerPlugin", () => { plugin = new DuplicateCheckerPlugin(() => now, 50); }); - const run = async (stackTrace?: StackFrameInfo[]): Promise => { + const run = async (stackTrace?: StackFrameInfo[], environment?: string): Promise => { // TODO: Generate unique stack traces based on test data. const context = new EventPluginContext( client, @@ -68,6 +68,7 @@ describe("DuplicateCheckerPlugin", () => { new EventContext() ); + if (environment) context.event.environment = environment; await plugin.run(context); return context; }; @@ -83,6 +84,18 @@ describe("DuplicateCheckerPlugin", () => { }, 100); }); + test("should merge duplicates only within the same environment", async () => { + const enqueue = vi.spyOn(client.config.services.queue, "enqueue"); + expect((await run(Exception1StackTrace, "production")).cancelled).not.toBe(true); + expect((await run(Exception1StackTrace, "staging")).cancelled).not.toBe(true); + expect((await run(Exception1StackTrace)).cancelled).not.toBe(true); + expect((await run(Exception1StackTrace, "production")).cancelled).toBe(true); + expect((await run(Exception1StackTrace, "staging")).cancelled).toBe(true); + expect((await run(Exception1StackTrace)).cancelled).toBe(true); + await plugin.suspend(); + expect(enqueue.mock.calls.map(([event]) => event.environment).sort()).toEqual(["production", "staging", undefined]); + }); + test("should ignore error without stack", async () => { await run(); const contextOfSecondRun = await run(); From 638593309e4d2717b53d77f7a6fc80af625782e3 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 9 Sep 2026 22:45:01 -0500 Subject: [PATCH 2/7] Preserve invalid environment overrides as unspecified --- packages/core/src/EventBuilder.ts | 7 +++---- packages/core/src/Utils.ts | 4 ++-- .../plugins/default/ConfigurationDefaultsPlugin.test.ts | 8 ++++++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/core/src/EventBuilder.ts b/packages/core/src/EventBuilder.ts index b2006e7f..6840ecb1 100644 --- a/packages/core/src/EventBuilder.ts +++ b/packages/core/src/EventBuilder.ts @@ -3,14 +3,13 @@ import { Event, EventType, KnownEventDataKeys } from "./models/Event.js"; import { ManualStackingInfo } from "./models/data/ManualStackingInfo.js"; import { UserInfo } from "./models/data/UserInfo.js"; import { EventContext } from "./models/EventContext.js"; -import { isEmpty, normalizeEnvironment, stringify } from "./Utils.js"; +import { isEmpty, stringify } from "./Utils.js"; import { EventPluginContext } from "./plugins/EventPluginContext.js"; export class EventBuilder { public setEnvironment(value: string | null | undefined): EventBuilder { - const environment = normalizeEnvironment(value); - if (environment) { - this.target.environment = environment; + if (value != null) { + this.target.environment = value; } else { delete this.target.environment; } diff --git a/packages/core/src/Utils.ts b/packages/core/src/Utils.ts index be8f6d7a..06b85ce1 100644 --- a/packages/core/src/Utils.ts +++ b/packages/core/src/Utils.ts @@ -563,7 +563,7 @@ export function normalizeEnvironment(value: string | null | undefined): string | if (typeof value !== "string") { return undefined; } - const name = value.trim(); + const name = value.trim().toLowerCase(); // eslint-disable-next-line no-control-regex -- Deployment names cannot contain control characters. - return name && name.length <= 64 && !/[\u0000-\u001f\u007f-\u009f]/u.test(name) ? name.toLowerCase() : undefined; + return name && name.length <= 64 && !/[\u0000-\u001f\u007f-\u009f]/u.test(name) ? name : undefined; } diff --git a/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts b/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts index 1dcc8401..b7026376 100644 --- a/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts +++ b/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts @@ -24,6 +24,14 @@ describe("ConfigurationDefaultsPlugin", () => { expect(JSON.parse(JSON.stringify(builder.target)).environment).toBe("staging"); }); + test.each(["", " ", "x".repeat(65), "prod\ninvalid", "İ".repeat(64)])("should keep invalid override %j unspecified", async (environment) => { + const client = new ExceptionlessClient(); + client.config.environment = "production"; + const builder = client.createLog("test", "message").setEnvironment(environment); + await new ConfigurationDefaultsPlugin().run(new EventPluginContext(client, builder.target, new EventContext())); + expect(builder.target.environment).toBeUndefined(); + }); + describe("should add default", () => { const userDataKey: string = "user"; const user = { From 4dedcec3a850a46ae7c5569ed1b2ca769488a036 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 9 Sep 2026 22:56:11 -0500 Subject: [PATCH 3/7] Use environment configuration consistently in SDK guidance --- .../exceptionless-javascript/references/client-core.md | 2 +- .../exceptionless-javascript/references/plugins.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/skills/exceptionless-javascript/references/client-core.md b/.agents/skills/exceptionless-javascript/references/client-core.md index ba51925b..2b96d49c 100644 --- a/.agents/skills/exceptionless-javascript/references/client-core.md +++ b/.agents/skills/exceptionless-javascript/references/client-core.md @@ -22,7 +22,7 @@ await client.startup((config) => { config.version = "1.2.3"; config.setUserIdentity("12345678", "Blake"); config.defaultTags.push("Example", "JavaScript", "Core"); - config.defaultData["deployment"] = { environment: "production" }; + config.environment = "production"; }); ``` diff --git a/.agents/skills/exceptionless-javascript/references/plugins.md b/.agents/skills/exceptionless-javascript/references/plugins.md index 1d49378a..74112bc9 100644 --- a/.agents/skills/exceptionless-javascript/references/plugins.md +++ b/.agents/skills/exceptionless-javascript/references/plugins.md @@ -30,24 +30,24 @@ await Exceptionless.startup((config) => { import type { EventPluginContext, IEventPlugin, PluginContext } from "@exceptionless/core"; import { Exceptionless } from "@exceptionless/browser"; -class DeploymentPlugin implements IEventPlugin { +class BuildInfoPlugin implements IEventPlugin { priority = 90; - name = "DeploymentPlugin"; + name = "BuildInfoPlugin"; startup(context: PluginContext): Promise { - context.log.info("Deployment plugin started"); + context.log.info("Build information plugin started"); return Promise.resolve(); } run(context: EventPluginContext): Promise { context.event.data ??= {}; - context.event.data["deployment"] = "production"; + context.event.data["build"] = "build-123"; return Promise.resolve(); } } await Exceptionless.startup((config) => { config.apiKey = "API_KEY_HERE"; - config.addPlugin(new DeploymentPlugin()); + config.addPlugin(new BuildInfoPlugin()); }); ``` From 0d459e6ad16e23a17bc913709aef25064581a7b0 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 9 Sep 2026 23:03:09 -0500 Subject: [PATCH 4/7] Update React Native deployment environment example --- packages/react-native/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 5751f0ed..c75818d1 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -78,8 +78,8 @@ await Exceptionless.startup((c) => { // Optional: add default tags c.defaultTags.push("react-native", "ios"); - // Optional: add default data - c.defaultData["environment"] = "production"; + // Optional: set the deployment environment + c.environment = "production"; }); ``` From 0e675b4caa5dcd9909156cd22c0219e864680df3 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Thu, 10 Sep 2026 00:10:37 -0500 Subject: [PATCH 5/7] Preserve supplied environment casing --- .agents/skills/exceptionless-javascript/SKILL.md | 2 +- .../exceptionless-javascript/references/configuration.md | 2 +- README.md | 2 +- packages/core/src/Utils.ts | 2 +- packages/core/test/configuration/Configuration.test.ts | 8 +++++--- .../plugins/default/ConfigurationDefaultsPlugin.test.ts | 8 ++++---- .../test/plugins/default/DuplicateCheckerPlugin.test.ts | 4 +++- 7 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.agents/skills/exceptionless-javascript/SKILL.md b/.agents/skills/exceptionless-javascript/SKILL.md index 9d61a39e..75ee1465 100644 --- a/.agents/skills/exceptionless-javascript/SKILL.md +++ b/.agents/skills/exceptionless-javascript/SKILL.md @@ -9,7 +9,7 @@ Use this skill to produce source-accurate setup code, integration guidance, and Keep answers compact. Prefer pointing to official docs for broad product behavior, and use local package READMEs/source to correct stale snippets or repo-specific package details. -Deployment environments use `config.environment` or `config.setEnvironment(name)` as the default, and `builder.setEnvironment(name)` for overrides. They serialize as top-level `environment`, separately from `data.@environment`. See [configuration.md](references/configuration.md). +Deployment environments use `config.environment` or `config.setEnvironment(name)` as the default, and `builder.setEnvironment(name)` for overrides. They serialize as top-level `environment`, separately from `data.@environment`, preserving the supplied casing after trimming. See [configuration.md](references/configuration.md). ## Official Docs diff --git a/.agents/skills/exceptionless-javascript/references/configuration.md b/.agents/skills/exceptionless-javascript/references/configuration.md index 2666bce7..1e80e601 100644 --- a/.agents/skills/exceptionless-javascript/references/configuration.md +++ b/.agents/skills/exceptionless-javascript/references/configuration.md @@ -31,7 +31,7 @@ await Exceptionless.startup((config) => { ## Deployment environment -Set `config.environment = "production"` or call `config.setEnvironment("production")`. Per-event `setEnvironment("staging")` overrides the default. Names are trimmed and lowercased; empty names, names longer than 64 characters, and control characters are ignored. Missing values remain unspecified. This property is independent of `data.@environment` runtime metadata and of the application version. It does not change server stack grouping or create per-environment status. +Set `config.environment = "production"` or call `config.setEnvironment("production")`. Per-event `setEnvironment("staging")` overrides the default. Names are trimmed and retain their supplied casing; empty names, names longer than 64 characters, and control characters are ignored. Missing values remain unspecified. The server filters case-insensitively and normalizes aggregation keys. This property is independent of `data.@environment` runtime metadata and of the application version. It does not change server stack grouping or create per-environment status. ## Privacy diff --git a/README.md b/README.md index 90fbf1cf..03445ec5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The definition of the word exceptionless is: to be without exception. Exceptionl ## Browser -Set a deployment environment in startup configuration with `config.environment = "production"` (or `config.setEnvironment("production")`). Override it on an event with `Exceptionless.createLog("Example").setEnvironment("staging").submit()`. Names are trimmed, lowercased, and limited to 64 characters. Missing or invalid names remain unspecified. The top-level `environment` is separate from machine/runtime diagnostics in `data.@environment`; stacks and fixed versions remain shared across environments. +Set a deployment environment in startup configuration with `config.environment = "production"` (or `config.setEnvironment("production")`). Override it on an event with `Exceptionless.createLog("Example").setEnvironment("staging").submit()`. 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. The top-level `environment` is separate from machine/runtime diagnostics in `data.@environment`; stacks and fixed versions remain shared across environments. You can install the npm package via `npm install @exceptionless/browser --save` or via cdn [`https://unpkg.com/@exceptionless/browser`](https://unpkg.com/@exceptionless/browser). diff --git a/packages/core/src/Utils.ts b/packages/core/src/Utils.ts index 06b85ce1..1569da50 100644 --- a/packages/core/src/Utils.ts +++ b/packages/core/src/Utils.ts @@ -563,7 +563,7 @@ export function normalizeEnvironment(value: string | null | undefined): string | if (typeof value !== "string") { return undefined; } - const name = value.trim().toLowerCase(); + const name = value.trim(); // eslint-disable-next-line no-control-regex -- Deployment names cannot contain control characters. return name && name.length <= 64 && !/[\u0000-\u001f\u007f-\u009f]/u.test(name) ? name : undefined; } diff --git a/packages/core/test/configuration/Configuration.test.ts b/packages/core/test/configuration/Configuration.test.ts index a6ecd4ce..61a96eab 100644 --- a/packages/core/test/configuration/Configuration.test.ts +++ b/packages/core/test/configuration/Configuration.test.ts @@ -3,13 +3,15 @@ import { describe, expect, test } from "vitest"; import { Configuration } from "../../src/configuration/Configuration.js"; describe("Configuration", () => { - test("should normalize deployment environments and allow clearing the default", () => { + test("should trim deployment environments, preserve casing, and allow clearing the default", () => { const config = new Configuration(); expect(config.environment).toBeUndefined(); config.setEnvironment(" Production "); - expect(config.environment).toBe("production"); + expect(config.environment).toBe("Production"); config.environment = "Staging"; - expect(config.environment).toBe("staging"); + expect(config.environment).toBe("Staging"); + config.environment = "İ".repeat(64); + expect(config.environment).toBe("İ".repeat(64)); config.setEnvironment(""); expect(config.environment).toBeUndefined(); config.setEnvironment("x".repeat(65)); diff --git a/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts b/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts index b7026376..bd45cb88 100644 --- a/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts +++ b/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts @@ -14,17 +14,17 @@ describe("ConfigurationDefaultsPlugin", () => { for (const type of ["error", "log", "usage", "session"]) { const event: Event = { type, data: { "@environment": { machine_name: "worker-1" } } }; await plugin.run(new EventPluginContext(client, event, new EventContext())); - expect(event.environment).toBe("production"); + expect(event.environment).toBe("Production"); expect(event.data?.["@environment"]?.machine_name).toBe("worker-1"); } const builder = client.createLog("test", "message").setEnvironment(" Staging "); await plugin.run(new EventPluginContext(client, builder.target, new EventContext())); - expect(builder.target.environment).toBe("staging"); - expect(JSON.parse(JSON.stringify(builder.target)).environment).toBe("staging"); + expect(builder.target.environment).toBe("Staging"); + expect(JSON.parse(JSON.stringify(builder.target)).environment).toBe("Staging"); }); - test.each(["", " ", "x".repeat(65), "prod\ninvalid", "İ".repeat(64)])("should keep invalid override %j unspecified", async (environment) => { + test.each(["", " ", "x".repeat(65), "prod\ninvalid"])("should keep invalid override %j unspecified", async (environment) => { const client = new ExceptionlessClient(); client.config.environment = "production"; const builder = client.createLog("test", "message").setEnvironment(environment); diff --git a/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts b/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts index dad8087c..c73c579f 100644 --- a/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts +++ b/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts @@ -86,14 +86,16 @@ describe("DuplicateCheckerPlugin", () => { test("should merge duplicates only within the same environment", async () => { const enqueue = vi.spyOn(client.config.services.queue, "enqueue"); + expect((await run(Exception1StackTrace, "Production")).cancelled).not.toBe(true); expect((await run(Exception1StackTrace, "production")).cancelled).not.toBe(true); expect((await run(Exception1StackTrace, "staging")).cancelled).not.toBe(true); expect((await run(Exception1StackTrace)).cancelled).not.toBe(true); + expect((await run(Exception1StackTrace, "Production")).cancelled).toBe(true); expect((await run(Exception1StackTrace, "production")).cancelled).toBe(true); expect((await run(Exception1StackTrace, "staging")).cancelled).toBe(true); expect((await run(Exception1StackTrace)).cancelled).toBe(true); await plugin.suspend(); - expect(enqueue.mock.calls.map(([event]) => event.environment).sort()).toEqual(["production", "staging", undefined]); + expect(enqueue.mock.calls.map(([event]) => event.environment).sort()).toEqual(["Production", "production", "staging", undefined]); }); test("should ignore error without stack", async () => { From 55b2fa95f801fcd86a98b368fb168f9dbf8b76f0 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 21 Oct 2026 00:13:46 -0500 Subject: [PATCH 6/7] Clarify environment overrides and cover their edge cases Document that an invalid per-event name does not fall back to the default, move the README guidance into the configuration section, test clearing an override, trimming control whitespace, and omitting an unset environment, and apply the lint rules from main. Co-Authored-By: Claude Opus 5.5 --- .../references/configuration.md | 2 +- README.md | 6 ++-- packages/core/src/EventBuilder.ts | 18 +++++----- .../test/configuration/Configuration.test.ts | 2 ++ .../ConfigurationDefaultsPlugin.test.ts | 33 ++++++++++++++++++- .../default/DuplicateCheckerPlugin.test.ts | 4 ++- 6 files changed, 51 insertions(+), 14 deletions(-) diff --git a/.agents/skills/exceptionless-javascript/references/configuration.md b/.agents/skills/exceptionless-javascript/references/configuration.md index 1e80e601..a58e3abe 100644 --- a/.agents/skills/exceptionless-javascript/references/configuration.md +++ b/.agents/skills/exceptionless-javascript/references/configuration.md @@ -31,7 +31,7 @@ await Exceptionless.startup((config) => { ## Deployment environment -Set `config.environment = "production"` or call `config.setEnvironment("production")`. Per-event `setEnvironment("staging")` overrides the default. Names are trimmed and retain their supplied casing; empty names, names longer than 64 characters, and control characters are ignored. Missing values remain unspecified. The server filters case-insensitively and normalizes aggregation keys. This property is independent of `data.@environment` runtime metadata and of the application version. It does not change server stack grouping or create per-environment status. +Set `config.environment = "production"` or call `config.setEnvironment("production")`. Per-event `setEnvironment("staging")` overrides the default. Names are trimmed and retain their supplied casing; empty names, names longer than 64 characters, and names with control characters are unspecified. An invalid per-event name stays unspecified instead of using the default; pass `null` or `undefined` to `setEnvironment` to use the default. Missing values remain unspecified. The server filters case-insensitively and normalizes aggregation keys. This property is independent of `data.@environment` runtime metadata and of the application version. It does not change server stack grouping or create per-environment status. ## Privacy diff --git a/README.md b/README.md index 03445ec5..2fb06058 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,6 @@ The definition of the word exceptionless is: to be without exception. Exceptionl ## Browser -Set a deployment environment in startup configuration with `config.environment = "production"` (or `config.setEnvironment("production")`). Override it on an event with `Exceptionless.createLog("Example").setEnvironment("staging").submit()`. 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. The top-level `environment` is separate from machine/runtime diagnostics in `data.@environment`; stacks and fixed versions remain shared across environments. - You can install the npm package via `npm install @exceptionless/browser --save` or via cdn [`https://unpkg.com/@exceptionless/browser`](https://unpkg.com/@exceptionless/browser). Next, you just need to call startup during your app's startup to automatically @@ -158,6 +156,10 @@ await Exceptionless.startup((c) => { Please see the [docs](https://exceptionless.com/docs/clients/javascript/) for more information on configuring the client. +#### Deployment environments + +Set a deployment environment in startup configuration with `config.environment = "production"` (or `config.setEnvironment("production")`). Override it on an event with `Exceptionless.createLog("Example").setEnvironment("staging").submit()`. 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 does not fall back to the configured default; pass `null` or `undefined` to `setEnvironment` to use the default. The top-level `environment` is separate from machine/runtime diagnostics in `data.@environment`; stacks and fixed versions remain shared across environments. + ### Submitting Events and Errors Once configured, Exceptionless will automatically submit any unhandled exceptions diff --git a/packages/core/src/EventBuilder.ts b/packages/core/src/EventBuilder.ts index daf2c112..d2456fa0 100644 --- a/packages/core/src/EventBuilder.ts +++ b/packages/core/src/EventBuilder.ts @@ -7,15 +7,6 @@ import { isEmpty, stringify } from "./Utils.js"; import { EventPluginContext } from "./plugins/EventPluginContext.js"; export class EventBuilder { - public setEnvironment(value: string | null | undefined): EventBuilder { - if (value != null) { - this.target.environment = value; - } else { - delete this.target.environment; - } - return this; - } - public target: Event; public client: ExceptionlessClient; public context: EventContext; @@ -71,6 +62,15 @@ export class EventBuilder { return this; } + public setEnvironment(value: string | null | undefined): EventBuilder { + if (value != null) { + this.target.environment = value; + } else { + delete this.target.environment; + } + return this; + } + public setMessage(message: string | null | undefined): EventBuilder { if (message) { this.target.message = message; diff --git a/packages/core/test/configuration/Configuration.test.ts b/packages/core/test/configuration/Configuration.test.ts index cf2842ed..4d0a6881 100644 --- a/packages/core/test/configuration/Configuration.test.ts +++ b/packages/core/test/configuration/Configuration.test.ts @@ -18,6 +18,8 @@ describe("Configuration", () => { expect(config.environment).toBeUndefined(); config.setEnvironment("prod\ninvalid"); expect(config.environment).toBeUndefined(); + config.setEnvironment("\tProduction\n"); + expect(config.environment).toBe("Production"); }); test("should override configuration defaults", () => { diff --git a/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts b/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts index 9817c6b3..a30ece8f 100644 --- a/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts +++ b/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts @@ -12,7 +12,14 @@ describe("ConfigurationDefaultsPlugin", () => { client.config.setEnvironment(" Production "); const plugin = new ConfigurationDefaultsPlugin(); for (const type of ["error", "log", "usage", "session"]) { - const event: Event = { type, data: { "@environment": { machine_name: "worker-1" } } }; + const event: Event = { + type, + data: { + "@environment": { + machine_name: "worker-1" + } + } + }; await plugin.run(new EventPluginContext(client, event, new EventContext())); expect(event.environment).toBe("Production"); expect(event.data?.["@environment"]?.machine_name).toBe("worker-1"); @@ -32,6 +39,30 @@ describe("ConfigurationDefaultsPlugin", () => { expect(builder.target.environment).toBeUndefined(); }); + test.each([null, undefined])("should apply the default when the override is cleared with %j", async (environment) => { + const client = new ExceptionlessClient(); + client.config.environment = "production"; + const builder = client.createLog("test", "message").setEnvironment("staging").setEnvironment(environment); + await new ConfigurationDefaultsPlugin().run(new EventPluginContext(client, builder.target, new EventContext())); + expect(builder.target.environment).toBe("production"); + }); + + test("should trim surrounding control whitespace from an override", async () => { + const client = new ExceptionlessClient(); + client.config.environment = "production"; + const builder = client.createLog("test", "message").setEnvironment("\tStaging\n"); + await new ConfigurationDefaultsPlugin().run(new EventPluginContext(client, builder.target, new EventContext())); + expect(builder.target.environment).toBe("Staging"); + }); + + test("should omit an unspecified environment from the serialized event", async () => { + const client = new ExceptionlessClient(); + const builder = client.createLog("test", "message"); + await new ConfigurationDefaultsPlugin().run(new EventPluginContext(client, builder.target, new EventContext())); + const serialized = JSON.parse(JSON.stringify(builder.target)) as Record; + expect(Object.keys(serialized)).not.toContain("environment"); + }); + describe("should add default", () => { const userDataKey: string = "user"; const user = { diff --git a/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts b/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts index c73c579f..b435683b 100644 --- a/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts +++ b/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts @@ -68,7 +68,9 @@ describe("DuplicateCheckerPlugin", () => { new EventContext() ); - if (environment) context.event.environment = environment; + if (environment) { + context.event.environment = environment; + } await plugin.run(context); return context; }; From f7b9ca262da7895b5d3805a3142ba9ea006cd05c Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 21 Oct 2026 07:14:45 -0500 Subject: [PATCH 7/7] Normalize environments assigned by later plugins A plugin that runs after the defaults plugin could set an untrimmed, oversized, or control-character environment that reached the queue. Normalize again before submitting and before resubmitting merged duplicates. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/ExceptionlessClient.ts | 5 ++++- packages/core/src/Utils.ts | 14 ++++++++++++++ .../default/ConfigurationDefaultsPlugin.ts | 9 ++------- .../plugins/default/DuplicateCheckerPlugin.ts | 4 +++- .../core/test/ExceptionlessClient.test.ts | 19 +++++++++++++++++++ .../default/DuplicateCheckerPlugin.test.ts | 8 ++++++++ 6 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/core/src/ExceptionlessClient.ts b/packages/core/src/ExceptionlessClient.ts index e4590dca..18ab1cd5 100644 --- a/packages/core/src/ExceptionlessClient.ts +++ b/packages/core/src/ExceptionlessClient.ts @@ -7,7 +7,7 @@ import { EventContext } from "./models/EventContext.js"; import { EventPluginContext } from "./plugins/EventPluginContext.js"; import { EventPluginManager } from "./plugins/EventPluginManager.js"; import { PluginContext } from "./plugins/PluginContext.js"; -import { allowProcessToExitWithoutWaitingForTimerOrInterval } from "./Utils.js"; +import { allowProcessToExitWithoutWaitingForTimerOrInterval, setNormalizedEnvironment } from "./Utils.js"; export class ExceptionlessClient { private _intervalId: ReturnType | undefined; @@ -248,6 +248,9 @@ export class ExceptionlessClient { ev.date = new Date(); } + // Plugins that run after the defaults can assign any value. + setNormalizedEnvironment(ev, ev.environment); + await this.config.services.queue.enqueue(ev); if (ev.reference_id && ev.reference_id.length > 0) { diff --git a/packages/core/src/Utils.ts b/packages/core/src/Utils.ts index 1569da50..5df2f5ba 100644 --- a/packages/core/src/Utils.ts +++ b/packages/core/src/Utils.ts @@ -1,3 +1,5 @@ +import type { Event } from "./models/Event.js"; + export function getHashCode(source: string): number { if (!source || source.length === 0) { return 0; @@ -567,3 +569,15 @@ export function normalizeEnvironment(value: string | null | undefined): string | // eslint-disable-next-line no-control-regex -- Deployment names cannot contain control characters. return name && name.length <= 64 && !/[\u0000-\u001f\u007f-\u009f]/u.test(name) ? name : undefined; } + +/** + * Sets the event's environment to the normalized value, or removes it when the value is not a valid name. + */ +export function setNormalizedEnvironment(event: Event, value: string | null | undefined): void { + const environment = normalizeEnvironment(value); + if (environment) { + event.environment = environment; + } else { + delete event.environment; + } +} diff --git a/packages/core/src/plugins/default/ConfigurationDefaultsPlugin.ts b/packages/core/src/plugins/default/ConfigurationDefaultsPlugin.ts index 6d153603..6a010a4c 100644 --- a/packages/core/src/plugins/default/ConfigurationDefaultsPlugin.ts +++ b/packages/core/src/plugins/default/ConfigurationDefaultsPlugin.ts @@ -1,4 +1,4 @@ -import { isEmpty, normalizeEnvironment, stringify } from "../../Utils.js"; +import { isEmpty, setNormalizedEnvironment, stringify } from "../../Utils.js"; import { EventPluginContext } from "../../plugins/EventPluginContext.js"; import { IEventPlugin } from "../../plugins/IEventPlugin.js"; @@ -9,12 +9,7 @@ export class ConfigurationDefaultsPlugin implements IEventPlugin { public run(context: EventPluginContext): Promise { const { dataExclusions, defaultData, defaultTags } = context.client.config; const ev = context.event; - const environment = normalizeEnvironment(ev.environment ?? context.client.config.environment); - if (environment) { - ev.environment = environment; - } else { - delete ev.environment; - } + setNormalizedEnvironment(ev, ev.environment ?? context.client.config.environment); if (defaultTags) { ev.tags = [...(ev.tags || []), ...defaultTags]; diff --git a/packages/core/src/plugins/default/DuplicateCheckerPlugin.ts b/packages/core/src/plugins/default/DuplicateCheckerPlugin.ts index a688c909..1203c7f4 100644 --- a/packages/core/src/plugins/default/DuplicateCheckerPlugin.ts +++ b/packages/core/src/plugins/default/DuplicateCheckerPlugin.ts @@ -1,6 +1,6 @@ import { InnerErrorInfo } from "../../models/data/ErrorInfo.js"; import { KnownEventDataKeys } from "../../models/Event.js"; -import { allowProcessToExitWithoutWaitingForTimerOrInterval, getHashCode } from "../../Utils.js"; +import { allowProcessToExitWithoutWaitingForTimerOrInterval, getHashCode, setNormalizedEnvironment } from "../../Utils.js"; import { EventPluginContext } from "../../plugins/EventPluginContext.js"; import { IEventPlugin } from "../../plugins/IEventPlugin.js"; @@ -123,6 +123,8 @@ class MergedEvent { public async resubmit(): Promise { this._context.event.count = this._count; + // Plugins between the defaults and this one can assign any value. + setNormalizedEnvironment(this._context.event, this._context.event.environment); await this._context.client.config.services.queue.enqueue(this._context.event); } diff --git a/packages/core/test/ExceptionlessClient.test.ts b/packages/core/test/ExceptionlessClient.test.ts index a12185ea..c48fd4a5 100644 --- a/packages/core/test/ExceptionlessClient.test.ts +++ b/packages/core/test/ExceptionlessClient.test.ts @@ -37,6 +37,25 @@ describe("ExceptionlessClient", () => { expect(context.event.reference_id).toBe(lastReferenceIdManager.getLast()); }); + test.each([ + [" QA\t", "QA"], + ["x".repeat(65), undefined] + ])("should normalize environment %j assigned by a later plugin", async (environment, expected) => { + const client = new ExceptionlessClient(); + client.config.apiKey = "UNIT_TEST_API_KEY"; + client.config.environment = "production"; + client.config.addPlugin("late-environment", 90, (context) => { + context.event.environment = environment; + return Promise.resolve(); + }); + const enqueue = vi.spyOn(client.config.services.queue, "enqueue").mockResolvedValue(); + + await client.submitLog("test"); + + expect(enqueue).toHaveBeenCalledOnce(); + expect(enqueue.mock.calls[0]?.[0].environment).toBe(expected); + }); + test("should accept undefined source", () => { const client = new ExceptionlessClient(); client.config.apiKey = "UNIT_TEST_API_KEY"; diff --git a/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts b/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts index b435683b..864f1b89 100644 --- a/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts +++ b/packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts @@ -100,6 +100,14 @@ describe("DuplicateCheckerPlugin", () => { expect(enqueue.mock.calls.map(([event]) => event.environment).sort()).toEqual(["Production", "production", "staging", undefined]); }); + test("should normalize the environment of a resubmitted duplicate", async () => { + const enqueue = vi.spyOn(client.config.services.queue, "enqueue").mockResolvedValue(); + await run(Exception1StackTrace, " staging\n"); + expect((await run(Exception1StackTrace, " staging\n")).cancelled).toBe(true); + await plugin.suspend(); + expect(enqueue.mock.calls.map(([event]) => event.environment)).toEqual(["staging"]); + }); + test("should ignore error without stack", async () => { await run(); const contextOfSecondRun = await run();