@iaportafolio/nextjs 0.1.1 → 0.3.0
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/README.md +86 -8
- package/dist/chunk-I3FDJF4L.js +369 -0
- package/dist/client.cjs +365 -93
- package/dist/client.d.cts +191 -0
- package/dist/client.d.ts +191 -0
- package/dist/client.js +25 -5
- package/dist/index.cjs +354 -94
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +25 -5
- package/dist/server.cjs +1 -0
- package/dist/server.d.cts +42 -0
- package/dist/server.d.ts +42 -0
- package/package.json +61 -62
- package/src/browser-core.ts +394 -0
- package/src/browser-react.tsx +53 -0
- package/src/client.ts +63 -131
- package/dist/chunk-TYH3TMKC.js +0 -120
package/package.json
CHANGED
|
@@ -1,62 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@iaportafolio/nextjs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Faro SDK for Next.js (App Router + Pages Router)",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"main": "./dist/index.cjs",
|
|
8
|
-
"module": "./dist/index.js",
|
|
9
|
-
"types": "./dist/index.d.ts",
|
|
10
|
-
"exports": {
|
|
11
|
-
".": {
|
|
12
|
-
"types": "./dist/index.d.ts",
|
|
13
|
-
"import": "./dist/index.js",
|
|
14
|
-
"require": "./dist/index.cjs"
|
|
15
|
-
},
|
|
16
|
-
"./client": {
|
|
17
|
-
"types": "./dist/client.d.ts",
|
|
18
|
-
"import": "./dist/client.js",
|
|
19
|
-
"require": "./dist/client.cjs"
|
|
20
|
-
},
|
|
21
|
-
"./server": {
|
|
22
|
-
"types": "./dist/server.d.ts",
|
|
23
|
-
"import": "./dist/server.js",
|
|
24
|
-
"require": "./dist/server.cjs"
|
|
25
|
-
}
|
|
26
|
-
},
|
|
27
|
-
"files": [
|
|
28
|
-
"dist",
|
|
29
|
-
"src",
|
|
30
|
-
"README.md"
|
|
31
|
-
],
|
|
32
|
-
"repository": {
|
|
33
|
-
"type": "git",
|
|
34
|
-
"url": "git+https://github.com/IA-Portafolio/faro.git",
|
|
35
|
-
"directory": "sdks/nextjs"
|
|
36
|
-
},
|
|
37
|
-
"homepage": "https://github.com/IA-Portafolio/faro/tree/main/sdks/nextjs#readme",
|
|
38
|
-
"bugs": "https://github.com/IA-Portafolio/faro/issues",
|
|
39
|
-
"keywords": [
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
},
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
},
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@iaportafolio/nextjs",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Faro SDK for Next.js (App Router + Pages Router): RUM, Web Vitals, error capture, ErrorBoundary",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
},
|
|
16
|
+
"./client": {
|
|
17
|
+
"types": "./dist/client.d.ts",
|
|
18
|
+
"import": "./dist/client.js",
|
|
19
|
+
"require": "./dist/client.cjs"
|
|
20
|
+
},
|
|
21
|
+
"./server": {
|
|
22
|
+
"types": "./dist/server.d.ts",
|
|
23
|
+
"import": "./dist/server.js",
|
|
24
|
+
"require": "./dist/server.cjs"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"src",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/IA-Portafolio/faro.git",
|
|
35
|
+
"directory": "sdks/nextjs"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/IA-Portafolio/faro/tree/main/sdks/nextjs#readme",
|
|
38
|
+
"bugs": "https://github.com/IA-Portafolio/faro/issues",
|
|
39
|
+
"keywords": ["faro", "nextjs", "observability", "rum", "web-vitals", "logging", "telemetry"],
|
|
40
|
+
"publishConfig": { "access": "public" },
|
|
41
|
+
"scripts": { "build": "tsup src/index.ts src/client.ts src/server.ts --format cjs,esm --dts --clean --external react --external next --external @iaportafolio/node" },
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@iaportafolio/node": "^0.1.0",
|
|
44
|
+
"next": ">=13",
|
|
45
|
+
"react": ">=16.8.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"react": { "optional": true }
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"web-vitals": "^4.2.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"tsup": "^8.0.0",
|
|
55
|
+
"typescript": "^5.4.0",
|
|
56
|
+
"@types/node": "^20.0.0",
|
|
57
|
+
"@types/react": "^18.0.0",
|
|
58
|
+
"react": "^18.0.0",
|
|
59
|
+
"@iaportafolio/node": "^0.1.0"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core RUM para navegador (interno de @iaportafolio/nextjs).
|
|
3
|
+
*
|
|
4
|
+
* Captura errores no manejados, Web Vitals, navegaciones y clicks como
|
|
5
|
+
* breadcrumbs, y envía todo en lotes a la API de ingesta usando
|
|
6
|
+
* sendBeacon cuando el tab se cierra (sin perder eventos).
|
|
7
|
+
*
|
|
8
|
+
* Este archivo no se exporta directamente al usuario; el entrypoint
|
|
9
|
+
* público es `@iaportafolio/nextjs/client`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type Severity = 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL';
|
|
13
|
+
|
|
14
|
+
export interface FaroBrowserOptions {
|
|
15
|
+
endpoint: string;
|
|
16
|
+
token: string;
|
|
17
|
+
service: string;
|
|
18
|
+
environment?: string;
|
|
19
|
+
release?: string;
|
|
20
|
+
/** Atributos por defecto adjuntados a cada evento */
|
|
21
|
+
attributes?: Record<string, string | number | boolean>;
|
|
22
|
+
/** Cadencia de flush en ms (default 2000) */
|
|
23
|
+
flushIntervalMs?: number;
|
|
24
|
+
/** Tamaño máximo de batch por POST (default 100) */
|
|
25
|
+
maxBatchSize?: number;
|
|
26
|
+
/** Cola en memoria máxima (default 2000) */
|
|
27
|
+
maxQueueSize?: number;
|
|
28
|
+
/** Tamaño del ring buffer de breadcrumbs (default 30) */
|
|
29
|
+
maxBreadcrumbs?: number;
|
|
30
|
+
/** Capturar window.onerror + unhandledrejection (default true) */
|
|
31
|
+
captureUnhandled?: boolean;
|
|
32
|
+
/** Capturar console.error y console.warn (default false — puede meter ruido) */
|
|
33
|
+
captureConsole?: boolean;
|
|
34
|
+
/** Capturar Web Vitals LCP/CLS/INP/FID/TTFB (default true) */
|
|
35
|
+
captureWebVitals?: boolean;
|
|
36
|
+
/** Capturar clicks como breadcrumbs (default true) */
|
|
37
|
+
captureClicks?: boolean;
|
|
38
|
+
/** Capturar navegaciones SPA (history.pushState/popstate) (default true) */
|
|
39
|
+
captureNavigation?: boolean;
|
|
40
|
+
/** Hook para muestrear o redactar eventos antes de enviar; devolver null descarta */
|
|
41
|
+
beforeSend?: (event: WireEvent) => WireEvent | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UserContext {
|
|
45
|
+
id?: string;
|
|
46
|
+
email?: string;
|
|
47
|
+
username?: string;
|
|
48
|
+
[key: string]: string | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Breadcrumb {
|
|
52
|
+
category: 'click' | 'navigation' | 'console' | 'fetch' | 'custom';
|
|
53
|
+
message: string;
|
|
54
|
+
timestamp: number;
|
|
55
|
+
data?: Record<string, string | number | boolean | undefined>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LogEntry {
|
|
59
|
+
level?: Severity;
|
|
60
|
+
message: string;
|
|
61
|
+
attributes?: Record<string, unknown>;
|
|
62
|
+
trace_id?: string;
|
|
63
|
+
span_id?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface WireEvent {
|
|
67
|
+
level: Severity;
|
|
68
|
+
message: string;
|
|
69
|
+
timestamp: string;
|
|
70
|
+
attributes: Record<string, string>;
|
|
71
|
+
trace_id?: string;
|
|
72
|
+
span_id?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class FaroBrowser {
|
|
76
|
+
private opts: Required<Omit<FaroBrowserOptions, 'attributes' | 'environment' | 'release' | 'beforeSend'>> &
|
|
77
|
+
Pick<FaroBrowserOptions, 'attributes' | 'environment' | 'release' | 'beforeSend'>;
|
|
78
|
+
private queue: WireEvent[] = [];
|
|
79
|
+
private breadcrumbs: Breadcrumb[] = [];
|
|
80
|
+
private user: UserContext | null = null;
|
|
81
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
82
|
+
private cleanup: Array<() => void> = [];
|
|
83
|
+
private closed = false;
|
|
84
|
+
|
|
85
|
+
constructor(opts: FaroBrowserOptions) {
|
|
86
|
+
this.opts = {
|
|
87
|
+
endpoint: opts.endpoint.replace(/\/$/, ''),
|
|
88
|
+
token: opts.token,
|
|
89
|
+
service: opts.service,
|
|
90
|
+
environment: opts.environment,
|
|
91
|
+
release: opts.release,
|
|
92
|
+
attributes: opts.attributes,
|
|
93
|
+
flushIntervalMs: opts.flushIntervalMs ?? 2000,
|
|
94
|
+
maxBatchSize: opts.maxBatchSize ?? 100,
|
|
95
|
+
maxQueueSize: opts.maxQueueSize ?? 2000,
|
|
96
|
+
maxBreadcrumbs: opts.maxBreadcrumbs ?? 30,
|
|
97
|
+
captureUnhandled: opts.captureUnhandled ?? true,
|
|
98
|
+
captureConsole: opts.captureConsole ?? false,
|
|
99
|
+
captureWebVitals: opts.captureWebVitals ?? true,
|
|
100
|
+
captureClicks: opts.captureClicks ?? true,
|
|
101
|
+
captureNavigation: opts.captureNavigation ?? true,
|
|
102
|
+
beforeSend: opts.beforeSend,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (typeof window === 'undefined') {
|
|
106
|
+
// SSR / Node: degradar a no-op silencioso.
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.timer = setInterval(() => void this.flush(), this.opts.flushIntervalMs);
|
|
111
|
+
if (this.opts.captureUnhandled) this.installErrorHandlers();
|
|
112
|
+
if (this.opts.captureConsole) this.installConsoleCapture();
|
|
113
|
+
if (this.opts.captureWebVitals) this.installWebVitals();
|
|
114
|
+
if (this.opts.captureClicks) this.installClickTracking();
|
|
115
|
+
if (this.opts.captureNavigation) this.installNavigationTracking();
|
|
116
|
+
this.installLifecycleHooks();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
setUser(user: UserContext | null): void {
|
|
120
|
+
this.user = user;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
addBreadcrumb(crumb: Omit<Breadcrumb, 'timestamp'>): void {
|
|
124
|
+
if (this.breadcrumbs.length >= this.opts.maxBreadcrumbs) {
|
|
125
|
+
this.breadcrumbs.shift();
|
|
126
|
+
}
|
|
127
|
+
this.breadcrumbs.push({ ...crumb, timestamp: Date.now() });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
log(entry: LogEntry): void {
|
|
131
|
+
if (this.closed) return;
|
|
132
|
+
const attrs = this.composeAttributes(entry.attributes);
|
|
133
|
+
const evt: WireEvent = {
|
|
134
|
+
level: entry.level ?? 'INFO',
|
|
135
|
+
message: entry.message,
|
|
136
|
+
timestamp: new Date().toISOString(),
|
|
137
|
+
attributes: attrs,
|
|
138
|
+
trace_id: entry.trace_id,
|
|
139
|
+
span_id: entry.span_id,
|
|
140
|
+
};
|
|
141
|
+
this.enqueue(evt);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
info(message: string, attrs?: Record<string, unknown>): void { this.log({ level: 'INFO', message, attributes: attrs }); }
|
|
145
|
+
warn(message: string, attrs?: Record<string, unknown>): void { this.log({ level: 'WARN', message, attributes: attrs }); }
|
|
146
|
+
error(message: string, attrs?: Record<string, unknown>): void { this.log({ level: 'ERROR', message, attributes: attrs }); }
|
|
147
|
+
|
|
148
|
+
captureException(err: unknown, ctx?: { tags?: Record<string, string>; message?: string }): void {
|
|
149
|
+
const e = toError(err);
|
|
150
|
+
this.log({
|
|
151
|
+
level: 'ERROR',
|
|
152
|
+
message: ctx?.message ?? `${e.name}: ${e.message}`,
|
|
153
|
+
attributes: {
|
|
154
|
+
'exception.type': e.name,
|
|
155
|
+
'exception.message': e.message,
|
|
156
|
+
'exception.stacktrace': e.stack ?? '',
|
|
157
|
+
...(ctx?.tags ?? {}),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async flush(useBeacon = false): Promise<void> {
|
|
163
|
+
if (this.queue.length === 0) return;
|
|
164
|
+
const batch = this.queue.splice(0, this.opts.maxBatchSize);
|
|
165
|
+
const body = JSON.stringify({ service: this.opts.service, logs: batch });
|
|
166
|
+
const url = `${this.opts.endpoint}/api/v1/ingest/logs`;
|
|
167
|
+
|
|
168
|
+
if (useBeacon && typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
|
169
|
+
const beaconUrl = `${url}?_token=${encodeURIComponent(this.opts.token)}`;
|
|
170
|
+
const ok = navigator.sendBeacon(beaconUrl, new Blob([body], { type: 'application/json' }));
|
|
171
|
+
if (ok) return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const res = await fetch(url, {
|
|
176
|
+
method: 'POST',
|
|
177
|
+
keepalive: true,
|
|
178
|
+
headers: {
|
|
179
|
+
'Authorization': `Bearer ${this.opts.token}`,
|
|
180
|
+
'Content-Type': 'application/json',
|
|
181
|
+
},
|
|
182
|
+
body,
|
|
183
|
+
});
|
|
184
|
+
if (!res.ok && res.status >= 500) {
|
|
185
|
+
this.queue.unshift(...batch);
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
this.queue.unshift(...batch);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
close(): void {
|
|
193
|
+
if (this.closed) return;
|
|
194
|
+
this.closed = true;
|
|
195
|
+
if (this.timer) clearInterval(this.timer);
|
|
196
|
+
this.timer = null;
|
|
197
|
+
for (const fn of this.cleanup) fn();
|
|
198
|
+
this.cleanup = [];
|
|
199
|
+
void this.flush(true);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private enqueue(evt: WireEvent): void {
|
|
203
|
+
const processed = this.opts.beforeSend ? this.opts.beforeSend(evt) : evt;
|
|
204
|
+
if (!processed) return;
|
|
205
|
+
if (this.queue.length >= this.opts.maxQueueSize) return;
|
|
206
|
+
this.queue.push(processed);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private composeAttributes(extra?: Record<string, unknown>): Record<string, string> {
|
|
210
|
+
const attrs: Record<string, string> = {};
|
|
211
|
+
if (this.opts.attributes) {
|
|
212
|
+
for (const [k, v] of Object.entries(this.opts.attributes)) attrs[k] = String(v);
|
|
213
|
+
}
|
|
214
|
+
if (this.opts.environment) attrs['deployment.environment'] = this.opts.environment;
|
|
215
|
+
if (this.opts.release) attrs['service.version'] = this.opts.release;
|
|
216
|
+
if (typeof window !== 'undefined') {
|
|
217
|
+
attrs['browser.url'] = window.location.href;
|
|
218
|
+
attrs['browser.userAgent'] = navigator.userAgent;
|
|
219
|
+
}
|
|
220
|
+
if (this.user) {
|
|
221
|
+
if (this.user.id) attrs['user.id'] = this.user.id;
|
|
222
|
+
if (this.user.email) attrs['user.email'] = this.user.email;
|
|
223
|
+
if (this.user.username) attrs['user.name'] = this.user.username;
|
|
224
|
+
}
|
|
225
|
+
if (this.breadcrumbs.length > 0) {
|
|
226
|
+
attrs['breadcrumbs'] = JSON.stringify(this.breadcrumbs.slice(-this.opts.maxBreadcrumbs));
|
|
227
|
+
}
|
|
228
|
+
if (extra) {
|
|
229
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
230
|
+
attrs[k] = typeof v === 'string' ? v : JSON.stringify(v);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return attrs;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private installErrorHandlers(): void {
|
|
237
|
+
const onError = (ev: ErrorEvent): void => {
|
|
238
|
+
this.captureException(ev.error ?? ev.message, {
|
|
239
|
+
tags: { origin: 'window.error', 'source.file': ev.filename ?? '', 'source.line': String(ev.lineno ?? 0) },
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
const onRejection = (ev: PromiseRejectionEvent): void => {
|
|
243
|
+
this.captureException(ev.reason, { tags: { origin: 'unhandledrejection' } });
|
|
244
|
+
};
|
|
245
|
+
window.addEventListener('error', onError);
|
|
246
|
+
window.addEventListener('unhandledrejection', onRejection);
|
|
247
|
+
this.cleanup.push(() => window.removeEventListener('error', onError));
|
|
248
|
+
this.cleanup.push(() => window.removeEventListener('unhandledrejection', onRejection));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private installConsoleCapture(): void {
|
|
252
|
+
const orig = { error: console.error, warn: console.warn };
|
|
253
|
+
console.error = (...args: unknown[]) => {
|
|
254
|
+
this.addBreadcrumb({ category: 'console', message: String(args[0] ?? ''), data: { level: 'error' } });
|
|
255
|
+
this.log({ level: 'ERROR', message: stringifyArgs(args), attributes: { 'console.method': 'error' } });
|
|
256
|
+
orig.error.apply(console, args);
|
|
257
|
+
};
|
|
258
|
+
console.warn = (...args: unknown[]) => {
|
|
259
|
+
this.addBreadcrumb({ category: 'console', message: String(args[0] ?? ''), data: { level: 'warn' } });
|
|
260
|
+
orig.warn.apply(console, args);
|
|
261
|
+
};
|
|
262
|
+
this.cleanup.push(() => { console.error = orig.error; console.warn = orig.warn; });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private installWebVitals(): void {
|
|
266
|
+
void import('web-vitals')
|
|
267
|
+
.then(({ onLCP, onCLS, onINP, onFCP, onTTFB }) => {
|
|
268
|
+
const report = (name: string) => (m: { value: number; rating: string; id: string }) => {
|
|
269
|
+
this.log({
|
|
270
|
+
level: 'INFO',
|
|
271
|
+
message: `web-vital ${name}`,
|
|
272
|
+
attributes: {
|
|
273
|
+
'metric.name': name,
|
|
274
|
+
'metric.value': m.value,
|
|
275
|
+
'metric.rating': m.rating,
|
|
276
|
+
'metric.id': m.id,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
onLCP(report('LCP'));
|
|
281
|
+
onCLS(report('CLS'));
|
|
282
|
+
onINP(report('INP'));
|
|
283
|
+
onFCP(report('FCP'));
|
|
284
|
+
onTTFB(report('TTFB'));
|
|
285
|
+
})
|
|
286
|
+
.catch(() => {
|
|
287
|
+
// web-vitals no instalado o falló el dynamic import — silencio.
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private installClickTracking(): void {
|
|
292
|
+
const onClick = (ev: MouseEvent): void => {
|
|
293
|
+
const target = ev.target as Element | null;
|
|
294
|
+
if (!target) return;
|
|
295
|
+
const tag = target.tagName?.toLowerCase() ?? '';
|
|
296
|
+
const id = (target as HTMLElement).id;
|
|
297
|
+
const text = (target.textContent ?? '').trim().slice(0, 60);
|
|
298
|
+
const data: Record<string, string | number> = { tag };
|
|
299
|
+
if (id) data.id = id;
|
|
300
|
+
if (text) data.text = text;
|
|
301
|
+
this.addBreadcrumb({ category: 'click', message: `${tag}${id ? '#' + id : ''}`, data });
|
|
302
|
+
};
|
|
303
|
+
window.addEventListener('click', onClick, { capture: true, passive: true });
|
|
304
|
+
this.cleanup.push(() => window.removeEventListener('click', onClick, { capture: true }));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private installNavigationTracking(): void {
|
|
308
|
+
const log = (from: string, to: string, method: string): void => {
|
|
309
|
+
if (from === to) return;
|
|
310
|
+
this.addBreadcrumb({ category: 'navigation', message: `${from} → ${to}`, data: { method, to } });
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const origPush = history.pushState;
|
|
314
|
+
const origReplace = history.replaceState;
|
|
315
|
+
history.pushState = function (this: History, ...args) {
|
|
316
|
+
const from = location.href;
|
|
317
|
+
const ret = origPush.apply(this, args);
|
|
318
|
+
log(from, location.href, 'pushState');
|
|
319
|
+
return ret;
|
|
320
|
+
};
|
|
321
|
+
history.replaceState = function (this: History, ...args) {
|
|
322
|
+
const from = location.href;
|
|
323
|
+
const ret = origReplace.apply(this, args);
|
|
324
|
+
log(from, location.href, 'replaceState');
|
|
325
|
+
return ret;
|
|
326
|
+
};
|
|
327
|
+
const onPop = (): void => log('', location.href, 'popstate');
|
|
328
|
+
window.addEventListener('popstate', onPop);
|
|
329
|
+
|
|
330
|
+
this.cleanup.push(() => {
|
|
331
|
+
history.pushState = origPush;
|
|
332
|
+
history.replaceState = origReplace;
|
|
333
|
+
window.removeEventListener('popstate', onPop);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private installLifecycleHooks(): void {
|
|
338
|
+
const onHide = (): void => {
|
|
339
|
+
if (document.visibilityState === 'hidden') void this.flush(true);
|
|
340
|
+
};
|
|
341
|
+
const onPageHide = (): void => void this.flush(true);
|
|
342
|
+
document.addEventListener('visibilitychange', onHide);
|
|
343
|
+
window.addEventListener('pagehide', onPageHide);
|
|
344
|
+
this.cleanup.push(() => document.removeEventListener('visibilitychange', onHide));
|
|
345
|
+
this.cleanup.push(() => window.removeEventListener('pagehide', onPageHide));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function toError(err: unknown): Error {
|
|
350
|
+
if (err instanceof Error) return err;
|
|
351
|
+
if (typeof err === 'string') return new Error(err);
|
|
352
|
+
try {
|
|
353
|
+
return new Error(JSON.stringify(err));
|
|
354
|
+
} catch {
|
|
355
|
+
return new Error(String(err));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function stringifyArgs(args: unknown[]): string {
|
|
360
|
+
return args
|
|
361
|
+
.map((a) => (typeof a === 'string' ? a : a instanceof Error ? a.stack ?? a.message : safeJson(a)))
|
|
362
|
+
.join(' ');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function safeJson(v: unknown): string {
|
|
366
|
+
try { return JSON.stringify(v); } catch { return String(v); }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let singleton: FaroBrowser | null = null;
|
|
370
|
+
|
|
371
|
+
export function init(opts: FaroBrowserOptions): FaroBrowser {
|
|
372
|
+
if (singleton) singleton.close();
|
|
373
|
+
singleton = new FaroBrowser(opts);
|
|
374
|
+
return singleton;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function getClient(): FaroBrowser {
|
|
378
|
+
if (!singleton) throw new Error('faro: init() must be called before use');
|
|
379
|
+
return singleton;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function log(entry: LogEntry): void { getClient().log(entry); }
|
|
383
|
+
export function info(msg: string, attrs?: Record<string, unknown>): void { getClient().info(msg, attrs); }
|
|
384
|
+
export function warn(msg: string, attrs?: Record<string, unknown>): void { getClient().warn(msg, attrs); }
|
|
385
|
+
export function error(msg: string, attrs?: Record<string, unknown>): void { getClient().error(msg, attrs); }
|
|
386
|
+
export function captureException(err: unknown, ctx?: { tags?: Record<string, string>; message?: string }): void {
|
|
387
|
+
getClient().captureException(err, ctx);
|
|
388
|
+
}
|
|
389
|
+
export function setUser(user: UserContext | null): void { getClient().setUser(user); }
|
|
390
|
+
export function addBreadcrumb(crumb: Omit<Breadcrumb, 'timestamp'>): void { getClient().addBreadcrumb(crumb); }
|
|
391
|
+
export function flush(): Promise<void> { return getClient().flush(); }
|
|
392
|
+
export function close(): void { getClient().close(); }
|
|
393
|
+
|
|
394
|
+
export { FaroBrowser };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React ErrorBoundary que reporta automáticamente a Faro.
|
|
3
|
+
* Importar desde `@iaportafolio/nextjs/client`.
|
|
4
|
+
*/
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
import { captureException } from './browser-core';
|
|
7
|
+
|
|
8
|
+
export interface FaroErrorBoundaryProps {
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
/** Fallback UI cuando un hijo lanza. Recibe el error y un `reset` para reintentar. */
|
|
11
|
+
fallback?: React.ReactNode | ((args: { error: Error; reset: () => void }) => React.ReactNode);
|
|
12
|
+
/** Tags adicionales para el evento (ej. nombre del módulo) */
|
|
13
|
+
tags?: Record<string, string>;
|
|
14
|
+
/** Hook opcional cuando se captura un error (para tracking adicional) */
|
|
15
|
+
onError?: (error: Error, info: React.ErrorInfo) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface State {
|
|
19
|
+
error: Error | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class FaroErrorBoundary extends React.Component<FaroErrorBoundaryProps, State> {
|
|
23
|
+
state: State = { error: null };
|
|
24
|
+
|
|
25
|
+
static getDerivedStateFromError(error: Error): State {
|
|
26
|
+
return { error };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
|
30
|
+
captureException(error, {
|
|
31
|
+
tags: {
|
|
32
|
+
origin: 'react.error-boundary',
|
|
33
|
+
...(this.props.tags ?? {}),
|
|
34
|
+
},
|
|
35
|
+
message: error.message,
|
|
36
|
+
});
|
|
37
|
+
this.props.onError?.(error, info);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
reset = (): void => {
|
|
41
|
+
this.setState({ error: null });
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
render(): React.ReactNode {
|
|
45
|
+
if (this.state.error) {
|
|
46
|
+
const fb = this.props.fallback;
|
|
47
|
+
if (typeof fb === 'function') return fb({ error: this.state.error, reset: this.reset });
|
|
48
|
+
if (fb !== undefined) return fb;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return this.props.children;
|
|
52
|
+
}
|
|
53
|
+
}
|