diff --git a/packages/observability/package.json b/packages/observability/package.json index 4c4eb0999..d7dc791bd 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -59,6 +59,7 @@ }, "author": "engineering@thesys.dev", "devDependencies": { + "jsdom": "^26.1.0", "vitest": "^4.1.0" } } diff --git a/packages/observability/src/observability.test.ts b/packages/observability/src/observability.test.ts index c260c24f3..fabbf28ac 100644 --- a/packages/observability/src/observability.test.ts +++ b/packages/observability/src/observability.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; import type { ObservabilityEvent } from "./index"; import { observability } from "./index"; diff --git a/packages/observability/src/observability.ts b/packages/observability/src/observability.ts index 533ee599a..cfa621c63 100644 --- a/packages/observability/src/observability.ts +++ b/packages/observability/src/observability.ts @@ -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).detail; + if (event == null || typeof event !== "object") return undefined; + return event; +} + +type Registration = { + listener: (raw: Event) => void; + levels: Set; + all: boolean; +}; /** Internal — the package exports a single shared instance, not this factory. */ function createObservability(): Observability { - const listeners = new Map>(); + const registrations = new Map(); // A throwing listener must not break the emitter or other listeners. const deliver = (listener: Handler, event: ObservabilityEvent) => { @@ -27,24 +36,49 @@ 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(); - 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. @@ -52,10 +86,9 @@ function createObservability(): 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); diff --git a/packages/observability/tsconfig.json b/packages/observability/tsconfig.json index 050ddeb9a..cd6bc40f5 100644 --- a/packages/observability/tsconfig.json +++ b/packages/observability/tsconfig.json @@ -6,6 +6,7 @@ "compilerOptions": { "moduleResolution": "bundler", "module": "ESNext", + "lib": ["ES2022", "DOM"], "outDir": "./dist", "rootDir": "./src", "noEmit": true diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19bf98fb4..403899edf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1817,6 +1817,9 @@ importers: packages/observability: devDependencies: + jsdom: + specifier: ^26.1.0 + version: 26.1.0 vitest: specifier: ^4.1.0 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))