@columenlabs/lumen-ui 0.1.79 → 0.1.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api-fetch.js CHANGED
@@ -2,10 +2,12 @@
2
2
  * One JSON fetch for the assistant, workspaces and joins. A failure throws `ApiError` with the
3
3
  * method, address, status and the server's `error` and `code`, so a caller shows what failed and
4
4
  * Workers Logs keep it: a server fault (5xx) or a network failure is reported to
5
- * `/api/client-error` with `where` naming the caller. A 4xx is the server saying why it refused,
5
+ * `/api/client-error` with `where` naming the caller. Every failure is also a `fetch_fail`
6
+ * telemetry point (its route with ids folded, status and request id) once `useLumenTelemetry` runs. A 4xx is the server saying why it refused,
6
7
  * shown to the person and not logged as a fault.
7
8
  */
8
9
  import { reportError } from './route-error.js';
10
+ import { apiRoute, reportFailedFetch } from './telemetry.js';
9
11
  export class ApiError extends Error {
10
12
  method;
11
13
  url;
@@ -39,6 +41,7 @@ export async function apiJson(url, init = {}, where = 'api') {
39
41
  throw err;
40
42
  const e = new ApiError(`Couldn't reach the server (${method} ${url})`, method, url, 0);
41
43
  reportError(e, 'shown', where);
44
+ reportFailedFetch(apiRoute(url), 0);
42
45
  throw e;
43
46
  }
44
47
  const text = await res.text();
@@ -54,6 +57,7 @@ export async function apiJson(url, init = {}, where = 'api') {
54
57
  const e = new ApiError(b.error ?? `${method} ${url} answered ${res.status}`, method, url, res.status, b.code);
55
58
  if (res.status >= 500)
56
59
  reportError(e, 'shown', where);
60
+ reportFailedFetch(apiRoute(url), res.status, res.headers.get('x-request-id'));
57
61
  throw e;
58
62
  }
59
63
  return body;
@@ -1,5 +1,5 @@
1
1
  export { type AssistantAdapter, assistantClient } from './client.js';
2
2
  export { type AssistantCommand, type ComposerExtra, LumenAssistant, type LumenAssistantProps, LumenComposer, assistantStatusKey, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
3
- export { assistantSettingsSections } from './settings.js';
3
+ export { assistantAdminSections, assistantSettingsSections } from './settings.js';
4
4
  export { type AssistantSource, type AssistantSourceItem, type ComposeRequest, type PageSelection, composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
5
5
  export { type AssistantChatState, type LiveItem, applyEvent, useAssistantChat, } from './use-assistant-chat.js';
@@ -3,6 +3,6 @@
3
3
  // `@columenlabs/lumen-ui-types/assistant`.
4
4
  export { assistantClient } from './client.js';
5
5
  export { LumenAssistant, LumenComposer, assistantStatusKey, useAssistantStatus, useLumenAssistantDock, } from './lumen-assistant.js';
6
- export { assistantSettingsSections } from './settings.js';
6
+ export { assistantAdminSections, assistantSettingsSections } from './settings.js';
7
7
  export { composeAssistant, selectionRef, useAssistantSource, usePageSelection, useShownAssistantSources, } from './sources.js';
8
8
  export { applyEvent, useAssistantChat, } from './use-assistant-chat.js';
@@ -1,5 +1,12 @@
1
1
  import type { SettingsSectionDef } from '../settings.js';
2
2
  import type { AssistantAdapter } from './client.js';
3
+ /**
4
+ * The admin's section: the app's shared keys and each person's monthly allowance on them. Put it
5
+ * where the app keeps its admin settings, shown only to admins.
6
+ */
7
+ export declare function assistantAdminSections({ client, }: {
8
+ client: AssistantAdapter;
9
+ }): SettingsSectionDef[];
3
10
  /**
4
11
  * The assistant's Settings sections: the assistant, then Model providers, Models, Tools, Web and
5
12
  * Spend under it. Pass them to LumenShell's `shared` (or an app's own `settings`).
@@ -173,28 +173,31 @@ function ChatgptSignin({ client, p }) {
173
173
  function SigninPaste({ id, redirect, pasted, setPasted, issues, busy, finish, }) {
174
174
  return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: id, children: _jsx(InfoLabel, { info: `The sign-in ends on ${redirect}, a page that does not load. That is expected: copy the whole address from that tab's address bar.`, children: "Paste the address the sign-in ended on" }) }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { id: id, autoComplete: "off", placeholder: `${redirect}?code=…`, className: "font-code text-xs", value: pasted, onChange: (e) => setPasted(e.target.value) }), _jsx(SaveButton, { issues: issues, busy: busy, disabled: !pasted.trim(), label: "Finish", onSave: finish })] })] }));
175
175
  }
176
- /** One provider, collapsed to a row: where to make a key (a new tab), the app's key and the person's. */
177
- function ProviderKeys({ client, p, admin, }) {
176
+ const ready = (_jsxs(Badge, { variant: "outline", className: "text-gain", children: [_jsx(CheckIcon, {}), "Ready"] }));
177
+ const notSet = _jsx(Badge, { variant: "outline", children: "Not set" });
178
+ /** One provider, collapsed to a row: the person's own key (or their ChatGPT plan sign-in). */
179
+ function OwnProviderKey({ client, p }) {
178
180
  const { apply, refresh } = useApply(client);
179
- const appOn = p.app.set || !!p.app.from_env;
180
181
  const plan = p.own.via === 'chatgpt';
181
- const meta = [
182
- appOn ? 'Shared key set' : 'No shared key',
183
- plan ? 'your ChatGPT plan' : p.own.set ? 'your own key' : null,
184
- ]
185
- .filter(Boolean)
186
- .join(' · ');
187
- const failed = [p.app, p.own].some((k) => k.set && k.test && !k.test.ok);
188
- const badge = failed ? (_jsx(TestBadge, { test: { ok: false } })) : appOn || p.own.set ? (_jsxs(Badge, { variant: "outline", className: "text-gain", children: [_jsx(CheckIcon, {}), "Ready"] })) : (_jsx(Badge, { variant: "outline", children: "Not set" }));
189
- const ownKey = (_jsx(KeyBlock, { label: p.signin && !p.own.set ? 'Or your own API key' : 'Your own key', prefix: p.prefix, provider: p.label, state: p.own, canEdit: true, keysUrl: p.keys_url, onSave: (k) => apply(`own:${p.id}`, () => client.ownKey(p.id, k), 'Key saved'), onRemove: () => apply(`own:${p.id}`, () => client.ownKey(p.id, null), 'Key removed'), onTest: () => client.testOwnKey(p.id).finally(refresh) }));
190
- return (_jsx(LumenExpandRow, { id: p.id, name: p.label, meta: meta, badge: badge, children: _jsxs("div", { className: "flex flex-col", children: [_jsx(KeyBlock, { label: "Shared key", info: "An admin sets it once; everyone uses it, within each person's monthly cap.", prefix: p.prefix, provider: p.label, state: p.app, canEdit: admin, keysUrl: p.keys_url, onSave: (k) => apply(`app:${p.id}`, () => client.appKey(p.id, k), 'Key saved'), onRemove: () => apply(`app:${p.id}`, () => client.appKey(p.id, null), 'Key removed'), onTest: () => client.testAppKey(p.id).finally(refresh) }), p.signin === 'chatgpt' && (!p.own.set || plan) && _jsx(ChatgptSignin, { client: client, p: p }), !plan && ownKey] }) }));
182
+ const badge = p.own.set && p.own.test && !p.own.test.ok ? (_jsx(TestBadge, { test: { ok: false } })) : p.own.set ? (ready) : (notSet);
183
+ return (_jsx(LumenExpandRow, { id: p.id, name: p.label, meta: plan ? 'Your ChatGPT plan' : p.own.set ? 'Your key' : 'No key', badge: badge, children: _jsxs("div", { className: "flex flex-col", children: [p.signin === 'chatgpt' && (!p.own.set || plan) && _jsx(ChatgptSignin, { client: client, p: p }), !plan && (_jsx(KeyBlock, { label: p.signin && !p.own.set ? 'Or your API key' : 'Your key', prefix: p.prefix, provider: p.label, state: p.own, canEdit: true, keysUrl: p.keys_url, onSave: (k) => apply(`own:${p.id}`, () => client.ownKey(p.id, k), 'Key saved'), onRemove: () => apply(`own:${p.id}`, () => client.ownKey(p.id, null), 'Key removed'), onTest: () => client.testOwnKey(p.id).finally(refresh) }))] }) }));
191
184
  }
185
+ /** A person's own keys, one row a provider, and what they may spend on them. */
192
186
  function Providers({ client }) {
193
187
  const { apply } = useApply(client);
194
- return (_jsx(WithStatus, { client: client, children: (s) => {
195
- const ownSet = s.providers.some((p) => p.own.set);
196
- return (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: s.providers.map((p) => (_jsx(ProviderKeys, { client: client, p: p, admin: s.admin }, p.id))) }), s.admin && (_jsx(Row, { label: "Each person may spend", info: "On the shared keys, every provider together.", children: _jsx(CapInput, { label: "Cap per person", value: s.caps.app, onSave: (n) => void apply('cap', () => client.appCap(n)) }) })), ownSet && (_jsx(Row, { label: "Your keys may spend", info: "On your own keys, every provider together.", children: _jsx(CapInput, { label: "Your monthly cap", value: s.caps.own, onSave: (n) => void apply('own', () => client.ownCap(n)) }) })), _jsx(Row, { label: "Pay with first", info: "When it reaches its cap this month, or has no key for the tier's provider, the other pays.", children: _jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: s.pay, "aria-label": "Pay with", onValueChange: (v) => v && void apply('pay', () => client.settings({ pay: v })), children: [_jsx(ToggleGroupItem, { value: "app", children: "Shared keys" }), _jsx(ToggleGroupItem, { value: "own", disabled: !ownSet, children: "Your keys" })] }) })] }));
197
- } }));
188
+ return (_jsx(WithStatus, { client: client, children: (s) => (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: s.providers.map((p) => (_jsx(OwnProviderKey, { client: client, p: p }, p.id))) }), s.providers.some((p) => p.own.set) && (_jsx(Row, { label: "Your keys may spend", info: "On your keys, every provider together.", children: _jsx(CapInput, { label: "Your monthly cap", value: s.caps.own, onSave: (n) => void apply('own', () => client.ownCap(n)) }) }))] })) }));
189
+ }
190
+ /** One provider's shared key, which an admin sets for everyone. */
191
+ function SharedProviderKey({ client, p }) {
192
+ const { apply, refresh } = useApply(client);
193
+ const on = p.app.set || !!p.app.from_env;
194
+ const badge = p.app.set && p.app.test && !p.app.test.ok ? (_jsx(TestBadge, { test: { ok: false } })) : on ? (ready) : (notSet);
195
+ return (_jsx(LumenExpandRow, { id: p.id, name: p.label, meta: p.app.from_env ? 'From the server' : on ? 'Shared key' : 'No key', badge: badge, children: _jsx(KeyBlock, { label: "Shared key", info: "Everyone's answers run on it, within the monthly allowance below.", prefix: p.prefix, provider: p.label, state: p.app, canEdit: true, keysUrl: p.keys_url, onSave: (k) => apply(`app:${p.id}`, () => client.appKey(p.id, k), 'Key saved'), onRemove: () => apply(`app:${p.id}`, () => client.appKey(p.id, null), 'Key removed'), onTest: () => client.testAppKey(p.id).finally(refresh) }) }));
196
+ }
197
+ /** Admins only: the app's shared keys and what each person may spend on them. */
198
+ function SharedKeys({ client }) {
199
+ const { apply } = useApply(client);
200
+ return (_jsx(WithStatus, { client: client, children: (s) => s.admin ? (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenExpandList, { children: s.providers.map((p) => (_jsx(SharedProviderKey, { client: client, p: p }, p.id))) }), _jsx(Row, { label: "Each person may spend", info: "A month on the shared keys, every provider together. Past it, a person's own key pays.", children: _jsx(CapInput, { label: "Allowance per person", value: s.caps.app, onSave: (n) => void apply('cap', () => client.appCap(n)) }) })] })) : (_jsx("p", { className: "text-muted-foreground text-sm", children: "Only an admin can change these." })) }));
198
201
  }
199
202
  // ---------------------------------------------------------------- models
200
203
  function Models({ client }) {
@@ -261,6 +264,21 @@ function Spend({ client }) {
261
264
  ` · ${usd(sp.cap)} cap on ${sp.payer === 'app' ? 'the app key' : 'your key'}`] })] }), sp.rows.length ? (_jsx("div", { className: "flex flex-col text-sm", children: sp.rows.map((r) => (_jsxs("div", { className: "flex items-center gap-2 border-b py-2 last:border-b-0", children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: r.label }), _jsx("span", { className: "text-muted-foreground text-xs", children: r.payer === 'app' ? 'Shared key' : 'Your key' }), _jsxs("span", { className: "w-16 text-right text-muted-foreground text-xs tabular-nums", children: [r.calls, " calls"] }), _jsx("span", { className: "w-16 text-right tabular-nums", children: usd(r.usd) })] }, `${r.label}:${r.payer}`))) })) : (_jsx("p", { className: "text-muted-foreground text-sm", children: "Nothing spent this month." }))] }));
262
265
  } }));
263
266
  }
267
+ /**
268
+ * The admin's section: the app's shared keys and each person's monthly allowance on them. Put it
269
+ * where the app keeps its admin settings, shown only to admins.
270
+ */
271
+ export function assistantAdminSections({ client, }) {
272
+ return [
273
+ {
274
+ id: 'assistant-shared-keys',
275
+ title: 'Shared model keys',
276
+ icon: KeyRoundIcon,
277
+ description: "Keys everyone's answers run on, and the allowance",
278
+ content: _jsx(SharedKeys, { client: client }),
279
+ },
280
+ ];
281
+ }
264
282
  /**
265
283
  * The assistant's Settings sections: the assistant, then Model providers, Models, Tools, Web and
266
284
  * Spend under it. Pass them to LumenShell's `shared` (or an app's own `settings`).
@@ -279,7 +297,7 @@ export function assistantSettingsSections({ client, name, web = true, }) {
279
297
  title: 'Model providers',
280
298
  icon: KeyRoundIcon,
281
299
  sub: true,
282
- description: 'Keys for each provider, and who pays',
300
+ description: 'Your own key for each provider',
283
301
  content: _jsx(Providers, { client: client }),
284
302
  },
285
303
  {
package/dist/index.d.ts CHANGED
@@ -52,6 +52,7 @@ export { ConnectionBlock, ErrorLine, IntegrationRow, KeyTestLine, type KeyTestRe
52
52
  export { LumenExpandList, LumenExpandRow } from './expand-list.js';
53
53
  export { LumenPager } from './pager.js';
54
54
  export { createDeskQueryClient } from './query-client.js';
55
+ export { type ClientPoint, type ClientPointKind, type LumenTelemetryOptions, apiRoute, reportClient, reportClientError, reportFailedFetch, stackFingerprint, useLumenTelemetry, } from './telemetry.js';
55
56
  export { refreshHeaders, withRefreshToken } from './refresh.js';
56
57
  export { type LumenLoad, LumenError, LumenLazy, LumenLoadFooter, LumenLoadTime, LumenProgress, LumenRefresh, LumenSkeleton, LumenStates, marketTrades, useLumenLoad, useLumenSource, useLiveLoad, } from './lumen-source.js';
57
58
  export { LumenBoundary } from './lumen-boundary.js';
package/dist/index.js CHANGED
@@ -59,6 +59,7 @@ export { ConnectionBlock, ErrorLine, IntegrationRow, KeyTestLine, TestBadge, err
59
59
  export { LumenExpandList, LumenExpandRow } from './expand-list.js';
60
60
  export { LumenPager } from './pager.js';
61
61
  export { createDeskQueryClient } from './query-client.js';
62
+ export { apiRoute, reportClient, reportClientError, reportFailedFetch, stackFingerprint, useLumenTelemetry, } from './telemetry.js';
62
63
  export { refreshHeaders, withRefreshToken } from './refresh.js';
63
64
  export { LumenError, LumenLazy, LumenLoadFooter, LumenLoadTime, LumenProgress, LumenRefresh, LumenSkeleton, LumenStates, marketTrades, useLumenLoad, useLumenSource, useLiveLoad, } from './lumen-source.js';
64
65
  export { LumenBoundary } from './lumen-boundary.js';
@@ -16,6 +16,7 @@ import { useRouter } from '@tanstack/react-router';
16
16
  import { ChevronRight, RefreshCw, RotateCcw, TriangleAlert } from 'lucide-react';
17
17
  import { useEffect, useState } from 'react';
18
18
  import { toast } from 'sonner';
19
+ import { reportClientError } from './telemetry.js';
19
20
  import { Button } from './ui/button.js';
20
21
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible.js';
21
22
  import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from './ui/empty.js';
@@ -166,6 +167,7 @@ export function reportError(error, outcome, where) {
166
167
  if (Date.now() - (sent.get(key) ?? 0) < GAP_MS)
167
168
  return;
168
169
  sent.set(key, Date.now());
170
+ reportClientError(err);
169
171
  const body = JSON.stringify({
170
172
  message: err.message.slice(0, 500),
171
173
  stack: (err.stack ?? '').slice(0, 2000),
@@ -0,0 +1,40 @@
1
+ export type ClientPointKind = 'view' | 'error' | 'fetch_fail' | 'vital';
2
+ export interface ClientPoint {
3
+ kind: ClientPointKind;
4
+ /** The screen's route template (`/connections/$id`), never the address with its ids. */
5
+ route: string;
6
+ /** An error's class, an API route, or a vital's name (LCP, INP, CLS). */
7
+ name?: string;
8
+ /** A hash of the error's stack frames. */
9
+ fingerprint?: string;
10
+ /** The `X-Request-Id` of the failed answer, to find its server side. */
11
+ requestId?: string;
12
+ /** ms, or a vital's score. */
13
+ value?: number;
14
+ status?: number;
15
+ device?: 'phone' | 'desktop';
16
+ }
17
+ /** The stack's frames only (a message can carry anything), with each file's query dropped. */
18
+ export declare function stackFingerprint(error: unknown): string;
19
+ /** An API address as a route: no host or query, and id-like segments as `:id`. */
20
+ export declare function apiRoute(url: string): string;
21
+ /** Queues one point (nothing is kept until `useLumenTelemetry` has named the app); the batch goes out every 15 s, at 50 points, or when the tab is hidden. */
22
+ export declare function reportClient(p: Omit<ClientPoint, 'device' | 'route'> & {
23
+ route?: string;
24
+ }): void;
25
+ /** A request that failed: its API route template, status (0 when it never answered) and id. */
26
+ export declare function reportFailedFetch(apiRoute: string, status: number, requestId?: string | null): void;
27
+ /** An error the page caught or didn't: its class and stack hash only. */
28
+ export declare function reportClientError(error: unknown): void;
29
+ export interface LumenTelemetryOptions {
30
+ /** The app's name in the points (`console`, `admin`). */
31
+ app: string;
32
+ /** The current screen's route template, from the app's router. */
33
+ route: string;
34
+ /** The build or deploy the page runs. */
35
+ version?: string;
36
+ /** Where batches go; `/api/t` by default. */
37
+ endpoint?: string;
38
+ }
39
+ /** Mount once at the app's root: every screen change is a view, and errors and vitals follow. */
40
+ export declare function useLumenTelemetry(opts: LumenTelemetryOptions): void;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The app's own telemetry from the browser: screens opened, uncaught errors, failed requests and
3
+ * Web Vitals, sent in batches to the app's Worker (by default `POST /api/t`), which checks each
4
+ * point and writes it with `meterClientBatch` from `@columenlabs/lumen-ops/client`.
5
+ *
6
+ * It is first party and keeps nothing in the browser: no cookie, no storage, no third party. A
7
+ * point holds a route template, an error's class and a hash of its stack, a status and a number.
8
+ * Never an error's message, a URL's query, page text or anything typed.
9
+ */
10
+ import { useEffect } from 'react';
11
+ import { onCLS, onINP, onLCP } from 'web-vitals';
12
+ const MAX_BATCH = 50;
13
+ const FLUSH_MS = 15_000;
14
+ const CODE = /^[A-Za-z0-9_.:/$-]{1,64}$/;
15
+ let endpoint = '/api/t';
16
+ let app = '';
17
+ let version = '';
18
+ let route = '';
19
+ const queue = [];
20
+ let started = false;
21
+ const fnv = (s) => {
22
+ let h = 2166136261;
23
+ for (let i = 0; i < s.length; i++)
24
+ h = Math.imul(h ^ s.charCodeAt(i), 16777619) >>> 0;
25
+ return h.toString(16).padStart(8, '0');
26
+ };
27
+ /** The stack's frames only (a message can carry anything), with each file's query dropped. */
28
+ export function stackFingerprint(error) {
29
+ const stack = error instanceof Error ? (error.stack ?? '') : '';
30
+ const frames = stack
31
+ .split('\n')
32
+ .filter((l) => /^\s*at\s|@/.test(l))
33
+ .slice(0, 8)
34
+ .map((l) => l.replace(/\?[^:)\s]*/g, '').trim());
35
+ return fnv(frames.join('\n'));
36
+ }
37
+ const device = () => typeof window !== 'undefined' && window.matchMedia?.('(max-width: 767px)').matches
38
+ ? 'phone'
39
+ : 'desktop';
40
+ const code = (s) => (s && CODE.test(s) ? s : undefined);
41
+ /** An API address as a route: no host or query, and id-like segments as `:id`. */
42
+ export function apiRoute(url) {
43
+ let path = url;
44
+ try {
45
+ path = new URL(url, 'http://x').pathname;
46
+ }
47
+ catch { }
48
+ return path
49
+ .split('/')
50
+ .map((seg) => /^[a-z]{1,4}_[A-Za-z0-9]+$/.test(seg) ||
51
+ seg.length >= 20 ||
52
+ (seg.length >= 8 && /\d/.test(seg)) ||
53
+ /[^A-Za-z0-9_.-]/.test(seg)
54
+ ? ':id'
55
+ : seg)
56
+ .join('/')
57
+ .slice(0, 64);
58
+ }
59
+ function flush() {
60
+ if (!app || queue.length === 0)
61
+ return;
62
+ const points = queue.splice(0, MAX_BATCH);
63
+ const body = JSON.stringify({ app, version: code(version), points });
64
+ try {
65
+ const sent = navigator.sendBeacon?.(endpoint, new Blob([body], { type: 'application/json' }));
66
+ if (!sent)
67
+ fetch(endpoint, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json' },
70
+ body,
71
+ keepalive: true,
72
+ }).catch(() => { });
73
+ }
74
+ catch {
75
+ // Telemetry must never break the page.
76
+ }
77
+ if (queue.length)
78
+ flush();
79
+ }
80
+ /** Queues one point (nothing is kept until `useLumenTelemetry` has named the app); the batch goes out every 15 s, at 50 points, or when the tab is hidden. */
81
+ export function reportClient(p) {
82
+ if (!started)
83
+ return;
84
+ queue.push({
85
+ ...p,
86
+ route: p.route ?? route,
87
+ name: code(p.name),
88
+ requestId: code(p.requestId),
89
+ device: device(),
90
+ });
91
+ if (queue.length >= MAX_BATCH)
92
+ flush();
93
+ }
94
+ /** A request that failed: its API route template, status (0 when it never answered) and id. */
95
+ export function reportFailedFetch(apiRoute, status, requestId) {
96
+ reportClient({ kind: 'fetch_fail', name: apiRoute, status, requestId: requestId ?? undefined });
97
+ }
98
+ /** An error the page caught or didn't: its class and stack hash only. */
99
+ export function reportClientError(error) {
100
+ reportClient({
101
+ kind: 'error',
102
+ name: error instanceof Error ? error.name : 'Error',
103
+ fingerprint: stackFingerprint(error),
104
+ });
105
+ }
106
+ const vital = (m) => reportClient({ kind: 'vital', name: m.name, value: m.value });
107
+ function start() {
108
+ if (started || typeof window === 'undefined')
109
+ return;
110
+ started = true;
111
+ window.addEventListener('error', (e) => reportClientError(e.error ?? new Error('script')));
112
+ window.addEventListener('unhandledrejection', (e) => reportClientError(e.reason));
113
+ document.addEventListener('visibilitychange', () => {
114
+ if (document.visibilityState === 'hidden')
115
+ flush();
116
+ });
117
+ window.addEventListener('pagehide', flush);
118
+ setInterval(flush, FLUSH_MS);
119
+ onLCP(vital);
120
+ onINP(vital);
121
+ onCLS(vital);
122
+ }
123
+ /** Mount once at the app's root: every screen change is a view, and errors and vitals follow. */
124
+ export function useLumenTelemetry(opts) {
125
+ useEffect(() => {
126
+ app = opts.app;
127
+ version = opts.version ?? '';
128
+ endpoint = opts.endpoint ?? '/api/t';
129
+ start();
130
+ }, [opts.app, opts.version, opts.endpoint]);
131
+ useEffect(() => {
132
+ route = opts.route;
133
+ reportClient({ kind: 'view', route: opts.route });
134
+ }, [opts.route]);
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.79",
3
+ "version": "0.1.81",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  "@codemirror/lang-javascript": "6.2.5",
50
50
  "@codemirror/lang-json": "6.0.2",
51
51
  "@codemirror/lang-sql": "6.10.0",
52
- "@columenlabs/lumen-ui-types": "0.1.79",
52
+ "@columenlabs/lumen-ui-types": "0.1.81",
53
53
  "@dnd-kit/core": "6.3.1",
54
54
  "@dnd-kit/sortable": "10.0.0",
55
55
  "@dnd-kit/utilities": "3.2.2",
@@ -83,6 +83,7 @@
83
83
  "sonner": "^2.0.8",
84
84
  "tailwind-merge": "^3.7.0",
85
85
  "topojson-client": "3.1.0",
86
+ "web-vitals": "6.2.3",
86
87
  "world-atlas": "2.0.2"
87
88
  },
88
89
  "peerDependencies": {