@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 +5 -1
- package/dist/assistant/index.d.ts +1 -1
- package/dist/assistant/index.js +1 -1
- package/dist/assistant/settings.d.ts +7 -0
- package/dist/assistant/settings.js +36 -18
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/route-error.js +2 -0
- package/dist/telemetry.d.ts +40 -0
- package/dist/telemetry.js +135 -0
- package/package.json +3 -2
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.
|
|
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';
|
package/dist/assistant/index.js
CHANGED
|
@@ -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
|
-
|
|
177
|
-
|
|
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
|
|
182
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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: '
|
|
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';
|
package/dist/route-error.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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": {
|