diff --git a/.github/workflows/publish-npm-package.yml b/.github/workflows/publish-npm-package.yml index 958705f95..8fba8e8fa 100644 --- a/.github/workflows/publish-npm-package.yml +++ b/.github/workflows/publish-npm-package.yml @@ -23,6 +23,7 @@ on: - vue-lang - browser-bundle - observability + - observability-cloud - devtools jobs: diff --git a/packages/observability-cloud/README.md b/packages/observability-cloud/README.md new file mode 100644 index 000000000..9092cf5bc --- /dev/null +++ b/packages/observability-cloud/README.md @@ -0,0 +1,43 @@ +# @openuidev/observability-cloud + +Official SDK for OpenUI observability. Sends render events from your app to the Thesys console so you can track request volume, errors and reliability. + +## Install + +```bash +npm i @openuidev/observability-cloud +``` + +## Setup + +Generate a publishable API key at [console.thesys.dev/client-api-keys](https://console.thesys.dev/client-api-keys), then initialise the SDK once at your app's entry point: + +```ts +import * as Observability from "@openuidev/observability-cloud"; + +Observability.init({ apiKey: "pk-th-…" }); +``` + +`init` is safe to call from shared client/server code — it is a no-op when `window` is not defined. + +## API reference + +### `Observability.init(options)` + +| option | type | default | description | +| ------------ | ----------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `apiKey` | `string` | — | Your publishable API key (`pk-th-…`). Required. | +| `capture` | `"full"` \| `"minimal"` | `"full"` | `"full"` logs complete event data for the richest debugging in the console. `"minimal"` is a privacy-first mode that strips event data that may include PII. | +| `sampleRate` | `number` | `1` | Fraction of renders to send, `0`–`1`. Sampling is deterministic per render, so all events for one render are kept or dropped together. | +| `endpoint` | `string` | `https://ingest.thesys.dev/v1/events` | Override the ingest URL (testing). | +| `debug` | `boolean` | `false` | Log SDK diagnostics to the console. | + +Calling `init` again with the same options is a no-op; calling it with different options replaces the previous configuration. + +### `Observability.flush(timeoutMs?)` + +Events are batched and sent in the background. Call `flush` to send everything queued now — for example before a hard navigation. Resolves to `true` once the batch is accepted, or `false` if it could not be sent within `timeoutMs` (default 10 s). + +### `Observability.close()` + +Flushes queued events and stops sending. Call `init` again to resume. diff --git a/packages/observability-cloud/eslint.config.cjs b/packages/observability-cloud/eslint.config.cjs new file mode 100644 index 000000000..413c49627 --- /dev/null +++ b/packages/observability-cloud/eslint.config.cjs @@ -0,0 +1,73 @@ +const tseslint = require("@typescript-eslint/eslint-plugin"); +const typescript = require("@typescript-eslint/parser"); +const prettier = require("eslint-config-prettier"); +const unusedImports = require("eslint-plugin-unused-imports"); +const eslintPluginPrettier = require("eslint-plugin-prettier"); + +module.exports = [ + { + files: ["**/__tests__/**/*.{ts,tsx}", "**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"], + languageOptions: { + parser: typescript, + parserOptions: { + project: "./tsconfig.test.json", + sourceType: "module", + }, + }, + }, + { + files: ["**/*.{ts,tsx}"], + ignores: [ + "**/*.stories.tsx", + "**/__tests__/**/*.{ts,tsx}", + "**/*.test.{ts,tsx}", + "**/*.spec.{ts,tsx}", + "*.config.ts", + ], + languageOptions: { + parser: typescript, + parserOptions: { + project: "./tsconfig.json", + sourceType: "module", + }, + }, + plugins: { + "@typescript-eslint": tseslint, + "unused-imports": unusedImports, + prettier: eslintPluginPrettier, + }, + rules: { + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "off", + "no-undefined": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-use-before-define": [ + "error", + { + functions: false, + classes: false, + variables: false, + }, + ], + "unused-imports/no-unused-imports": "error", + "no-console": [ + "error", + { + allow: ["error", "warn", "info"], + }, + ], + ...eslintPluginPrettier.configs.recommended.rules, + }, + }, + prettier, +]; diff --git a/packages/observability-cloud/package.json b/packages/observability-cloud/package.json new file mode 100644 index 000000000..f7baa584e --- /dev/null +++ b/packages/observability-cloud/package.json @@ -0,0 +1,66 @@ +{ + "name": "@openuidev/observability-cloud", + "version": "0.0.1", + "description": "Cloud sink for @openuidev/observability: batches OpenUI events and ships them to Thesys ingest", + "license": "MIT", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.cts", + "sideEffects": false, + "files": [ + "dist", + "README.md" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "scripts": { + "test": "vitest run --passWithNoTests", + "build": "tsdown", + "watch": "tsdown --watch", + "typecheck": "tsc --noEmit", + "lint:check": "eslint ./src", + "lint:fix": "eslint ./src --fix", + "format:fix": "prettier --write ./src", + "format:check": "prettier --check ./src", + "check:publint": "publint", + "check:attw": "attw --pack .", + "prepare": "pnpm run build", + "prepublishOnly": "pnpm run check:publint && pnpm run check:attw", + "ci": "pnpm run lint:check && pnpm run format:check" + }, + "keywords": [ + "openui", + "observability", + "telemetry", + "ingest", + "errors" + ], + "homepage": "https://openui.com", + "repository": { + "type": "git", + "url": "https://github.com/thesysdev/openui.git", + "directory": "packages/observability-cloud" + }, + "bugs": { + "url": "https://github.com/thesysdev/openui/issues" + }, + "author": "engineering@thesys.dev", + "dependencies": { + "@openuidev/observability": "workspace:^" + }, + "devDependencies": { + "jsdom": "catalog:", + "vitest": "^4.1.0" + } +} diff --git a/packages/observability-cloud/src/core/batcher.test.ts b/packages/observability-cloud/src/core/batcher.test.ts new file mode 100644 index 000000000..71585c65b --- /dev/null +++ b/packages/observability-cloud/src/core/batcher.test.ts @@ -0,0 +1,236 @@ +// @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, + capture: "full" as const, +}; + +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); + expect(envelope?.capture).toBe("full"); + await batcher.close(); + }); + + it("stamps the client's capture mode on every envelope", async () => { + const batcher = new Batcher({ ...transportConfig, capture: "minimal" }); + batcher.enqueue(wireEvent(0)); + await batcher.flush(); + const envelope = vi.mocked(transport.sendEnvelope).mock.calls[0]?.[0]; + expect(envelope?.capture).toBe("minimal"); + 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/observability-cloud/src/core/batcher.ts b/packages/observability-cloud/src/core/batcher.ts new file mode 100644 index 000000000..3165fadc6 --- /dev/null +++ b/packages/observability-cloud/src/core/batcher.ts @@ -0,0 +1,197 @@ +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, + capture: "full" | "minimal", +): WireEnvelope { + return { + v: 1, + sentAt: Date.now(), + sdk: { name: "observability-cloud", version: SDK_VERSION }, + capture, + ...(droppedEvents > 0 ? { droppedEvents } : {}), + events, + }; +} + +export interface BatcherOptions extends TransportConfig { + capture: "full" | "minimal"; +} + +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; + + private readonly transport: TransportConfig; + private readonly capture: "full" | "minimal"; + + constructor(options: BatcherOptions) { + this.transport = { endpoint: options.endpoint, apiKey: options.apiKey, debug: options.debug }; + this.capture = options.capture; + 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.capture), 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.capture), + 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/observability-cloud/src/core/client.test.ts b/packages/observability-cloud/src/core/client.test.ts new file mode 100644 index 000000000..1ab295fad --- /dev/null +++ b/packages/observability-cloud/src/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/observability-cloud/src/core/client.ts b/packages/observability-cloud/src/core/client.ts new file mode 100644 index 000000000..591cf5de6 --- /dev/null +++ b/packages/observability-cloud/src/core/client.ts @@ -0,0 +1,40 @@ +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, + capture: options.capture, + }); + + 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/observability-cloud]", "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/observability-cloud/src/core/queue.test.ts b/packages/observability-cloud/src/core/queue.test.ts new file mode 100644 index 000000000..f682fef02 --- /dev/null +++ b/packages/observability-cloud/src/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/observability-cloud/src/core/queue.ts b/packages/observability-cloud/src/core/queue.ts new file mode 100644 index 000000000..258a6f385 --- /dev/null +++ b/packages/observability-cloud/src/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/observability-cloud/src/core/selector.test.ts b/packages/observability-cloud/src/core/selector.test.ts new file mode 100644 index 000000000..816c7a0e4 --- /dev/null +++ b/packages/observability-cloud/src/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/observability-cloud/src/core/selector.ts b/packages/observability-cloud/src/core/selector.ts new file mode 100644 index 000000000..16b7e71a9 --- /dev/null +++ b/packages/observability-cloud/src/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/observability-cloud]", message); + return; + } + console.warn("[@openuidev/observability-cloud]", 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/observability-cloud/src/core/transport.test.ts b/packages/observability-cloud/src/core/transport.test.ts new file mode 100644 index 000000000..8086ad802 --- /dev/null +++ b/packages/observability-cloud/src/core/transport.test.ts @@ -0,0 +1,149 @@ +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: "observability-cloud", version: SDK_VERSION }, + capture: "full", + 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/observability-cloud/src/core/transport.ts b/packages/observability-cloud/src/core/transport.ts new file mode 100644 index 000000000..243232c6f --- /dev/null +++ b/packages/observability-cloud/src/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/observability-cloud]", message); + // eslint-disable-next-line no-console -- gated debug diagnostics for cloud sink + else console.debug("[@openuidev/observability-cloud]", 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/observability-cloud/src/core/wire.test.ts b/packages/observability-cloud/src/core/wire.test.ts new file mode 100644 index 000000000..8ee76213f --- /dev/null +++ b/packages/observability-cloud/src/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/observability-cloud/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/observability-cloud/src/core/wire.ts b/packages/observability-cloud/src/core/wire.ts new file mode 100644 index 000000000..e43beb6f7 --- /dev/null +++ b/packages/observability-cloud/src/core/wire.ts @@ -0,0 +1,27 @@ +import type { ObservabilityLevel } from "@openuidev/observability"; +import type { StreamWireEvent } from "../events/stream"; + +/** Must be kept in sync with packages/observability-cloud/package.json on release. */ +export const SDK_VERSION = "0.0.1"; + +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, WireErrorEntry } from "../events/stream"; + +export interface WireEnvelope { + v: 1; + sentAt: number; + sdk: { name: "observability-cloud"; version: string }; + /** The client's capture mode; every event in the batch was shaped by it. */ + capture: "full" | "minimal"; + droppedEvents?: number; + events: WireEvent[]; +} diff --git a/packages/observability-cloud/src/events/stream.test.ts b/packages/observability-cloud/src/events/stream.test.ts new file mode 100644 index 000000000..d324939c1 --- /dev/null +++ b/packages/observability-cloud/src/events/stream.test.ts @@ -0,0 +1,148 @@ +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", + }); + expect(selectStreamEvent(settledEvent(), "full")).not.toHaveProperty("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"); + }); + + const twoErrors = [ + { + code: "unknown-component", + source: "materialize", + component: "Fancy", + statementId: "card", + message: "Unknown component Fancy", + extra: "ignored", + }, + { code: "unknown-tool", source: "tool", toolName: "search", message: "No tool search" }, + ]; + + it("full mode ships typed error entries with messages", () => { + const selected = selectStreamEvent( + settledEvent({ level: "error", errors: twoErrors, errorCount: 2 }), + "full", + ); + + expect(selected?.errorCount).toBe(2); + expect(selected?.errors).toEqual([ + { + code: "unknown-component", + source: "materialize", + component: "Fancy", + statementId: "card", + message: "Unknown component Fancy", + }, + { code: "unknown-tool", source: "tool", toolName: "search", message: "No tool search" }, + ]); + }); + + it("minimal mode keeps error codes and identifiers but strips messages", () => { + const selected = selectStreamEvent( + settledEvent({ errors: twoErrors, errorCount: 2 }), + "minimal", + ); + + expect(selected?.errorCount).toBe(2); + expect(selected?.errors).toEqual([ + { code: "unknown-component", source: "materialize", component: "Fancy", statementId: "card" }, + { code: "unknown-tool", source: "tool", toolName: "search" }, + ]); + expect(selected).not.toHaveProperty("message"); + expect(selected).not.toHaveProperty("response"); + }); + + it("drops malformed error entries and reports the shipped count", () => { + const selected = selectStreamEvent( + settledEvent({ + errors: [{ code: "incomplete", source: "parser" }, { code: "no-source" }, "junk", null], + errorCount: 4, + }), + "full", + ); + + expect(selected?.errorCount).toBe(1); + expect(selected?.errors).toEqual([{ code: "incomplete", source: "parser" }]); + }); + + it("drops parser metadata whose identifier lists are not arrays", () => { + const selected = selectStreamEvent( + settledEvent({ + parser: { incomplete: false, unresolved: "a", orphaned: [], statementCount: 1 }, + }), + "full", + ); + + expect(selected).not.toHaveProperty("parser"); + }); +}); diff --git a/packages/observability-cloud/src/events/stream.ts b/packages/observability-cloud/src/events/stream.ts new file mode 100644 index 000000000..df504c201 --- /dev/null +++ b/packages/observability-cloud/src/events/stream.ts @@ -0,0 +1,164 @@ +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; + /** Identifiers referenced but never defined in the generated program. */ + unresolved: string[]; + /** Identifiers defined but not reachable from root. */ + orphaned: string[]; + statementCount: number; +} + +/** + * One error from a settled render, as shipped to ingest. `code`/`source` are + * required and enum-like; the rest are optional identifiers. `message` is + * free text and is stripped in minimal capture. + */ +export interface WireErrorEntry { + code: string; + source: string; + component?: string; + statementId?: string; + toolName?: string; + message?: string; +} + +/** 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?: WireErrorEntry[]; +} + +function isStreamParserMetadata(value: unknown): value is StreamParserMetadata { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return ( + typeof record["incomplete"] === "boolean" && + Array.isArray(record["unresolved"]) && + Array.isArray(record["orphaned"]) && + typeof record["statementCount"] === "number" + ); +} + +const OPTIONAL_ERROR_KEYS = ["component", "statementId", "toolName"] as const; + +/** + * Projects the producer's error list onto the wire shape. Entries without a + * string `code` and `source` are dropped; `errorCount` on the wire is the + * length of the result, so the count always matches the list. + */ +function projectErrors(raw: unknown, capture: "full" | "minimal"): WireErrorEntry[] { + if (!Array.isArray(raw)) return []; + const projected: WireErrorEntry[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== "object") continue; + const record = entry as Record; + const code = record["code"]; + const source = record["source"]; + if (typeof code !== "string" || typeof source !== "string") continue; + const wire: WireErrorEntry = { code, source }; + for (const key of OPTIONAL_ERROR_KEYS) { + const value = record[key]; + if (typeof value === "string") wire[key] = value; + } + if (capture === "full" && typeof record["message"] === "string") { + wire.message = record["message"]; + } + projected.push(wire); + } + return projected; +} + +/** + * 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"]; + if (typeof updateIndex !== "number" || typeof detail["errorCount"] !== "number") return null; + + const parser = isStreamParserMetadata(detail["parser"]) ? detail["parser"] : undefined; + const errors = projectErrors(detail["errors"], capture); + const errorCount = errors.length; + + if (capture === "minimal") { + return { + id, + kind: STREAM_EVENT_KIND, + level: event.level, + timestamp: event.timestamp, + updateIndex, + errorCount, + ...(parser ? { parser } : {}), + ...(errors.length > 0 ? { errors } : {}), + }; + } + + 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"] } : {}), + ...(errors.length > 0 ? { errors } : {}), + }; +} diff --git a/packages/observability-cloud/src/index.ssr.test.ts b/packages/observability-cloud/src/index.ssr.test.ts new file mode 100644 index 000000000..fb2066c38 --- /dev/null +++ b/packages/observability-cloud/src/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/observability-cloud]", + "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/observability-cloud/src/index.test.ts b/packages/observability-cloud/src/index.test.ts new file mode 100644 index 000000000..e264bb6df --- /dev/null +++ b/packages/observability-cloud/src/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/observability-cloud]", + "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/observability-cloud]", + "sampleRate 1.5 is outside [0, 1] and was clamped to 1", + ); + }); +}); diff --git a/packages/observability-cloud/src/index.ts b/packages/observability-cloud/src/index.ts new file mode 100644 index 000000000..9d7414cc9 --- /dev/null +++ b/packages/observability-cloud/src/index.ts @@ -0,0 +1,152 @@ +import { CloudObservabilityClient } from "./core/client"; +import type { WireEvent } from "./core/wire"; + +export type { StreamWireEvent, WireEnvelope, WireErrorEntry, WireEvent } from "./core/wire"; + +const DEFAULT_ENDPOINT = "https://ingest.thesys.dev/v1/events"; +const GLOBAL_KEY = Symbol.for("openui.cloudObservability"); + +export interface CloudObservabilityOptions { + /** Your publishable API key from the Thesys console (`pk-th-…`). */ + 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/observability-cloud]", message); +} + +function debugWarn(options: CloudObservabilityOptions, message: string): void { + if (!options.debug) return; + console.warn("[@openuidev/observability-cloud]", 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/observability-cloud]", "init failed", error); + } + } + } catch (error) { + if (options.debug) { + console.warn("[@openuidev/observability-cloud]", "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/observability-cloud]", "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/observability-cloud]", "close failed", error); + } + getGlobalState().client = null; + getGlobalState().options = null; + } +} diff --git a/packages/observability-cloud/src/integration.test.ts b/packages/observability-cloud/src/integration.test.ts new file mode 100644 index 000000000..092e3ae93 --- /dev/null +++ b/packages/observability-cloud/src/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/observability-cloud/tsconfig.json b/packages/observability-cloud/tsconfig.json new file mode 100644 index 000000000..050ddeb9a --- /dev/null +++ b/packages/observability-cloud/tsconfig.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"], + "compilerOptions": { + "moduleResolution": "bundler", + "module": "ESNext", + "outDir": "./dist", + "rootDir": "./src", + "noEmit": true + } +} diff --git a/packages/observability-cloud/tsconfig.test.json b/packages/observability-cloud/tsconfig.test.json new file mode 100644 index 000000000..60b3001d5 --- /dev/null +++ b/packages/observability-cloud/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/observability-cloud/tsdown.config.ts b/packages/observability-cloud/tsdown.config.ts new file mode 100644 index 000000000..bd5f1a3e1 --- /dev/null +++ b/packages/observability-cloud/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + target: "es2022", + outDir: "dist", + clean: true, + deps: { + neverBundle: [/^(?![./]|[A-Za-z]:[/\\])/], + }, +}); diff --git a/packages/react-lang/src/hooks/streamEvent.ts b/packages/react-lang/src/hooks/streamEvent.ts new file mode 100644 index 000000000..5dd439b4a --- /dev/null +++ b/packages/react-lang/src/hooks/streamEvent.ts @@ -0,0 +1,34 @@ +/** + * Producer-side contract for the stream lifecycle events this package emits on + * `@openuidev/observability`. Intentionally duplicated (not imported) in + * `@openuidev/observability-cloud` (src/events/stream.ts), which consumes these + * events; the two packages have no dependency on each other. Keep the string + * constants and the settled detail shape in sync when changing either side. + */ +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; + +export interface StreamParserMetadata { + incomplete: boolean; + unresolved: unknown; + orphaned: unknown; + statementCount: number; +} + +/** Detail payload emitted 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; +} diff --git a/packages/react-lang/src/hooks/useStreamingObservability.test.ts b/packages/react-lang/src/hooks/useStreamingObservability.test.ts index ce5b369be..10a982358 100644 --- a/packages/react-lang/src/hooks/useStreamingObservability.test.ts +++ b/packages/react-lang/src/hooks/useStreamingObservability.test.ts @@ -36,7 +36,7 @@ describe("streaming observability lifecycle", () => { ).toEqual({ id: "stream-1", phase: "settled", - updateIndex: 2, + updateIndex: 3, }); expect(idFactory).toHaveBeenCalledOnce(); }); @@ -70,7 +70,7 @@ describe("streaming observability lifecycle", () => { expect(advanceStreamingObservability(state, false, "first", "[]", idFactory)).toEqual({ id: "stream-1", phase: "settled", - updateIndex: 1, + updateIndex: 2, }); expect(advanceStreamingObservability(state, true, "second", null, idFactory)).toEqual({ id: "stream-2", @@ -80,12 +80,12 @@ describe("streaming observability lifecycle", () => { expect(advanceStreamingObservability(state, false, "second", "[]", idFactory)).toEqual({ id: "stream-2", phase: "settled", - updateIndex: 1, + updateIndex: 2, }); expect(idFactory).toHaveBeenCalledTimes(2); }); - it("republishes settled when the error snapshot changes", () => { + it("republishes settled with a new updateIndex when the error snapshot changes", () => { const state = createStreamingObservabilityState(); const idFactory = () => "stream-1"; @@ -103,7 +103,7 @@ describe("streaming observability lifecycle", () => { ).toEqual({ id: "stream-1", phase: "settled", - updateIndex: 1, + updateIndex: 3, }); expect( advanceStreamingObservability( @@ -114,5 +114,14 @@ describe("streaming observability lifecycle", () => { idFactory, ), ).toBeNull(); + expect( + advanceStreamingObservability( + state, + false, + "root = Card()", + '[{"code":"query-error"},{"code":"tool-failed"}]', + idFactory, + ), + ).toEqual({ id: "stream-1", phase: "settled", updateIndex: 4 }); }); }); diff --git a/packages/react-lang/src/hooks/useStreamingObservability.ts b/packages/react-lang/src/hooks/useStreamingObservability.ts index 3c2bd4013..5509631b9 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 "./streamEvent"; 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. @@ -79,9 +86,10 @@ export function advanceStreamingObservability( return null; } + state.updateIndex += 1; 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 +129,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 +141,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 +156,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/tsdown.config.ts b/packages/react-lang/tsdown.config.ts index e2715120b..fdb9d33cc 100644 --- a/packages/react-lang/tsdown.config.ts +++ b/packages/react-lang/tsdown.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts", "src/index.native.ts"], + entry: { + index: "src/index.ts", + "index.native": "src/index.native.ts", + }, format: ["esm", "cjs"], dts: true, sourcemap: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc03eb9e8..5d51813dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1808,6 +1808,19 @@ importers: 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)) + packages/observability-cloud: + dependencies: + '@openuidev/observability': + specifier: workspace:^ + version: link:../observability + devDependencies: + jsdom: + specifier: 'catalog:' + 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)) + packages/openui-cli: dependencies: '@inquirer/core':