diff --git a/.agents/skills/exceptionless-javascript/SKILL.md b/.agents/skills/exceptionless-javascript/SKILL.md index 99e273e0..639fe024 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`, preserving the supplied casing after trimming. See [configuration.md](references/configuration.md). + ## Pick References Read only the references needed for the request. Start with the runtime for setup or the topic for an API question; add others only when relevant. Each reference contains focused examples and official documentation links. Use the [JavaScript overview](https://exceptionless.com/docs/clients/javascript/) for broader product context. 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/configuration.md b/.agents/skills/exceptionless-javascript/references/configuration.md index 721cc2f9..a58e3abe 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 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 For deeper guidance on PII removal, read [data-exclusions.md](data-exclusions.md). 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()); }); ``` 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..2fb06058 100644 --- a/README.md +++ b/README.md @@ -156,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 7645e812..d2456fa0 100644 --- a/packages/core/src/EventBuilder.ts +++ b/packages/core/src/EventBuilder.ts @@ -62,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/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 1d64be44..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; @@ -559,3 +561,23 @@ 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 : 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/configuration/Configuration.ts b/packages/core/src/configuration/Configuration.ts index 8bb93381..bab53683 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..6a010a4c 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, setNormalizedEnvironment, stringify } from "../../Utils.js"; import { EventPluginContext } from "../../plugins/EventPluginContext.js"; import { IEventPlugin } from "../../plugins/IEventPlugin.js"; @@ -9,6 +9,7 @@ export class ConfigurationDefaultsPlugin implements IEventPlugin { public run(context: EventPluginContext): Promise { const { dataExclusions, defaultData, defaultTags } = context.client.config; const ev = context.event; + 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 bd04a429..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"; @@ -51,11 +51,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); @@ -63,7 +64,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; @@ -73,6 +77,7 @@ export class DuplicateCheckerPlugin implements IEventPlugin { context.log.trace(`Enqueueing event with hash: ${hashCode} to cache`); this._processedHashCodes.push({ hash: hashCode, + environment, timestamp: now }); @@ -95,16 +100,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; } @@ -115,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/configuration/Configuration.test.ts b/packages/core/test/configuration/Configuration.test.ts index fc2aec8e..4d0a6881 100644 --- a/packages/core/test/configuration/Configuration.test.ts +++ b/packages/core/test/configuration/Configuration.test.ts @@ -3,6 +3,25 @@ import { describe, expect, test } from "vitest"; import { Configuration } from "../../src/configuration/Configuration.js"; describe("Configuration", () => { + 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"); + config.environment = "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)); + 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", () => { 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 90e948fa..a30ece8f 100644 --- a/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts +++ b/packages/core/test/plugins/default/ConfigurationDefaultsPlugin.test.ts @@ -7,6 +7,62 @@ 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"); + }); + + 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); + await new ConfigurationDefaultsPlugin().run(new EventPluginContext(client, builder.target, new EventContext())); + 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 ad758afc..864f1b89 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,9 @@ describe("DuplicateCheckerPlugin", () => { new EventContext() ); + if (environment) { + context.event.environment = environment; + } await plugin.run(context); return context; }; @@ -83,6 +86,28 @@ 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, "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", "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(); 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"; }); ```