Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/observability/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
},
"author": "engineering@thesys.dev",
"devDependencies": {
"jsdom": "^26.1.0",
"vitest": "^4.1.0"
}
}
1 change: 1 addition & 0 deletions packages/observability/src/observability.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ObservabilityEvent } from "./index";
import { observability } from "./index";
Expand Down
75 changes: 54 additions & 21 deletions packages/observability/src/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,25 @@ import type {
Remove,
} from "./types";

/**
* All listeners live in one map keyed by level plus a
* literal "all" key for `listenAll`. An event at level
* L is delivered to the L level and "all".
*/
const ALL_KEY = "all";
/** Private — not part of the public API. */
const EVENT_TYPE = "openui:observability";

function unwrap(raw: Event): ObservabilityEvent | undefined {
if (!("detail" in raw)) return undefined;
const event = (raw as CustomEvent<ObservabilityEvent>).detail;
if (event == null || typeof event !== "object") return undefined;
return event;
}

type Registration = {
listener: (raw: Event) => void;
levels: Set<ObservabilityLevel>;
all: boolean;
};

/** Internal — the package exports a single shared instance, not this factory. */
function createObservability(): Observability {
const listeners = new Map<string, Set<Handler>>();
const registrations = new Map<Handler, Registration>();

// A throwing listener must not break the emitter or other listeners.
const deliver = (listener: Handler, event: ObservabilityEvent) => {
Expand All @@ -27,35 +36,59 @@ function createObservability(): Observability {
}
};

const subscribe = (key: string, handler: Handler): Remove => {
let set = listeners.get(key);
if (!set) {
set = new Set();
listeners.set(key, set);
const subscribe = (handler: Handler, keys: "all" | ObservabilityLevel[]): Remove => {
// Delivery is browser-only; no-op on the server.
if (typeof window === "undefined") return () => {};

let registration = registrations.get(handler);
if (!registration) {
const listener = (raw: Event) => {
const current = registrations.get(handler);
if (!current) return;
const event = unwrap(raw);
if (!event) return;
if (!current.all && !current.levels.has(event.level)) return;
deliver(handler, event);
};
window.addEventListener(EVENT_TYPE, listener);
registration = { listener, levels: new Set(), all: false };
registrations.set(handler, registration);
}
set.add(handler);
if (keys === "all") {
registration.all = true;
} else {
for (const level of keys) registration.levels.add(level);
}

return () => {
set.delete(handler);
const current = registrations.get(handler);
if (!current) return;
if (keys === "all") {
current.all = false;
} else {
for (const level of keys) current.levels.delete(level);
}
if (!current.all && current.levels.size === 0) {
window.removeEventListener(EVENT_TYPE, current.listener);
registrations.delete(handler);
}
};
};

const emit = (level: ObservabilityLevel, detail: ObservabilityDetail): void => {
if (typeof window === "undefined") return;
const event: ObservabilityEvent = { level, timestamp: Date.now(), detail };
const targets = new Set<Handler>();
listeners.get(level)?.forEach((listener) => targets.add(listener));
listeners.get(ALL_KEY)?.forEach((listener) => targets.add(listener));
targets.forEach((listener) => deliver(listener, event));
window.dispatchEvent(new CustomEvent(EVENT_TYPE, { detail: event }));
};

// The bus IS the emit function, with the rest of the API attached to it.
const bus = emit as Observability;

bus.listen = (level, handler) => {
const levels = Array.isArray(level) ? level : [level];
const removers = levels.map((l) => subscribe(l, handler as Handler));
return () => removers.forEach((remove) => remove());
return subscribe(handler as Handler, levels);
};
bus.listenAll = (handler) => subscribe(ALL_KEY, handler);
bus.listenAll = (handler) => subscribe(handler, "all");
bus.info = (detail) => emit("info", detail);
bus.warn = (detail) => emit("warning", detail);
bus.error = (detail) => emit("error", detail);
Expand Down
1 change: 1 addition & 0 deletions packages/observability/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"compilerOptions": {
"moduleResolution": "bundler",
"module": "ESNext",
"lib": ["ES2022", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"noEmit": true
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading