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
2 changes: 2 additions & 0 deletions .agents/skills/exceptionless-javascript/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
});
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 5 additions & 5 deletions .agents/skills/exceptionless-javascript/references/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
context.log.info("Deployment plugin started");
context.log.info("Build information plugin started");
return Promise.resolve();
}

run(context: EventPluginContext): Promise<void> {
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());
});
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/EventBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/ExceptionlessClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setInterval> | undefined;
Expand Down Expand Up @@ -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) {
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/Utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Event } from "./models/Event.js";

export function getHashCode(source: string): number {
if (!source || source.length === 0) {
return 0;
Expand Down Expand Up @@ -559,3 +561,23 @@ export function allowProcessToExitWithoutWaitingForTimerOrInterval(timeoutOrInte
(timeoutOrIntervalId as { unref: () => ReturnType<typeof setTimeout> }).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;
}
}
17 changes: 16 additions & 1 deletion packages/core/src/configuration/Configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,29 @@ 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";
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(),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/models/Event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -9,6 +9,7 @@ export class ConfigurationDefaultsPlugin implements IEventPlugin {
public run(context: EventPluginContext): Promise<void> {
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];
Expand Down
16 changes: 13 additions & 3 deletions packages/core/src/plugins/default/DuplicateCheckerPlugin.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -51,19 +51,23 @@ 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);
context.log.info("Ignoring duplicate event with hash: " + hashCode);
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;
Expand All @@ -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
});

Expand All @@ -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;
}
Expand All @@ -115,6 +123,8 @@ class MergedEvent {

public async resubmit(): Promise<void> {
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);
}

Expand Down
19 changes: 19 additions & 0 deletions packages/core/test/ExceptionlessClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
19 changes: 19 additions & 0 deletions packages/core/test/configuration/Configuration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
expect(Object.keys(serialized)).not.toContain("environment");
});

describe("should add default", () => {
const userDataKey: string = "user";
const user = {
Expand Down
Loading
Loading