diff --git a/packages/react-lang/package.json b/packages/react-lang/package.json index b60ef3c44..b934dd601 100644 --- a/packages/react-lang/package.json +++ b/packages/react-lang/package.json @@ -7,6 +7,13 @@ "main": "dist/index.cjs", "module": "dist/index.mjs", "types": "dist/index.d.cts", + "typesVersions": { + "*": { + "observability": [ + "dist/observability/index.d.cts" + ] + } + }, "sideEffects": [ "./dist/index.mjs", "./dist/index.cjs", @@ -37,6 +44,16 @@ "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } + }, + "./observability": { + "import": { + "types": "./dist/observability/index.d.mts", + "default": "./dist/observability/index.mjs" + }, + "require": { + "types": "./dist/observability/index.d.cts", + "default": "./dist/observability/index.cjs" + } } }, "react-native": "./dist/index.native.cjs", diff --git a/packages/react-lang/src/hooks/useStreamingObservability.ts b/packages/react-lang/src/hooks/useStreamingObservability.ts index 3c2bd4013..4498c806a 100644 --- a/packages/react-lang/src/hooks/useStreamingObservability.ts +++ b/packages/react-lang/src/hooks/useStreamingObservability.ts @@ -1,6 +1,13 @@ import type { OpenUIError, ParseResult } from "@openuidev/lang-core"; import { observability } from "@openuidev/observability"; import { useEffect, useRef } from "react"; +import { + STREAM_EVENT_KIND, + STREAM_PHASE_SETTLED, + STREAM_PHASE_STREAMING, + type SettledStreamEventDetail, + type StreamPhase, +} from "../observability/events/stream"; type CurrentRef = { current: T }; @@ -23,7 +30,7 @@ export interface StreamingObservabilityState { export interface StreamingObservabilityUpdate { id: string; - phase: "streaming" | "settled"; + phase: StreamPhase; updateIndex: number; } @@ -67,7 +74,7 @@ export function advanceStreamingObservability( state.hasPublishedStreamingSnapshot = true; state.lastResponse = response; state.updateIndex += 1; - return { id: state.id, phase: "streaming", updateIndex: state.updateIndex }; + return { id: state.id, phase: STREAM_PHASE_STREAMING, updateIndex: state.updateIndex }; } // A Renderer mounted only for static or historical content never starts a stream. @@ -81,7 +88,7 @@ export function advanceStreamingObservability( state.settled = true; state.lastSettledErrorKey = settledErrorKey; - return { id: state.id, phase: "settled", updateIndex: state.updateIndex }; + return { id: state.id, phase: STREAM_PHASE_SETTLED, updateIndex: state.updateIndex }; } function parserMetadata(result: ParseResult | null) { @@ -121,7 +128,7 @@ export function useStreamingObservability({ if (update) { observability.info({ id: update.id, - kind: "react-lang:stream", + kind: STREAM_EVENT_KIND, phase: update.phase, updateIndex: update.updateIndex, response, @@ -133,11 +140,11 @@ export function useStreamingObservability({ return; } - if (update?.phase === "settled") { + if (update?.phase === STREAM_PHASE_SETTLED) { observability(errors.length > 0 ? "error" : "info", { id: update.id, - kind: "react-lang:stream", - phase: update.phase, + kind: STREAM_EVENT_KIND, + phase: STREAM_PHASE_SETTLED, updateIndex: update.updateIndex, response, responseLength: response?.length ?? 0, @@ -148,7 +155,7 @@ export function useStreamingObservability({ errors.length > 0 ? `OpenUI Lang settled with ${errors.length} error${errors.length === 1 ? "" : "s"}` : "OpenUI Lang settled", - }); + } satisfies SettledStreamEventDetail); } }, [isStreaming, response, result, errorsRef, errorRevision]); } diff --git a/packages/react-lang/src/observability/core/batcher.test.ts b/packages/react-lang/src/observability/core/batcher.test.ts new file mode 100644 index 000000000..7079a7033 --- /dev/null +++ b/packages/react-lang/src/observability/core/batcher.test.ts @@ -0,0 +1,225 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Batcher } from "./batcher"; +import * as transport from "./transport"; +import type { WireEvent } from "./wire"; + +const transportConfig = { + endpoint: "https://ingest.example.com/v1/events", + apiKey: "test-key", + debug: false, +}; + +function wireEvent(index: number): WireEvent { + return { + id: `event-${index}`, + kind: "react-lang:stream", + level: "info", + timestamp: index, + updateIndex: index, + errorCount: 0, + }; +} + +describe("Batcher", () => { + beforeEach(() => { + vi.spyOn(transport, "sendEnvelope").mockResolvedValue(true); + vi.spyOn(transport, "sendEnvelopeBeacon").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("flushes on interval when the queue is non-empty", async () => { + vi.useFakeTimers(); + const batcher = new Batcher(transportConfig); + batcher.enqueue(wireEvent(1)); + + await vi.advanceTimersByTimeAsync(5000); + + expect(transport.sendEnvelope).toHaveBeenCalledTimes(1); + await batcher.close(); + }); + + it("flushes when the queue reaches 50 events", async () => { + const batcher = new Batcher(transportConfig); + for (let index = 0; index < 50; index++) { + batcher.enqueue(wireEvent(index)); + } + + await Promise.resolve(); + expect(transport.sendEnvelope).toHaveBeenCalledTimes(1); + const envelope = vi.mocked(transport.sendEnvelope).mock.calls[0]?.[0]; + expect(envelope?.events).toHaveLength(50); + await batcher.close(); + }); + + it("explicit flush resolves true when transport accepts every batch", async () => { + const batcher = new Batcher(transportConfig); + batcher.enqueue(wireEvent(1)); + batcher.enqueue(wireEvent(2)); + + await expect(batcher.flush()).resolves.toBe(true); + expect(transport.sendEnvelope).toHaveBeenCalledTimes(1); + await batcher.close(); + }); + + it("explicit flush resolves false when transport drops a batch", async () => { + vi.mocked(transport.sendEnvelope).mockResolvedValue(false); + const batcher = new Batcher(transportConfig); + batcher.enqueue(wireEvent(1)); + + await expect(batcher.flush()).resolves.toBe(false); + await batcher.close(); + }); + + it("close resolves false when transport drops during final flush", async () => { + vi.mocked(transport.sendEnvelope).mockResolvedValue(false); + const batcher = new Batcher(transportConfig); + batcher.enqueue(wireEvent(1)); + + await expect(batcher.close()).resolves.toBe(false); + }); + + it("flush resolves false within timeoutMs while send is in retry backoff", async () => { + vi.useFakeTimers(); + vi.restoreAllMocks(); + vi.spyOn(transport, "sendEnvelopeBeacon").mockImplementation(() => {}); + + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 500 })); + vi.stubGlobal("fetch", fetchMock); + + const batcher = new Batcher(transportConfig); + batcher.enqueue(wireEvent(1)); + + const flushPromise = batcher.flush(100); + await vi.advanceTimersByTimeAsync(100); + + await expect(flushPromise).resolves.toBe(false); + await vi.runAllTimersAsync(); + await batcher.close(); + }); + + it("stamps droppedEvents from queue overflow on the next envelope", async () => { + const flushSpy = vi.spyOn(Batcher.prototype, "flushNextBatch").mockResolvedValue({ + accepted: true, + timedOut: false, + }); + const batcher = new Batcher(transportConfig); + + for (let index = 0; index < 501; index++) { + batcher.enqueue(wireEvent(index)); + } + + flushSpy.mockRestore(); + await batcher.flush(); + + const envelopes = vi.mocked(transport.sendEnvelope).mock.calls.map(([payload]) => payload); + expect(envelopes.some((payload) => payload.droppedEvents === 1)).toBe(true); + await batcher.close(); + }); + + it("flush awaits a threshold-initiated in-flight send and reflects its failure", async () => { + let resolveSend!: (accepted: boolean) => void; + vi.mocked(transport.sendEnvelope).mockImplementation( + () => new Promise((resolve) => (resolveSend = resolve)), + ); + const batcher = new Batcher(transportConfig); + for (let index = 0; index < 50; index++) { + batcher.enqueue(wireEvent(index)); + } + expect(transport.sendEnvelope).toHaveBeenCalledTimes(1); + + let settled = false; + const flushPromise = batcher.flush(); + void flushPromise.then(() => { + settled = true; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + + resolveSend(false); + await expect(flushPromise).resolves.toBe(false); + vi.mocked(transport.sendEnvelope).mockResolvedValue(true); + await batcher.close(); + }); + + it("flush resolves false within timeoutMs when the in-flight send is stuck", async () => { + vi.useFakeTimers(); + vi.mocked(transport.sendEnvelope).mockImplementation(() => new Promise(() => {})); + const batcher = new Batcher(transportConfig); + for (let index = 0; index < 50; index++) { + batcher.enqueue(wireEvent(index)); + } + + const flushPromise = batcher.flush(100); + await vi.advanceTimersByTimeAsync(100); + await expect(flushPromise).resolves.toBe(false); + + const closePromise = batcher.close(); + await vi.runAllTimersAsync(); + await expect(closePromise).resolves.toBe(false); + }); + + it("stamps droppedEvents from a transport-dropped batch on the next accepted envelope", async () => { + vi.mocked(transport.sendEnvelope).mockResolvedValueOnce(false); + const batcher = new Batcher(transportConfig); + batcher.enqueue(wireEvent(1)); + batcher.enqueue(wireEvent(2)); + await expect(batcher.flush()).resolves.toBe(false); + + batcher.enqueue(wireEvent(3)); + await expect(batcher.flush()).resolves.toBe(true); + + const envelopes = vi.mocked(transport.sendEnvelope).mock.calls.map(([payload]) => payload); + expect(envelopes[0]?.droppedEvents).toBeUndefined(); + expect(envelopes[1]?.droppedEvents).toBe(2); + await batcher.close(); + }); + + it("re-stamps queue-overflow drops carried by a failed envelope on the next accepted one", async () => { + const flushSpy = vi.spyOn(Batcher.prototype, "flushNextBatch").mockResolvedValue({ + accepted: true, + timedOut: false, + }); + const batcher = new Batcher(transportConfig); + for (let index = 0; index < 501; index++) { + batcher.enqueue(wireEvent(index)); + } + flushSpy.mockRestore(); + + vi.mocked(transport.sendEnvelope).mockResolvedValueOnce(false); + await expect(batcher.flush()).resolves.toBe(false); + + const envelopes = vi.mocked(transport.sendEnvelope).mock.calls.map(([payload]) => payload); + expect(envelopes[0]?.droppedEvents).toBe(1); + expect(envelopes[1]?.droppedEvents).toBe(51); + await batcher.close(); + }); + + it("pagehide flushes through the beacon path", () => { + const batcher = new Batcher(transportConfig); + batcher.enqueue(wireEvent(1)); + + window.dispatchEvent(new Event("pagehide")); + + expect(transport.sendEnvelopeBeacon).toHaveBeenCalledTimes(1); + expect(transport.sendEnvelope).not.toHaveBeenCalled(); + }); + + it("visibilitychange to hidden flushes through the beacon path", () => { + const batcher = new Batcher(transportConfig); + batcher.enqueue(wireEvent(1)); + + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => "hidden", + }); + document.dispatchEvent(new Event("visibilitychange")); + + expect(transport.sendEnvelopeBeacon).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-lang/src/observability/core/batcher.ts b/packages/react-lang/src/observability/core/batcher.ts new file mode 100644 index 000000000..8b0b49c2b --- /dev/null +++ b/packages/react-lang/src/observability/core/batcher.ts @@ -0,0 +1,180 @@ +import { EventQueue } from "./queue"; +import { sendEnvelope, sendEnvelopeBeacon, type TransportConfig } from "./transport"; +import { SDK_VERSION, type WireEnvelope, type WireEvent } from "./wire"; + +const FLUSH_INTERVAL_MS = 5000; +const BATCH_SIZE = 50; +const DEFAULT_FLUSH_TIMEOUT_MS = 10_000; + +function buildEnvelope(events: WireEvent[], droppedEvents: number): WireEnvelope { + return { + v: 1, + sentAt: Date.now(), + sdk: { name: "react-lang", version: SDK_VERSION }, + ...(droppedEvents > 0 ? { droppedEvents } : {}), + events, + }; +} + +type BatchSendResult = { accepted: boolean; timedOut: boolean }; + +export class Batcher { + private readonly queue = new EventQueue(); + private readonly inFlight = new Set>(); + private droppedEvents = 0; + private intervalId: ReturnType | null = null; + private closed = false; + private readonly onPageHide: () => void; + private readonly onVisibilityChange: () => void; + + constructor(private readonly transport: TransportConfig) { + this.onPageHide = () => { + this.flushBeaconSync(); + }; + this.onVisibilityChange = () => { + if (document.visibilityState === "hidden") { + this.flushBeaconSync(); + } + }; + + if (typeof window !== "undefined" && typeof document !== "undefined") { + window.addEventListener("pagehide", this.onPageHide); + document.addEventListener("visibilitychange", this.onVisibilityChange); + } + } + + enqueue(event: WireEvent): void { + if (this.closed) return; + this.queue.enqueue(event); + if (this.queue.size >= BATCH_SIZE) { + void this.flushNextBatch(); + } + this.ensureInterval(); + } + + async flush(timeoutMs = DEFAULT_FLUSH_TIMEOUT_MS): Promise { + if (this.closed && this.queue.size === 0 && this.inFlight.size === 0) return true; + + const deadline = Date.now() + timeoutMs; + let allAccepted = true; + + while (this.queue.size > 0) { + if (Date.now() >= deadline) return false; + + const result = await this.flushNextBatch(deadline); + if (result.timedOut) return false; + if (!result.accepted) allAccepted = false; + } + + while (this.inFlight.size > 0) { + if (Date.now() >= deadline) return false; + + const pending = Promise.all([...this.inFlight]).then((results): BatchSendResult => ({ + accepted: results.every((result) => result.accepted), + timedOut: false, + })); + const result = await this.raceDeadline(pending, deadline); + if (result.timedOut) return false; + if (!result.accepted) allAccepted = false; + } + + this.stopInterval(); + return allAccepted; + } + + close(): Promise { + this.closed = true; + this.detachPageListeners(); + this.stopInterval(); + return this.flush(); + } + + private ensureInterval(): void { + if (this.intervalId !== null || this.closed) return; + this.intervalId = setInterval(() => { + if (this.queue.size === 0) { + this.stopInterval(); + return; + } + void this.flushNextBatch(); + }, FLUSH_INTERVAL_MS); + } + + private stopInterval(): void { + if (this.intervalId === null) return; + clearInterval(this.intervalId); + this.intervalId = null; + } + + private detachPageListeners(): void { + if (typeof window === "undefined" || typeof document === "undefined") return; + window.removeEventListener("pagehide", this.onPageHide); + document.removeEventListener("visibilitychange", this.onVisibilityChange); + } + + private flushBeaconSync(): void { + while (this.queue.size > 0) { + const droppedEvents = this.takeDroppedEvents(); + const events = this.queue.drain(BATCH_SIZE); + if (events.length === 0) break; + sendEnvelopeBeacon(buildEnvelope(events, droppedEvents), this.transport); + } + this.stopInterval(); + } + + private takeDroppedEvents(): number { + const count = this.droppedEvents + this.queue.readAndResetDropped(); + this.droppedEvents = 0; + return count; + } + + private async flushNextBatch(deadline?: number): Promise { + if (this.queue.size === 0) return { accepted: true, timedOut: false }; + + const droppedEvents = this.takeDroppedEvents(); + const events = this.queue.drain(BATCH_SIZE); + if (events.length === 0) { + this.droppedEvents += droppedEvents; + return { accepted: true, timedOut: false }; + } + + const sendPromise = sendEnvelope(buildEnvelope(events, droppedEvents), this.transport) + .catch(() => false) + .then((accepted): BatchSendResult => { + if (!accepted) this.droppedEvents += events.length + droppedEvents; + return { accepted, timedOut: false }; + }); + + this.inFlight.add(sendPromise); + void sendPromise.then(() => { + this.inFlight.delete(sendPromise); + }); + + return this.raceDeadline(sendPromise, deadline); + } + + private async raceDeadline( + sendPromise: Promise, + deadline?: number, + ): Promise { + if (deadline === undefined) { + return sendPromise; + } + + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + return { accepted: false, timedOut: true }; + } + + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => resolve({ accepted: false, timedOut: true }), remainingMs); + }); + + try { + return await Promise.race([sendPromise, timeoutPromise]); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } + } +} diff --git a/packages/react-lang/src/observability/core/client.test.ts b/packages/react-lang/src/observability/core/client.test.ts new file mode 100644 index 000000000..1ab295fad --- /dev/null +++ b/packages/react-lang/src/observability/core/client.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +import type { Observability, ObservabilityEvent } from "@openuidev/observability"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CloudObservabilityClient } from "./client"; +import * as transport from "./transport"; + +function createFakeBus() { + const handlers: Array<(event: ObservabilityEvent) => void> = []; + const remove = vi.fn(); + const bus = { + listenAll: (handler: (event: ObservabilityEvent) => void) => { + handlers.push(handler); + return remove; + }, + } as unknown as Observability; + return { bus, handlers, remove }; +} + +const options = { + endpoint: "https://ingest.example.com/v1/events", + apiKey: "test-key", + capture: "full" as const, + sampleRate: 1, + debug: false, +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("CloudObservabilityClient bus injection", () => { + it("listens on the injected bus and detaches on close", async () => { + vi.spyOn(transport, "sendEnvelope").mockResolvedValue(true); + const { bus, handlers, remove } = createFakeBus(); + + const client = new CloudObservabilityClient(options, bus); + expect(handlers).toHaveLength(1); + + handlers[0]!({ + level: "info", + timestamp: 1_700_000_000_000, + detail: { + id: "stream-1", + kind: "react-lang:stream", + phase: "settled", + updateIndex: 1, + errorCount: 0, + }, + }); + + await client.flush(); + expect(transport.sendEnvelope).toHaveBeenCalledTimes(1); + expect(vi.mocked(transport.sendEnvelope).mock.calls[0]?.[0]?.events).toEqual([ + expect.objectContaining({ id: "stream-1", kind: "react-lang:stream" }), + ]); + + await client.close(); + expect(remove).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/react-lang/src/observability/core/client.ts b/packages/react-lang/src/observability/core/client.ts new file mode 100644 index 000000000..04ec1d7cc --- /dev/null +++ b/packages/react-lang/src/observability/core/client.ts @@ -0,0 +1,43 @@ +import { observability, type Observability, type Remove } from "@openuidev/observability"; +import { Batcher } from "./batcher"; +import { selectEvent, type SelectorOptions } from "./selector"; +import type { TransportConfig } from "./transport"; + +export interface CloudClientOptions extends SelectorOptions, TransportConfig {} + +export class CloudObservabilityClient { + private readonly removeListener: Remove; + private readonly batcher: Batcher; + + constructor(options: CloudClientOptions, bus: Observability = observability) { + this.batcher = new Batcher({ + endpoint: options.endpoint, + apiKey: options.apiKey, + debug: options.debug, + }); + + this.removeListener = bus.listenAll((event) => { + try { + const wireEvent = selectEvent(event, options); + if (wireEvent) this.batcher.enqueue(wireEvent); + } catch (error) { + if (options.debug) { + console.warn( + "[@openuidev/react-lang/observability]", + "listener threw; dropping event", + error, + ); + } + } + }); + } + + flush(timeoutMs?: number): Promise { + return this.batcher.flush(timeoutMs); + } + + close(): Promise { + this.removeListener(); + return this.batcher.close(); + } +} diff --git a/packages/react-lang/src/observability/core/queue.test.ts b/packages/react-lang/src/observability/core/queue.test.ts new file mode 100644 index 000000000..f682fef02 --- /dev/null +++ b/packages/react-lang/src/observability/core/queue.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { EventQueue } from "./queue"; +import type { WireEvent } from "./wire"; + +function wireEvent(id: string): WireEvent { + return { + id, + kind: "react-lang:stream", + level: "info", + timestamp: 1, + updateIndex: 1, + errorCount: 0, + }; +} + +describe("EventQueue", () => { + it("preserves FIFO order", () => { + const queue = new EventQueue(10); + queue.enqueue(wireEvent("a")); + queue.enqueue(wireEvent("b")); + queue.enqueue(wireEvent("c")); + + expect(queue.drain(2).map((event) => event.id)).toEqual(["a", "b"]); + expect(queue.drain(10).map((event) => event.id)).toEqual(["c"]); + }); + + it("drops the oldest event on overflow and counts drops", () => { + const queue = new EventQueue(2); + queue.enqueue(wireEvent("a")); + queue.enqueue(wireEvent("b")); + queue.enqueue(wireEvent("c")); + + expect(queue.size).toBe(2); + expect(queue.drain(10).map((event) => event.id)).toEqual(["b", "c"]); + expect(queue.readAndResetDropped()).toBe(1); + expect(queue.readAndResetDropped()).toBe(0); + }); +}); diff --git a/packages/react-lang/src/observability/core/queue.ts b/packages/react-lang/src/observability/core/queue.ts new file mode 100644 index 000000000..258a6f385 --- /dev/null +++ b/packages/react-lang/src/observability/core/queue.ts @@ -0,0 +1,35 @@ +import type { WireEvent } from "./wire"; + +const DEFAULT_CAPACITY = 500; + +export class EventQueue { + private readonly items: WireEvent[] = []; + private dropped = 0; + + constructor(private readonly capacity = DEFAULT_CAPACITY) {} + + get size(): number { + return this.items.length; + } + + enqueue(event: WireEvent): void { + if (this.items.length >= this.capacity) { + this.items.shift(); + this.dropped += 1; + } + this.items.push(event); + } + + /** Removes and returns up to `limit` events in FIFO order. */ + drain(limit: number): WireEvent[] { + if (limit <= 0 || this.items.length === 0) return []; + return this.items.splice(0, Math.min(limit, this.items.length)); + } + + /** Returns the overflow drop count since the last read and resets it to zero. */ + readAndResetDropped(): number { + const count = this.dropped; + this.dropped = 0; + return count; + } +} diff --git a/packages/react-lang/src/observability/core/selector.test.ts b/packages/react-lang/src/observability/core/selector.test.ts new file mode 100644 index 000000000..816c7a0e4 --- /dev/null +++ b/packages/react-lang/src/observability/core/selector.test.ts @@ -0,0 +1,83 @@ +import type { ObservabilityEvent } from "@openuidev/observability"; +import { describe, expect, it, vi } from "vitest"; +import { selectEvent } from "./selector"; + +function settledEvent(overrides: Record = {}): ObservabilityEvent { + return { + level: "info", + timestamp: 1_700_000_000_000, + detail: { + id: "stream-1", + kind: "react-lang:stream", + phase: "settled", + updateIndex: 2, + errorCount: 0, + response: "hello", + message: "OpenUI Lang settled", + errors: [], + parser: { + incomplete: false, + unresolved: [], + orphaned: [], + statementCount: 1, + }, + ...overrides, + }, + }; +} + +describe("selectEvent", () => { + it("samples deterministically per id and respects rate 0 and 1", () => { + const id = "deterministic-id-a"; + const options = { capture: "full" as const, sampleRate: 0.5, debug: false }; + + const first = selectEvent(settledEvent({ id }), options); + const second = selectEvent(settledEvent({ id }), options); + expect(Boolean(first)).toBe(Boolean(second)); + + const decisions = Array.from({ length: 100 }, (_, index) => + Boolean(selectEvent(settledEvent({ id: `sample-id-${index}` }), options)), + ); + expect(decisions.some(Boolean)).toBe(true); + expect(decisions.some((kept) => !kept)).toBe(true); + + expect(selectEvent(settledEvent({ id }), { ...options, sampleRate: 1 })).not.toBeNull(); + expect(selectEvent(settledEvent({ id }), { ...options, sampleRate: 0 })).toBeNull(); + }); + + it("beforeSend can mutate or drop events", () => { + const mutated = selectEvent(settledEvent(), { + capture: "full", + sampleRate: 1, + debug: false, + beforeSend: (event) => ({ ...event, message: "mutated" }), + }); + const dropped = selectEvent(settledEvent(), { + capture: "full", + sampleRate: 1, + debug: false, + beforeSend: () => null, + }); + + expect(mutated?.message).toBe("mutated"); + expect(dropped).toBeNull(); + }); + + it("drops when beforeSend throws without propagating", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + expect( + selectEvent(settledEvent(), { + capture: "full", + sampleRate: 1, + debug: true, + beforeSend: () => { + throw new Error("boom"); + }, + }), + ).toBeNull(); + + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/packages/react-lang/src/observability/core/selector.ts b/packages/react-lang/src/observability/core/selector.ts new file mode 100644 index 000000000..299903cbc --- /dev/null +++ b/packages/react-lang/src/observability/core/selector.ts @@ -0,0 +1,61 @@ +import type { ObservabilityEvent } from "@openuidev/observability"; +import { selectStreamEvent } from "../events/stream"; +import type { WireEvent } from "./wire"; + +export interface SelectorOptions { + capture: "full" | "minimal"; + sampleRate: number; + beforeSend?: (event: WireEvent) => WireEvent | null; + debug: boolean; +} + +/** Kind-specific selectors. Add a new event by appending its select* function. */ +const eventSelectors = [selectStreamEvent] as const; + +function hashToUnitInterval(input: string): number { + let hash = 2166136261; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 4294967296; +} + +function debugWarn(debug: boolean, message: string, error?: unknown): void { + if (!debug) return; + if (error === undefined) { + console.warn("[@openuidev/react-lang/observability]", message); + return; + } + console.warn("[@openuidev/react-lang/observability]", message, error); +} + +function shapeEvent( + event: ObservabilityEvent, + capture: SelectorOptions["capture"], +): WireEvent | null { + for (const select of eventSelectors) { + const shaped = select(event, capture); + if (shaped) return shaped; + } + return null; +} + +/** Shared pipeline: kind-specific selection, then sampling and beforeSend. */ +export function selectEvent(event: ObservabilityEvent, options: SelectorOptions): WireEvent | null { + const shaped = shapeEvent(event, options.capture); + if (!shaped) return null; + + if (options.sampleRate < 1 && hashToUnitInterval(shaped.id) >= options.sampleRate) { + return null; + } + + if (!options.beforeSend) return shaped; + + try { + return options.beforeSend(shaped); + } catch (error) { + debugWarn(options.debug, "beforeSend threw; dropping event", error); + return null; + } +} diff --git a/packages/react-lang/src/observability/core/transport.test.ts b/packages/react-lang/src/observability/core/transport.test.ts new file mode 100644 index 000000000..34039557b --- /dev/null +++ b/packages/react-lang/src/observability/core/transport.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sendEnvelope, sendEnvelopeBeacon } from "./transport"; +import { SDK_VERSION, type WireEnvelope } from "./wire"; + +const config = { + endpoint: "https://ingest.example.com/v1/events", + apiKey: "test-key", + debug: false, +}; + +function envelope(events = 1): WireEnvelope { + return { + v: 1, + sentAt: Date.now(), + sdk: { name: "react-lang", version: SDK_VERSION }, + events: Array.from({ length: events }, (_, index) => ({ + id: `event-${index}`, + kind: "react-lang:stream" as const, + level: "info" as const, + timestamp: 1, + updateIndex: 1, + errorCount: 0, + })), + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("sendEnvelope", () => { + it("sends the expected envelope shape with auth header", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const payload = envelope(2); + await expect(sendEnvelope(payload, config)).resolves.toBe(true); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(config.endpoint); + expect(init.method).toBe("POST"); + expect(init.keepalive).toBe(true); + expect(init.headers).toMatchObject({ + "content-type": "application/json", + authorization: "Bearer test-key", + }); + expect(JSON.parse(String(init.body))).toEqual(payload); + }); + + it("retries 5xx responses then drops", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 500 })) + .mockResolvedValueOnce(new Response(null, { status: 502 })) + .mockResolvedValueOnce(new Response(null, { status: 503 })); + vi.stubGlobal("fetch", fetchMock); + + const promise = sendEnvelope(envelope(), config); + await vi.runAllTimersAsync(); + await expect(promise).resolves.toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(3); + vi.useRealTimers(); + }); + + it("drops 4xx responses without retry", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 400 })); + vi.stubGlobal("fetch", fetchMock); + + await expect(sendEnvelope(envelope(), config)).resolves.toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("retries 429 responses", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 429, + headers: { "Retry-After": "1" }, + }), + ) + .mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const promise = sendEnvelope(envelope(), config); + await vi.runAllTimersAsync(); + await expect(promise).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + it("retries network errors", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const promise = sendEnvelope(envelope(), config); + await vi.runAllTimersAsync(); + await expect(promise).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); +}); + +describe("sendEnvelopeBeacon", () => { + it("appends apiKey as a query param when sendBeacon succeeds", () => { + const sendBeacon = vi.fn().mockReturnValue(true); + vi.stubGlobal("navigator", { sendBeacon }); + + sendEnvelopeBeacon(envelope(), config); + + expect(sendBeacon).toHaveBeenCalledTimes(1); + const [url] = sendBeacon.mock.calls[0] as [string, Blob]; + expect(url).toContain("apiKey=test-key"); + }); + + it("falls back to fetch keepalive when sendBeacon returns false", () => { + const sendBeacon = vi.fn().mockReturnValue(false); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("navigator", { sendBeacon }); + vi.stubGlobal("fetch", fetchMock); + + sendEnvelopeBeacon(envelope(), config); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("apiKey=test-key"); + expect(init.keepalive).toBe(true); + }); + + it("falls back to fetch keepalive when sendBeacon is unavailable", () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("navigator", {}); + vi.stubGlobal("fetch", fetchMock); + + sendEnvelopeBeacon(envelope(), config); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toContain("apiKey=test-key"); + }); +}); diff --git a/packages/react-lang/src/observability/core/transport.ts b/packages/react-lang/src/observability/core/transport.ts new file mode 100644 index 000000000..c705f9008 --- /dev/null +++ b/packages/react-lang/src/observability/core/transport.ts @@ -0,0 +1,131 @@ +import type { WireEnvelope } from "./wire"; + +const MAX_BACKOFF_MS = 5000; +const BACKOFF_MS = [500, 2000] as const; + +export interface TransportConfig { + endpoint: string; + apiKey: string; + debug: boolean; +} + +function debugLog(debug: boolean, message: string, detail?: unknown): void { + if (!debug) return; + // eslint-disable-next-line no-console -- gated debug diagnostics for cloud sink + if (detail === undefined) console.debug("[@openuidev/react-lang/observability]", message); + // eslint-disable-next-line no-console -- gated debug diagnostics for cloud sink + else console.debug("[@openuidev/react-lang/observability]", message, detail); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function beaconUrl(endpoint: string, apiKey: string): string { + const url = new URL(endpoint); + url.searchParams.set("apiKey", apiKey); + return url.toString(); +} + +function envelopeBody(envelope: WireEnvelope): string { + return JSON.stringify(envelope); +} + +function retryAfterMs(response: Response): number { + // Retry-After may also be an HTTP-date; parseInt fails on those, which + // intentionally falls through to the default backoff. + const header = response.headers.get("Retry-After"); + if (!header) return BACKOFF_MS[0]; + const seconds = Number.parseInt(header, 10); + if (!Number.isFinite(seconds) || seconds <= 0) return BACKOFF_MS[0]; + return Math.min(seconds * 1000, MAX_BACKOFF_MS); +} + +async function postEnvelope( + envelope: WireEnvelope, + config: TransportConfig, + attempt: number, +): Promise<{ ok: true } | { ok: false; retryable: boolean; retryDelayMs: number }> { + try { + const response = await fetch(config.endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${config.apiKey}`, + }, + body: envelopeBody(envelope), + keepalive: true, + }); + + if (response.ok) return { ok: true }; + + if (response.status === 429) { + return { + ok: false, + retryable: true, + retryDelayMs: retryAfterMs(response), + }; + } + + if (response.status >= 400 && response.status < 500) { + debugLog(config.debug, `Dropped batch after HTTP ${response.status} (non-retryable)`); + return { ok: false, retryable: false, retryDelayMs: 0 }; + } + + debugLog(config.debug, `HTTP ${response.status} on attempt ${attempt + 1}`); + return { + ok: false, + retryable: true, + retryDelayMs: BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)] ?? MAX_BACKOFF_MS, + }; + } catch (error) { + debugLog(config.debug, `Network error on attempt ${attempt + 1}`, error); + return { + ok: false, + retryable: true, + retryDelayMs: BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)] ?? MAX_BACKOFF_MS, + }; + } +} + +/** Sends an envelope with retry policy. Returns true when accepted by the server. */ +export async function sendEnvelope( + envelope: WireEnvelope, + config: TransportConfig, +): Promise { + const maxAttempts = 3; + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const result = await postEnvelope(envelope, config, attempt); + if (result.ok) return true; + if (!result.retryable) return false; + if (attempt >= maxAttempts - 1) break; + await sleep(Math.min(result.retryDelayMs, MAX_BACKOFF_MS)); + } + + debugLog( + config.debug, + `Dropped batch of ${envelope.events.length} event(s) after ${maxAttempts} attempts`, + ); + return false; +} + +/** Best-effort synchronous send for page hide; no retries. */ +export function sendEnvelopeBeacon(envelope: WireEnvelope, config: TransportConfig): void { + const body = envelopeBody(envelope); + const blob = new Blob([body], { type: "application/json" }); + + if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") { + const accepted = navigator.sendBeacon(beaconUrl(config.endpoint, config.apiKey), blob); + if (accepted) return; + debugLog(config.debug, "sendBeacon returned false; falling back to fetch keepalive"); + } + + void fetch(beaconUrl(config.endpoint, config.apiKey), { + method: "POST", + headers: { "content-type": "application/json" }, + body, + keepalive: true, + }).catch((error) => { + debugLog(config.debug, "Beacon fallback fetch failed", error); + }); +} diff --git a/packages/react-lang/src/observability/core/wire.test.ts b/packages/react-lang/src/observability/core/wire.test.ts new file mode 100644 index 000000000..265ba8dc2 --- /dev/null +++ b/packages/react-lang/src/observability/core/wire.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { SDK_VERSION } from "./wire"; + +describe("SDK_VERSION", () => { + it("matches packages/react-lang/package.json version", () => { + const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "../../../package.json"); + const { version } = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version: string }; + expect(SDK_VERSION).toBe(version); + }); +}); diff --git a/packages/react-lang/src/observability/core/wire.ts b/packages/react-lang/src/observability/core/wire.ts new file mode 100644 index 000000000..c09423001 --- /dev/null +++ b/packages/react-lang/src/observability/core/wire.ts @@ -0,0 +1,25 @@ +import type { ObservabilityLevel } from "@openuidev/observability"; +import type { StreamWireEvent } from "../events/stream"; + +/** Must be kept in sync with packages/react-lang/package.json on release. */ +export const SDK_VERSION = "0.2.11"; + +export interface WireEventBase { + id: string; + kind: string; + level: ObservabilityLevel; + timestamp: number; +} + +/** Currently a single member; will widen to a union as more kinds ship. */ +export type WireEvent = StreamWireEvent; + +export type { StreamWireEvent } from "../events/stream"; + +export interface WireEnvelope { + v: 1; + sentAt: number; + sdk: { name: "react-lang"; version: string }; + droppedEvents?: number; + events: WireEvent[]; +} diff --git a/packages/react-lang/src/observability/events/stream.test.ts b/packages/react-lang/src/observability/events/stream.test.ts new file mode 100644 index 000000000..5a17e943e --- /dev/null +++ b/packages/react-lang/src/observability/events/stream.test.ts @@ -0,0 +1,78 @@ +import type { ObservabilityEvent } from "@openuidev/observability"; +import { describe, expect, it } from "vitest"; +import { selectStreamEvent } from "./stream"; + +function settledEvent(overrides: Record = {}): ObservabilityEvent { + return { + level: "info", + timestamp: 1_700_000_000_000, + detail: { + id: "stream-1", + kind: "react-lang:stream", + phase: "settled", + updateIndex: 2, + errorCount: 0, + response: "hello", + message: "OpenUI Lang settled", + errors: [], + parser: { + incomplete: false, + unresolved: [], + orphaned: [], + statementCount: 1, + }, + ...overrides, + }, + }; +} + +describe("selectStreamEvent", () => { + it("accepts settled react-lang:stream events only", () => { + expect(selectStreamEvent(settledEvent(), "full")).toMatchObject({ + id: "stream-1", + kind: "react-lang:stream", + level: "info", + timestamp: 1_700_000_000_000, + updateIndex: 2, + errorCount: 0, + response: "hello", + message: "OpenUI Lang settled", + errors: [], + }); + }); + + it("rejects streaming and other kinds", () => { + expect(selectStreamEvent(settledEvent({ phase: "streaming" }), "full")).toBeNull(); + expect(selectStreamEvent(settledEvent({ kind: "other", phase: "settled" }), "full")).toBeNull(); + }); + + it("truncates full-mode response and sets responseTruncated", () => { + const selected = selectStreamEvent(settledEvent({ response: "x".repeat(16_385) }), "full"); + + expect(selected?.response).toHaveLength(16_384); + expect(selected?.responseTruncated).toBe(true); + }); + + it("minimal mode contains only allowed keys", () => { + const selected = selectStreamEvent(settledEvent(), "minimal"); + + expect(selected).toEqual({ + id: "stream-1", + kind: "react-lang:stream", + level: "info", + timestamp: 1_700_000_000_000, + updateIndex: 2, + errorCount: 0, + parser: { + incomplete: false, + unresolved: [], + orphaned: [], + statementCount: 1, + }, + }); + expect(selected).not.toHaveProperty("response"); + expect(selected).not.toHaveProperty("responseTruncated"); + expect(selected).not.toHaveProperty("message"); + expect(selected).not.toHaveProperty("errors"); + }); +}); diff --git a/packages/react-lang/src/observability/events/stream.ts b/packages/react-lang/src/observability/events/stream.ts new file mode 100644 index 000000000..d0dda02cc --- /dev/null +++ b/packages/react-lang/src/observability/events/stream.ts @@ -0,0 +1,117 @@ +import type { ObservabilityEvent } from "@openuidev/observability"; +import type { WireEventBase } from "../core/wire"; + +/** + * Shared producer↔sink contract for stream lifecycle events. The hook + * (src/hooks/useStreamingObservability.ts) builds event details against this + * module, and selectStreamEvent filters against the same constants, so the two + * sides cannot silently drift. + */ +export const STREAM_EVENT_KIND = "react-lang:stream" as const; +export type StreamEventKind = typeof STREAM_EVENT_KIND; + +export const STREAM_PHASE_STREAMING = "streaming" as const; +export const STREAM_PHASE_SETTLED = "settled" as const; +export type StreamPhase = typeof STREAM_PHASE_STREAMING | typeof STREAM_PHASE_SETTLED; + +const MAX_RESPONSE_LENGTH = 16_384; + +export interface StreamParserMetadata { + incomplete: boolean; + unresolved: unknown; + orphaned: unknown; + statementCount: number; +} + +/** Detail payload the producer emits when a stream settles. */ +export interface SettledStreamEventDetail { + id: string; + kind: StreamEventKind; + phase: typeof STREAM_PHASE_SETTLED; + updateIndex: number; + response: string | null; + responseLength: number; + parser?: StreamParserMetadata; + errors: unknown[]; + errorCount: number; + message: string; +} + +/** Wire shape for settled stream events sent to cloud ingest. */ +export interface StreamWireEvent extends WireEventBase { + kind: StreamEventKind; + updateIndex: number; + errorCount: number; + parser?: StreamParserMetadata; + response?: string; + responseTruncated?: true; + message?: string; + errors?: unknown; +} + +function isStreamParserMetadata(value: unknown): value is StreamParserMetadata { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return ( + typeof record.incomplete === "boolean" && + "unresolved" in record && + "orphaned" in record && + typeof record.statementCount === "number" + ); +} + +/** + * Returns a wire event when `event` is a settled stream event the cloud sink + * should keep; otherwise null. Kind filtering, validation, and capture shaping + * all live here so the shared selector stays kind-agnostic. + */ +export function selectStreamEvent( + event: ObservabilityEvent, + capture: "full" | "minimal", +): StreamWireEvent | null { + const { detail } = event; + if (detail.kind !== STREAM_EVENT_KIND || detail.phase !== STREAM_PHASE_SETTLED) return null; + + const id = detail.id; + if (typeof id !== "string") return null; + + const updateIndex = detail.updateIndex; + const errorCount = detail.errorCount; + if (typeof updateIndex !== "number" || typeof errorCount !== "number") return null; + + const parser = isStreamParserMetadata(detail.parser) ? detail.parser : undefined; + + if (capture === "minimal") { + return { + id, + kind: STREAM_EVENT_KIND, + level: event.level, + timestamp: event.timestamp, + updateIndex, + errorCount, + ...(parser ? { parser } : {}), + }; + } + + const response = typeof detail.response === "string" ? detail.response : undefined; + let responseTruncated: true | undefined; + let truncatedResponse = response; + if (response && response.length > MAX_RESPONSE_LENGTH) { + truncatedResponse = response.slice(0, MAX_RESPONSE_LENGTH); + responseTruncated = true; + } + + return { + id, + kind: STREAM_EVENT_KIND, + level: event.level, + timestamp: event.timestamp, + updateIndex, + errorCount, + ...(parser ? { parser } : {}), + ...(truncatedResponse !== undefined ? { response: truncatedResponse } : {}), + ...(responseTruncated ? { responseTruncated } : {}), + ...(typeof detail.message === "string" ? { message: detail.message } : {}), + ...(detail.errors !== undefined ? { errors: detail.errors } : {}), + }; +} diff --git a/packages/react-lang/src/observability/index.ssr.test.ts b/packages/react-lang/src/observability/index.ssr.test.ts new file mode 100644 index 000000000..a3eea4785 --- /dev/null +++ b/packages/react-lang/src/observability/index.ssr.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as cloud from "./index"; + +const GLOBAL_KEY = Symbol.for("openui.cloudObservability"); + +function resetGlobalState(): void { + const root = globalThis as Record; + root[GLOBAL_KEY] = { client: null, options: null }; +} + +const baseOptions = { + apiKey: "test-key", + debug: true, +} as const; + +describe("cloud observability SSR guard", () => { + beforeEach(async () => { + await cloud.close(); + resetGlobalState(); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await cloud.close(); + resetGlobalState(); + }); + + it("init no-ops when window is undefined", () => { + const originalWindow = globalThis.window; + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + + Object.defineProperty(globalThis, "window", { + configurable: true, + value: undefined, + }); + + cloud.init(baseOptions); + + expect(debug).toHaveBeenCalledWith( + "[@openuidev/react-lang/observability]", + "init skipped in non-browser environment", + ); + expect((globalThis as Record)[GLOBAL_KEY]?.client).toBeNull(); + + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + }); + }); +}); diff --git a/packages/react-lang/src/observability/index.test.ts b/packages/react-lang/src/observability/index.test.ts new file mode 100644 index 000000000..9de20f942 --- /dev/null +++ b/packages/react-lang/src/observability/index.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment jsdom +import { observability } from "@openuidev/observability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as transport from "./core/transport"; + +const mockState = vi.hoisted(() => ({ failConstruction: false })); + +vi.mock("./core/client", async (importOriginal) => { + const actual = await importOriginal(); + class CloudObservabilityClient extends actual.CloudObservabilityClient { + constructor(options: ConstructorParameters[0]) { + if (mockState.failConstruction) { + throw new Error("construction failed"); + } + super(options); + } + } + return { ...actual, CloudObservabilityClient }; +}); + +import { CloudObservabilityClient } from "./core/client"; +import * as cloud from "./index"; + +const GLOBAL_KEY = Symbol.for("openui.cloudObservability"); + +function getGlobalState(): { client: CloudObservabilityClient | null; options: unknown } { + return (globalThis as Record & Record)[GLOBAL_KEY] as { + client: CloudObservabilityClient | null; + options: unknown; + }; +} + +function resetGlobalState(): void { + const root = globalThis as Record; + root[GLOBAL_KEY] = { client: null, options: null }; +} + +const baseOptions = { + apiKey: "test-key", + endpoint: "https://ingest.example.com/v1/events", + debug: false, +} as const; + +beforeEach(async () => { + mockState.failConstruction = false; + await cloud.close(); + resetGlobalState(); +}); + +afterEach(async () => { + mockState.failConstruction = false; + vi.restoreAllMocks(); + await cloud.close(); + resetGlobalState(); +}); + +describe("cloud observability lifecycle", () => { + it("no-ops flush and close before init", async () => { + await expect(cloud.flush()).resolves.toBe(true); + await expect(cloud.close()).resolves.toBeUndefined(); + }); + + it("init is idempotent for equivalent options", () => { + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + + cloud.init({ ...baseOptions, debug: true }); + cloud.init({ ...baseOptions, debug: true }); + + expect(debug).toHaveBeenCalledWith( + "[@openuidev/react-lang/observability]", + "init skipped; already initialized with equivalent options", + ); + }); + + it("replaces the client when init is called with different options", async () => { + const closeSpy = vi.spyOn(CloudObservabilityClient.prototype, "close"); + + cloud.init(baseOptions); + const firstClient = getGlobalState().client; + cloud.init({ ...baseOptions, capture: "minimal" }); + const secondClient = getGlobalState().client; + + expect(secondClient).not.toBe(firstClient); + expect(closeSpy).toHaveBeenCalledTimes(1); + await cloud.close(); + }); + + it("failed init leaves prior singleton state intact", () => { + cloud.init(baseOptions); + const priorClient = getGlobalState().client; + const priorOptions = getGlobalState().options; + + mockState.failConstruction = true; + cloud.init({ ...baseOptions, capture: "minimal" }); + + expect(getGlobalState().client).toBe(priorClient); + expect(getGlobalState().options).toBe(priorOptions); + }); + + it("close detaches the observability bus listener", async () => { + let removeListener: (() => void) | undefined; + vi.spyOn(observability, "listenAll").mockImplementation(() => { + removeListener = vi.fn(); + return removeListener; + }); + + cloud.init(baseOptions); + await cloud.close(); + + expect(removeListener).toHaveBeenCalledOnce(); + }); + + it("flush resolves false when transport drops batches", async () => { + vi.spyOn(transport, "sendEnvelope").mockResolvedValue(false); + cloud.init(baseOptions); + + observability.info({ + id: "stream-1", + kind: "react-lang:stream", + phase: "settled", + updateIndex: 1, + errorCount: 0, + }); + + await expect(cloud.flush()).resolves.toBe(false); + }); + + it("warns and clamps sampleRate outside [0, 1] when debug is enabled", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + cloud.init({ ...baseOptions, debug: true, sampleRate: 1.5 }); + + expect(warn).toHaveBeenCalledWith( + "[@openuidev/react-lang/observability]", + "sampleRate 1.5 is outside [0, 1] and was clamped to 1", + ); + }); +}); diff --git a/packages/react-lang/src/observability/index.ts b/packages/react-lang/src/observability/index.ts new file mode 100644 index 000000000..c241751aa --- /dev/null +++ b/packages/react-lang/src/observability/index.ts @@ -0,0 +1,151 @@ +import { CloudObservabilityClient } from "./core/client"; +import type { WireEvent } from "./core/wire"; + +export type { StreamWireEvent, WireEnvelope, WireEvent } from "./core/wire"; + +const DEFAULT_ENDPOINT = "https://ingest.openui.com/v1/events"; +const GLOBAL_KEY = Symbol.for("openui.cloudObservability"); + +export interface CloudObservabilityOptions { + apiKey: string; + endpoint?: string; + capture?: "full" | "minimal"; + sampleRate?: number; + beforeSend?: (event: WireEvent) => WireEvent | null; + debug?: boolean; +} + +interface CloudObservabilityGlobal { + client: CloudObservabilityClient | null; + options: CloudObservabilityOptions | null; +} + +function getGlobalState(): CloudObservabilityGlobal { + const root = globalThis as Record; + if (!root[GLOBAL_KEY]) { + root[GLOBAL_KEY] = { client: null, options: null }; + } + return root[GLOBAL_KEY]!; +} + +function debugLog(options: CloudObservabilityOptions | null | undefined, message: string): void { + if (!options?.debug) return; + // eslint-disable-next-line no-console -- gated debug diagnostics for cloud sink + console.debug("[@openuidev/react-lang/observability]", message); +} + +function debugWarn(options: CloudObservabilityOptions, message: string): void { + if (!options.debug) return; + console.warn("[@openuidev/react-lang/observability]", message); +} + +function resolvedEndpoint(options: CloudObservabilityOptions): string { + return options.endpoint ?? DEFAULT_ENDPOINT; +} + +function resolvedCapture(options: CloudObservabilityOptions): "full" | "minimal" { + return options.capture ?? "full"; +} + +function resolvedSampleRate(options: CloudObservabilityOptions): number { + const raw = options.sampleRate ?? 1; + const clamped = Math.min(1, Math.max(0, raw)); + if (clamped !== raw) { + debugWarn(options, `sampleRate ${raw} is outside [0, 1] and was clamped to ${clamped}`); + } + return clamped; +} + +function resolvedDebug(options: CloudObservabilityOptions): boolean { + return options.debug ?? false; +} + +function optionsEqual(a: CloudObservabilityOptions, b: CloudObservabilityOptions): boolean { + return ( + a.apiKey === b.apiKey && + resolvedEndpoint(a) === resolvedEndpoint(b) && + resolvedCapture(a) === resolvedCapture(b) && + resolvedSampleRate(a) === resolvedSampleRate(b) && + resolvedDebug(a) === resolvedDebug(b) && + a.beforeSend === b.beforeSend + ); +} + +function createClient(options: CloudObservabilityOptions): CloudObservabilityClient { + return new CloudObservabilityClient({ + apiKey: options.apiKey, + endpoint: resolvedEndpoint(options), + capture: resolvedCapture(options), + sampleRate: resolvedSampleRate(options), + beforeSend: options.beforeSend, + debug: resolvedDebug(options), + }); +} + +export function init(options: CloudObservabilityOptions): void { + try { + if (typeof window === "undefined") { + debugLog(options, "init skipped in non-browser environment"); + return; + } + + const state = getGlobalState(); + if (state.client && state.options && optionsEqual(state.options, options)) { + debugLog(options, "init skipped; already initialized with equivalent options"); + return; + } + + const priorClient = state.client; + const priorOptions = state.options; + + try { + const client = createClient(options); + if (priorClient) { + void priorClient.close().catch(() => {}); + } + state.client = client; + state.options = options; + } catch (error) { + state.client = priorClient; + state.options = priorOptions; + if (options.debug) { + console.warn("[@openuidev/react-lang/observability]", "init failed", error); + } + } + } catch (error) { + if (options.debug) { + console.warn("[@openuidev/react-lang/observability]", "init failed", error); + } + } +} + +export async function flush(timeoutMs?: number): Promise { + try { + const client = getGlobalState().client; + if (!client) return true; + return await client.flush(timeoutMs); + } catch (error) { + const options = getGlobalState().options; + if (options?.debug) { + console.warn("[@openuidev/react-lang/observability]", "flush failed", error); + } + return false; + } +} + +export async function close(): Promise { + try { + const state = getGlobalState(); + if (!state.client) return; + await state.client.close(); + state.client = null; + state.options = null; + } catch (error) { + const options = getGlobalState().options; + if (options?.debug) { + console.warn("[@openuidev/react-lang/observability]", "close failed", error); + } + getGlobalState().client = null; + getGlobalState().options = null; + } +} diff --git a/packages/react-lang/src/observability/integration.test.ts b/packages/react-lang/src/observability/integration.test.ts new file mode 100644 index 000000000..092e3ae93 --- /dev/null +++ b/packages/react-lang/src/observability/integration.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import { observability } from "@openuidev/observability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as transport from "./core/transport"; +import * as cloud from "./index"; + +const GLOBAL_KEY = Symbol.for("openui.cloudObservability"); + +function resetGlobalState(): void { + const root = globalThis as Record; + root[GLOBAL_KEY] = { client: null, options: null }; +} + +const baseOptions = { + apiKey: "test-key", + endpoint: "https://ingest.example.com/v1/events", + debug: false, +} as const; + +function settledDetail(id: string, updateIndex = 1) { + return { + id, + kind: "react-lang:stream" as const, + phase: "settled" as const, + updateIndex, + errorCount: 0, + response: "done", + responseLength: 4, + }; +} + +beforeEach(async () => { + await cloud.close(); + resetGlobalState(); + vi.spyOn(transport, "sendEnvelope").mockResolvedValue(true); + vi.spyOn(transport, "sendEnvelopeBeacon").mockImplementation(() => {}); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await cloud.close(); + resetGlobalState(); +}); + +describe("cloud observability integration", () => { + it("forwards settled react-lang:stream events from the observability bus", async () => { + cloud.init(baseOptions); + + observability.info(settledDetail("stream-a")); + + await cloud.flush(); + + expect(transport.sendEnvelope).toHaveBeenCalledTimes(1); + const envelope = vi.mocked(transport.sendEnvelope).mock.calls[0]?.[0]; + expect(envelope?.events).toEqual([ + expect.objectContaining({ id: "stream-a", kind: "react-lang:stream" }), + ]); + }); + + it("forwards republished settled events with the same id as separate wire events", async () => { + cloud.init(baseOptions); + + observability.info(settledDetail("stream-a", 1)); + observability.error({ + ...settledDetail("stream-a", 2), + errorCount: 1, + errors: [{ source: "query", code: "x", message: "failed" }], + }); + + await cloud.flush(); + + const envelopes = vi + .mocked(transport.sendEnvelope) + .mock.calls.flatMap(([payload]) => payload.events); + expect(envelopes).toHaveLength(2); + expect(envelopes[0]).toMatchObject({ id: "stream-a", updateIndex: 1 }); + expect(envelopes[1]).toMatchObject({ id: "stream-a", updateIndex: 2, errorCount: 1 }); + }); +}); diff --git a/packages/react-lang/tsdown.config.ts b/packages/react-lang/tsdown.config.ts index e2715120b..63009bca3 100644 --- a/packages/react-lang/tsdown.config.ts +++ b/packages/react-lang/tsdown.config.ts @@ -1,14 +1,31 @@ import { defineConfig } from "tsdown"; -export default defineConfig({ - entry: ["src/index.ts", "src/index.native.ts"], - format: ["esm", "cjs"], +const shared = { + format: ["esm", "cjs"] as const, dts: true, sourcemap: true, target: "es2022", - outDir: "dist", - clean: true, deps: { neverBundle: [/^(?![./]|[A-Za-z]:[/\\])/], }, -}); +}; + +export default defineConfig([ + { + ...shared, + entry: { + index: "src/index.ts", + "index.native": "src/index.native.ts", + }, + outDir: "dist", + clean: true, + }, + { + ...shared, + entry: { + index: "src/observability/index.ts", + }, + outDir: "dist/observability", + clean: false, + }, +]);