@columenlabs/lumen-ui 0.1.80 → 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/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;
|
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": {
|