Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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`. See [configuration.md](references/configuration.md).
Comment thread
ejsmith marked this conversation as resolved.
Outdated

## Official Docs

Primary docs:
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 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).
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
ejsmith marked this conversation as resolved.
Outdated

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
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/EventBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,3 +559,11 @@ 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.toLowerCase() : undefined;
Comment thread
ejsmith marked this conversation as resolved.
Outdated
}
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, normalizeEnvironment, stringify } from "../../Utils.js";
import { EventPluginContext } from "../../plugins/EventPluginContext.js";
import { IEventPlugin } from "../../plugins/IEventPlugin.js";

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

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) {
Expand All @@ -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;
}
Expand Down
15 changes: 15 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,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("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
17 changes: 15 additions & 2 deletions packages/core/test/plugins/default/DuplicateCheckerPlugin.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -51,7 +51,7 @@ describe("DuplicateCheckerPlugin", () => {
plugin = new DuplicateCheckerPlugin(() => now, 50);
});

const run = async (stackTrace?: StackFrameInfo[]): Promise<EventPluginContext> => {
const run = async (stackTrace?: StackFrameInfo[], environment?: string): Promise<EventPluginContext> => {
// TODO: Generate unique stack traces based on test data.
const context = new EventPluginContext(
client,
Expand All @@ -68,6 +68,7 @@ describe("DuplicateCheckerPlugin", () => {
new EventContext()
);

if (environment) context.event.environment = environment;
await plugin.run(context);
return context;
};
Expand All @@ -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();
Expand Down