@iaportafolio/nextjs 0.1.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 ADDED
@@ -0,0 +1,91 @@
1
+ # @iaportafolio/nextjs
2
+
3
+ SDK para Next.js (App Router y Pages Router). Tiene dos mitades: **server** (Node runtime, captura errores de Route Handlers / Server Actions / SSR) y **client** (browser, captura `window.onerror` + `unhandledrejection` + Web Vitals manuales).
4
+
5
+ ```bash
6
+ npm install @iaportafolio/nextjs @iaportafolio/node
7
+ ```
8
+
9
+ ## Server-side
10
+
11
+ ```ts
12
+ // instrumentation.ts (en la raíz del proyecto, junto a app/)
13
+ export async function register() {
14
+ const { registerFaro } = await import('@iaportafolio/nextjs/server');
15
+ registerFaro({
16
+ endpoint: process.env.FARO_ENDPOINT!,
17
+ token: process.env.FARO_TOKEN!,
18
+ service: 'mi-next-app',
19
+ environment: process.env.NODE_ENV,
20
+ release: process.env.VERCEL_GIT_COMMIT_SHA,
21
+ });
22
+ }
23
+
24
+ // Next 15+: hook nativo de errores de request.
25
+ export async function onRequestError(err: unknown, request: { path: string; method: string }) {
26
+ const { captureRequestError } = await import('@iaportafolio/nextjs/server');
27
+ captureRequestError(err, request);
28
+ }
29
+ ```
30
+
31
+ En cualquier ruta server:
32
+
33
+ ```ts
34
+ import { faro } from '@iaportafolio/nextjs/server';
35
+
36
+ export async function POST(req: Request) {
37
+ try {
38
+ return await procesar(req);
39
+ } catch (e) {
40
+ faro.captureException(e, { tags: { route: '/api/charge' } });
41
+ return new Response('fallo', { status: 500 });
42
+ }
43
+ }
44
+ ```
45
+
46
+ ## Client-side (browser)
47
+
48
+ ```tsx
49
+ // app/faro-client.tsx
50
+ 'use client';
51
+ import { useEffect } from 'react';
52
+ import { initFaroClient } from '@iaportafolio/nextjs/client';
53
+
54
+ export function FaroClient() {
55
+ useEffect(() => {
56
+ initFaroClient({
57
+ endpoint: process.env.NEXT_PUBLIC_FARO_ENDPOINT!,
58
+ token: process.env.NEXT_PUBLIC_FARO_TOKEN!,
59
+ service: 'mi-next-app-web',
60
+ });
61
+ }, []);
62
+ return null;
63
+ }
64
+
65
+ // app/layout.tsx
66
+ import { FaroClient } from './faro-client';
67
+ export default function RootLayout({ children }) {
68
+ return (
69
+ <html>
70
+ <body>
71
+ <FaroClient />
72
+ {children}
73
+ </body>
74
+ </html>
75
+ );
76
+ }
77
+ ```
78
+
79
+ El cliente:
80
+ - Registra `window.onerror` y `unhandledrejection`.
81
+ - Hace `flush` automático al `visibilitychange=hidden` y `pagehide` (usa `navigator.sendBeacon` cuando está disponible).
82
+ - Adjunta `browser.url` y `browser.userAgent` a cada evento.
83
+
84
+ ## Variables de entorno
85
+
86
+ | Var | Dónde | Para qué |
87
+ | --------------------------------- | ------------------ | ----------------------------------------- |
88
+ | `FARO_ENDPOINT` | solo servidor | URL base |
89
+ | `FARO_TOKEN` | solo servidor | Token de proyecto (privado) |
90
+ | `NEXT_PUBLIC_FARO_ENDPOINT` | cliente + servidor | URL base para el navegador |
91
+ | `NEXT_PUBLIC_FARO_TOKEN` | cliente + servidor | **Mismo token de proyecto.** Sí queda expuesto en el bundle — es deliberado, igual que en Sentry: el token solo permite ingerir, no leer datos del dashboard. |
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@iaportafolio/nextjs",
3
+ "version": "0.1.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
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" },
12
+ "./client": { "types": "./dist/client.d.ts", "import": "./dist/client.js", "require": "./dist/client.cjs" },
13
+ "./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js", "require": "./dist/server.cjs" }
14
+ },
15
+ "files": ["dist", "src", "README.md"],
16
+ "repository": { "type": "git", "url": "git+https://github.com/IA-Portafolio/faro.git", "directory": "sdks/nextjs" },
17
+ "homepage": "https://github.com/IA-Portafolio/faro/tree/main/sdks/nextjs#readme",
18
+ "bugs": "https://github.com/IA-Portafolio/faro/issues",
19
+ "keywords": ["faro", "nextjs", "observability", "logging", "telemetry"],
20
+ "publishConfig": { "access": "public" },
21
+ "scripts": { "build": "tsup src/index.ts src/client.ts src/server.ts --format cjs,esm --dts --clean" },
22
+ "peerDependencies": { "@iaportafolio/node": "^0.1.0", "next": ">=13" },
23
+ "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.4.0", "@types/node": "^20.0.0" }
24
+ }
package/src/client.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Faro para Next.js — lado cliente (corre en el navegador).
3
+ *
4
+ * Uso:
5
+ *
6
+ * // app/faro-client.tsx
7
+ * 'use client';
8
+ * import { useEffect } from 'react';
9
+ * import { initFaroClient } from '@iaportafolio/nextjs/client';
10
+ *
11
+ * export function FaroClient() {
12
+ * useEffect(() => {
13
+ * initFaroClient({
14
+ * endpoint: process.env.NEXT_PUBLIC_FARO_ENDPOINT!,
15
+ * token: process.env.NEXT_PUBLIC_FARO_TOKEN!,
16
+ * service: 'mi-next-app-web',
17
+ * });
18
+ * }, []);
19
+ * return null;
20
+ * }
21
+ *
22
+ * // y renderiza <FaroClient /> dentro de app/layout.tsx
23
+ */
24
+
25
+ import type { FaroOptions } from '@iaportafolio/node';
26
+
27
+ let client: ReturnType<typeof newClient> | null = null;
28
+
29
+ interface BrowserClient {
30
+ log(entry: { level?: string; message: string; attributes?: Record<string, unknown> }): void;
31
+ info(msg: string, attrs?: Record<string, unknown>): void;
32
+ warn(msg: string, attrs?: Record<string, unknown>): void;
33
+ error(msg: string, attrs?: Record<string, unknown>): void;
34
+ captureException(err: unknown, ctx?: { tags?: Record<string, string>; message?: string }): void;
35
+ flush(): Promise<void>;
36
+ }
37
+
38
+ function newClient(opts: FaroOptions): BrowserClient {
39
+ const endpoint = opts.endpoint.replace(/\/$/, '');
40
+ const queue: unknown[] = [];
41
+ const maxBatch = opts.maxBatchSize ?? 100;
42
+ let pendingTimer: ReturnType<typeof setTimeout> | null = null;
43
+
44
+ function scheduleFlush(): void {
45
+ if (pendingTimer) return;
46
+ pendingTimer = setTimeout(() => {
47
+ pendingTimer = null;
48
+ void flush();
49
+ }, opts.flushIntervalMs ?? 1500);
50
+ }
51
+
52
+ async function flush(): Promise<void> {
53
+ if (queue.length === 0) return;
54
+ const batch = queue.splice(0, maxBatch);
55
+ try {
56
+ // sendBeacon maneja `pagehide` mejor que fetch.
57
+ const body = JSON.stringify({ service: opts.service, logs: batch });
58
+ const ok =
59
+ typeof navigator !== 'undefined' &&
60
+ typeof navigator.sendBeacon === 'function' &&
61
+ document.visibilityState === 'hidden'
62
+ ? navigator.sendBeacon(
63
+ `${endpoint}/api/v1/ingest/logs?_token=${encodeURIComponent(opts.token)}`,
64
+ new Blob([body], { type: 'application/json' }),
65
+ )
66
+ : false;
67
+ if (ok) return;
68
+ const res = await fetch(`${endpoint}/api/v1/ingest/logs`, {
69
+ method: 'POST',
70
+ keepalive: true,
71
+ headers: {
72
+ 'Authorization': `Bearer ${opts.token}`,
73
+ 'Content-Type': 'application/json',
74
+ },
75
+ body,
76
+ });
77
+ if (!res.ok) {
78
+ // Re-encola en best-effort si no es un 4xx (ahí la request es irrecuperable).
79
+ if (res.status >= 500) queue.unshift(...batch);
80
+ }
81
+ } catch (_e) {
82
+ queue.unshift(...batch);
83
+ }
84
+ }
85
+
86
+ function enqueue(level: string, message: string, attributes?: Record<string, unknown>): void {
87
+ const attrs: Record<string, string> = {};
88
+ if (opts.attributes) {
89
+ for (const [k, v] of Object.entries(opts.attributes)) attrs[k] = String(v);
90
+ }
91
+ if (opts.environment) attrs['deployment.environment'] = opts.environment;
92
+ if (opts.release) attrs['service.version'] = opts.release;
93
+ if (typeof window !== 'undefined') {
94
+ attrs['browser.url'] = window.location.href;
95
+ attrs['browser.userAgent'] = navigator.userAgent;
96
+ }
97
+ if (attributes) {
98
+ for (const [k, v] of Object.entries(attributes)) {
99
+ attrs[k] = typeof v === 'string' ? v : JSON.stringify(v);
100
+ }
101
+ }
102
+ queue.push({
103
+ level,
104
+ message,
105
+ timestamp: new Date().toISOString(),
106
+ attributes: attrs,
107
+ });
108
+ if (queue.length >= maxBatch) void flush();
109
+ else scheduleFlush();
110
+ }
111
+
112
+ return {
113
+ log: (e) => enqueue(e.level ?? 'INFO', e.message, e.attributes),
114
+ info: (m, a) => enqueue('INFO', m, a),
115
+ warn: (m, a) => enqueue('WARN', m, a),
116
+ error: (m, a) => enqueue('ERROR', m, a),
117
+ captureException: (err, ctx) => {
118
+ const e = err instanceof Error ? err : new Error(typeof err === 'string' ? err : JSON.stringify(err));
119
+ enqueue('ERROR', ctx?.message ?? `${e.name}: ${e.message}`, {
120
+ 'exception.type': e.name,
121
+ 'exception.message': e.message,
122
+ 'exception.stacktrace': e.stack ?? '',
123
+ ...(ctx?.tags ?? {}),
124
+ });
125
+ },
126
+ flush,
127
+ };
128
+ }
129
+
130
+ export function initFaroClient(opts: FaroOptions): BrowserClient {
131
+ if (typeof window === 'undefined') {
132
+ // Render en servidor — devolvemos un no-op para que el mismo import funcione.
133
+ return {
134
+ log() {}, info() {}, warn() {}, error() {}, captureException() {}, flush: async () => undefined,
135
+ };
136
+ }
137
+ client = newClient(opts);
138
+
139
+ // Captura errores no manejados en el navegador.
140
+ window.addEventListener('error', (ev) => {
141
+ client?.captureException(ev.error ?? ev.message, { tags: { origin: 'window.error' } });
142
+ });
143
+ window.addEventListener('unhandledrejection', (ev) => {
144
+ client?.captureException(ev.reason, { tags: { origin: 'unhandledrejection' } });
145
+ });
146
+ // Hace flush cuando la pestaña se oculta.
147
+ document.addEventListener('visibilitychange', () => {
148
+ if (document.visibilityState === 'hidden') void client?.flush();
149
+ });
150
+ window.addEventListener('pagehide', () => void client?.flush());
151
+
152
+ return client;
153
+ }
154
+
155
+ export function faroClient(): BrowserClient {
156
+ if (!client) throw new Error('Hay que llamar a initFaroClient() antes de usarlo');
157
+ return client;
158
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ // Re-exporta ambas mitades por comodidad.
2
+ // El tree-shaking igual mantiene la mitad client fuera de los bundles de server cuando los
3
+ // consumidores importan `@iaportafolio/nextjs/server` (y viceversa).
4
+ export * from './server';
5
+ export * from './client';
package/src/server.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Faro para Next.js — lado servidor (instrumentation.ts).
3
+ *
4
+ * Uso (Next.js 13.4+ App Router):
5
+ *
6
+ * // next.config.mjs
7
+ * export default { experimental: { instrumentationHook: true } }; // Next 14- only
8
+ *
9
+ * // instrumentation.ts
10
+ * export async function register() {
11
+ * const { registerFaro } = await import('@iaportafolio/nextjs/server');
12
+ * registerFaro({
13
+ * endpoint: process.env.FARO_ENDPOINT!,
14
+ * token: process.env.FARO_TOKEN!,
15
+ * service: 'mi-next-app',
16
+ * environment: process.env.NODE_ENV,
17
+ * release: process.env.VERCEL_GIT_COMMIT_SHA,
18
+ * });
19
+ * }
20
+ *
21
+ * export async function onRequestError(err: unknown, request: { path: string; method: string }) {
22
+ * const { captureRequestError } = await import('@iaportafolio/nextjs/server');
23
+ * captureRequestError(err, request);
24
+ * }
25
+ */
26
+
27
+ import * as faro from '@iaportafolio/node';
28
+
29
+ export type ServerOptions = faro.FaroOptions;
30
+
31
+ let installed = false;
32
+
33
+ export function registerFaro(opts: ServerOptions): void {
34
+ if (installed) return;
35
+ // El Edge Runtime es un proceso distinto; solo inicializamos en el runtime de Node.
36
+ // Next expone process.env.NEXT_RUNTIME = 'nodejs' | 'edge'.
37
+ if (process.env.NEXT_RUNTIME && process.env.NEXT_RUNTIME !== 'nodejs') {
38
+ return;
39
+ }
40
+ faro.init(opts);
41
+ installed = true;
42
+ }
43
+
44
+ /**
45
+ * Engánchalo al hook de instrumentación `onRequestError` de Next.js (Next 15+).
46
+ * Reporta el error con el contexto de la request adjunto como tags.
47
+ */
48
+ export function captureRequestError(
49
+ err: unknown,
50
+ request: { path?: string; method?: string; routerKind?: string },
51
+ ): void {
52
+ if (!installed) return;
53
+ faro.captureException(err, {
54
+ tags: {
55
+ 'http.path': request.path ?? '',
56
+ 'http.method': request.method ?? '',
57
+ 'next.router': request.routerKind ?? '',
58
+ },
59
+ });
60
+ }
61
+
62
+ export { faro };