@eiaifactory/monitor 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EI AI Factory
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,285 @@
1
+ # @eiaifactory/monitor
2
+
3
+ Manda los errores de un sistema de cliente al Monitor de EI AI Factory, sin que el usuario final
4
+ lo note.
5
+
6
+ Este README **es el contrato**: el SDK es una forma de emitir, pero cualquier cosa que sepa hacer
7
+ un `POST` con JSON y una clave emite igual (un `curl` en un workflow, un `net.http_post` desde un
8
+ cron, un servicio en otro lenguaje). La definición de la que se copia vive en
9
+ [`eiaifactory-finance`](https://github.com/eiaifactory/eiaifactory-finance/blob/main/supabase/functions/_shared/monitor/tipos.ts).
10
+
11
+ ## La regla que manda: invisible para el usuario final
12
+
13
+ - `capturar()` guarda el error en memoria y retorna (en menos de 1 ms): nada de red ni de
14
+ `localStorage` en el mismo tick. Lo demás corre después, cuando el navegador está ocioso.
15
+ - Nunca lanza, nunca muestra nada, no escribe en la consola (salvo `debug: true`).
16
+ - Si finance no contesta, el sistema del cliente anda igual: la cola espera, con tope.
17
+ - Sin clave no hace nada. Así corre en desarrollo local, en tests y en SSR.
18
+
19
+ ## Instalar
20
+
21
+ ```bash
22
+ npm install @eiaifactory/monitor
23
+ ```
24
+
25
+ La clave de cada ambiente se crea en finance → **Monitor → Proyectos y claves**, y se ve una sola
26
+ vez. Es un secreto: va en variables de entorno, nunca en el repo.
27
+
28
+ | Dónde | Variable |
29
+ |---|---|
30
+ | Front (Vite) | `VITE_MONITOR_CLAVE` |
31
+ | Edge functions | secreto `MONITOR_CLAVE` |
32
+ | Crons de la base | `monitor_clave` en el vault |
33
+
34
+ ## Navegador
35
+
36
+ ```ts
37
+ // src/main.tsx
38
+ import { initMonitor, onErrorQuery, onErrorMutation, setUsuario } from '@eiaifactory/monitor';
39
+ import { QueryCache, MutationCache, QueryClient } from '@tanstack/react-query';
40
+
41
+ initMonitor({
42
+ clave: import.meta.env.VITE_MONITOR_CLAVE,
43
+ release: import.meta.env.VITE_RELEASE,
44
+ // Opcional: lo que no es un incidente para este sistema.
45
+ ignorar: ['Failed to fetch dynamically imported module'],
46
+ });
47
+
48
+ // Aditivos: los onError de cada query o mutation siguen andando igual.
49
+ export const queryClient = new QueryClient({
50
+ queryCache: new QueryCache({ onError: onErrorQuery }),
51
+ mutationCache: new MutationCache({ onError: onErrorMutation }),
52
+ });
53
+
54
+ // Cuando hay sesión. Un id opaco (uuid), nunca un email.
55
+ setUsuario({ id: session.user.id, rol: perfil.rol });
56
+ ```
57
+
58
+ `release` es el sha corto del build. En Railway, `RAILWAY_GIT_COMMIT_SHA` existe durante el build,
59
+ pero Vite sólo expone las variables con prefijo `VITE_`:
60
+
61
+ ```ts
62
+ // vite.config.ts
63
+ define: {
64
+ 'import.meta.env.VITE_RELEASE': JSON.stringify(process.env.RAILWAY_GIT_COMMIT_SHA?.slice(0, 7) ?? 'local'),
65
+ },
66
+ ```
67
+
68
+ Instalado, captura solo los errores no manejados (`error`) y las promesas rechazadas sin atajar
69
+ (`unhandledrejection`), sin `preventDefault` y sin pisar los handlers de la página.
70
+
71
+ | Función | Qué hace |
72
+ |---|---|
73
+ | `initMonitor({ clave, url?, release?, ignorar?, maxBuffer?, maxPorMinuto?, debug? })` | Instala los listeners. Una sola vez por página; las siguientes se ignoran. |
74
+ | `capturar(error, { ruta?, ubicacion?, contexto? })` | Reporta un error atajado a mano (el `catch` de un flujo que igual querés ver). |
75
+ | `setContext({ clave: valor })` | Suma datos a los próximos eventos (`sucursal`, `terminal`…). Sólo escalares; `undefined` saca la clave. |
76
+ | `setUsuario({ id, rol? })` / `limpiarUsuario()` | Quién estaba usando el sistema. |
77
+ | `onErrorQuery` / `onErrorMutation` | Para `QueryCache` / `MutationCache`. De la key se manda sólo el primer elemento si es texto (el nombre de la consulta), como `ubicacion`: el resto puede traer ids o filtros. |
78
+ | `esEsperado(error)` | La misma regla que usa el SDK para no reportar (ver abajo). |
79
+ | `flush()` | Procesa y manda lo pendiente ya. Nunca rechaza. |
80
+
81
+ ## Edge functions (Supabase)
82
+
83
+ ```ts
84
+ import { initMonitor, withHandler } from 'npm:@eiaifactory/monitor@0.1.0/deno';
85
+
86
+ const monitor = initMonitor({
87
+ clave: Deno.env.get('MONITOR_CLAVE'),
88
+ funcion: 'cobrar',
89
+ release: Deno.env.get('RELEASE'), // si el deploy lo define
90
+ });
91
+
92
+ Deno.serve(
93
+ withHandler(
94
+ monitor,
95
+ async (req) => {
96
+ // …la función de siempre
97
+ return new Response(JSON.stringify(resultado), { headers: corsHeaders });
98
+ },
99
+ // Si la función lanza, esto es lo que se devuelve. Con los CORS de la función: sin ellos,
100
+ // el navegador ve un error de CORS en vez del 500.
101
+ { aRespuesta: () => new Response('{"error":"interno"}', { status: 500, headers: corsHeaders }) },
102
+ ),
103
+ );
104
+ ```
105
+
106
+ `withHandler` devuelve la respuesta de la función tal cual y manda **después**, en
107
+ `EdgeRuntime.waitUntil`: el usuario no espera al Monitor. Reporta lo que la función lanza y las
108
+ respuestas 5xx (como `cobrar: respondió 503`, **sin el cuerpo**: puede traer datos). Las 4xx son
109
+ la función contestando que no, y no se reportan. Lo que se capture a mano con
110
+ `monitor.capturar(error)` durante el request también sale al final.
111
+
112
+ | Opción de `withHandler` | |
113
+ |---|---|
114
+ | `esEsperado(error)` | Además de los esperados de siempre. |
115
+ | `aRespuesta(error)` | La respuesta cuando la función lanza. Default: `500 {"error":"error_interno"}`. |
116
+
117
+ **Node (Railway u otro servidor):** la misma entrada (`import … from '@eiaifactory/monitor/deno'`),
118
+ con `origen: 'servidor'`. Sin `EdgeRuntime`, el envío sale sin esperarlo.
119
+
120
+ ## Qué no se reporta
121
+
122
+ Decisión del spec: los rechazos de negocio y los `toast.error` no son incidentes.
123
+
124
+ - **`PT4xx`**: el `RAISE` de negocio de las funciones de la base (`raise exception using errcode = 'PT402'`).
125
+ - **`AbortError`**: una petición cancelada a propósito.
126
+ - **401 / 403**: sesión vencida o sin permiso. También los `Auth*` de Supabase con status 4xx
127
+ (contraseña mal escrita, mail sin confirmar).
128
+ - Lo que el cliente declare en `ignorar` (texto contenido en el mensaje, o una regex).
129
+
130
+ ## Frenos del lado del cliente
131
+
132
+ | | Navegador | Servidor |
133
+ |---|---|---|
134
+ | Colapso | El mismo error en 10 s viaja una vez, con `repeticiones` | — |
135
+ | Tope | 20 eventos nuevos por minuto (`maxPorMinuto`) | — |
136
+ | Ráfaga | Hasta 100 errores esperando a ser procesados; lo que llega con eso lleno se pierde | — |
137
+ | Cola | 50 (`maxBuffer`) en `localStorage`, hasta 128 KB; descarta lo más viejo | 50 en memoria |
138
+ | Envío | Cada 2 s o al juntar 10; lotes de hasta 20 eventos y 60 KB | Al terminar el request |
139
+ | Timeout | 3 s | 2 s |
140
+ | Red caída o 5xx | Espera 1 → 2 → 4 → 30 s | Un reintento |
141
+ | `429` | Espera `Retry-After`; si el navegador no lo puede leer, hasta el próximo minuto | Espera `Retry-After` (hasta 10 s) y reintenta una vez |
142
+ | `413` | Parte el lote a la mitad | Parte el lote a la mitad |
143
+ | `401` | Se apaga hasta la próxima carga | Se apaga |
144
+
145
+ Un reintento manda los mismos `id_externo`: finance lo cuenta como duplicado, no suma dos veces.
146
+ Al irse de la página (`pagehide`, o la pestaña pasa a segundo plano), lo pendiente se guarda y
147
+ sale en un `fetch` con `keepalive`; si el navegador no lo deja salir, va en la próxima carga.
148
+
149
+ PII, primera línea (la segunda es finance): los valores de `contexto` cuyas claves parecen
150
+ sensibles (`mail`, `tel`, `phone`, `dni`, `cuit`, `pass`, `token`, `secret`, `key`, `clave`) se
151
+ reemplazan por `[redactado]`; emails y teléfonos se redactan en el mensaje, el stack, la ruta y
152
+ el contexto; la ruta viaja sin query ni hash; un `usuario_id` con `@` se redacta.
153
+
154
+ ## El contrato HTTP v1
155
+
156
+ Congelado: cambiarlo es versionar (`/v2`), no editar.
157
+
158
+ **Endpoint.** `POST https://qoyskdjcjyjxcnhruhni.supabase.co/functions/v1/monitor-ingesta`.
159
+ CORS abierto (`*`), `OPTIONS` → 204.
160
+
161
+ **Auth.** `Authorization: Bearer eiai_mon_<ambiente>_<32 hex>`. La clave es la única autoridad
162
+ sobre proyecto y ambiente: el cuerpo no los declara, y si los declara se ignoran. Nunca en la URL.
163
+
164
+ **Cuerpo.** Siempre un lote: `{ "eventos": [ … ] }`, **hasta 20 eventos** (los que sobran se
165
+ rechazan uno por uno con `lote_excedido`) y **hasta 64 KB** (si no, `413` y se rechaza entero).
166
+
167
+ | Campo | Tipo | Req | Tope | Qué es |
168
+ |---|---|---|---|---|
169
+ | `id_externo` | uuid | sí | — | Idempotencia: repetido cuenta como `duplicados`, no suma |
170
+ | `origen` | `browser` · `edge` · `servidor` · `db` · `ci` · `deploy` | sí | — | `latido` es reservado de finance |
171
+ | `mensaje` | string | sí | 1024 | Se recorta, no se rechaza |
172
+ | `ocurrido_at` | ISO-8601 | no | — | Default: cuándo llegó |
173
+ | `tipo_error` | string | no | 128 | `TypeError`, `PostgrestError`, `XX000`… |
174
+ | `codigo` | string | no | 64 | SQLSTATE, status HTTP, code de PostgREST |
175
+ | `stack` | string | no | 8192 | Crudo |
176
+ | `ubicacion` | string | no | 256 | Función edge, nombre del cron, consulta |
177
+ | `ruta` | string | no | 512 | `pathname`, sin query |
178
+ | `release` | string | no | 64 | Sha corto del build |
179
+ | `usuario_id` | string | no | 64 | Id opaco; con `@` se redacta |
180
+ | `rol` | string | no | 64 | |
181
+ | `dispositivo_id` | string | no | 64 | Uuid del dispositivo |
182
+ | `user_agent` | string | no | 512 | |
183
+ | `contexto` | objeto plano de escalares | no | 2 KB | Claves sensibles → `[redactado]` |
184
+ | `repeticiones` | entero ≥ 1 | no | — | Colapso local. Default 1 |
185
+ | `huella_manual` | string | no | 128 | Reemplaza la agrupación calculada (`cron:offers-expire`) |
186
+
187
+ Los topes son en caracteres. Un texto más largo se recorta con la marca `…[cortado]`.
188
+
189
+ **Respuestas.**
190
+
191
+ | Status | Cuerpo | Cuándo |
192
+ |---|---|---|
193
+ | `202` | `{ "recibidos": n, "duplicados": n, "rechazados": [{ "i": 0, "motivo": "…" }] }` | La clave vale. Motivos por evento: `sin_id_externo` · `id_externo_invalido` · `origen_invalido` · `sin_mensaje` · `lote_excedido` · `evento_invalido` |
194
+ | `400` | `{ "error": "cuerpo_invalido" }` | No es JSON o no tiene `eventos[]` |
195
+ | `401` | `{ "error": "clave_invalida" }` | Sin header, clave desconocida o revocada |
196
+ | `413` | `{ "error": "cuerpo_excedido" }` | Más de 64 KB |
197
+ | `429` | `{ "error": "cuota_excedida" }` + `Retry-After` | Cuota por clave y minuto de reloj agotada |
198
+ | `405` | — | Método que no es `POST` ni `OPTIONS` |
199
+
200
+ Cómo se agrupa (lo hace finance, no el emisor): mismo origen, tipo, mensaje (sin ids, hashes ni
201
+ números) y primer frame del stack (sin el hash del chunk de Vite ni línea y columna) son el mismo
202
+ problema. `huella_manual` reemplaza ese cálculo.
203
+
204
+ ### Con `curl`
205
+
206
+ ```bash
207
+ curl -X POST "https://qoyskdjcjyjxcnhruhni.supabase.co/functions/v1/monitor-ingesta" \
208
+ -H "Authorization: Bearer $MONITOR_CLAVE" \
209
+ -H "Content-Type: application/json" \
210
+ -d "{\"eventos\":[{\"id_externo\":\"$(uuidgen)\",\"origen\":\"ci\",\"mensaje\":\"falló el build\",\"ubicacion\":\"deploy.yml\"}]}"
211
+ ```
212
+
213
+ ### Desde un cron de la base (`net.http_post`)
214
+
215
+ El patrón es envolver cada job en una función que ataja el error y avisa, con la clave en el vault
216
+ del cliente:
217
+
218
+ ```sql
219
+ create or replace function app.run_job(p_fn text)
220
+ returns void
221
+ language plpgsql
222
+ security definer
223
+ set search_path = ''
224
+ as $$
225
+ declare
226
+ v_estado text;
227
+ v_mensaje text;
228
+ begin
229
+ execute format('select %s()', p_fn::regproc);
230
+ exception when others then
231
+ get stacked diagnostics v_estado = returned_sqlstate, v_mensaje = message_text;
232
+ -- PT4xx es un rechazo de negocio, no un incidente.
233
+ if v_estado not like 'PT4%' then
234
+ perform net.http_post(
235
+ url := 'https://qoyskdjcjyjxcnhruhni.supabase.co/functions/v1/monitor-ingesta',
236
+ headers := jsonb_build_object(
237
+ 'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'monitor_clave'),
238
+ 'Content-Type', 'application/json'
239
+ ),
240
+ body := jsonb_build_object('eventos', jsonb_build_array(jsonb_build_object(
241
+ 'id_externo', gen_random_uuid(),
242
+ 'origen', 'db',
243
+ 'mensaje', v_mensaje,
244
+ 'codigo', v_estado,
245
+ 'ubicacion', p_fn,
246
+ 'huella_manual', 'cron:' || p_fn
247
+ )))
248
+ );
249
+ end if;
250
+ -- Sin re-raise: `net.http_post` encola en una tabla, y si la transacción se revierte el
251
+ -- aviso se revierte con ella.
252
+ end;
253
+ $$;
254
+
255
+ -- select cron.schedule('offers-expire', '*/5 * * * *', $$select app.run_job('app.offers_expire')$$);
256
+ ```
257
+
258
+ ## Qué NO hace
259
+
260
+ - **Sin sourcemaps**: el stack llega minificado; se agrupa igual por frame normalizado.
261
+ - **Sin breadcrumbs, performance ni session replay.** Errores, nada más.
262
+ - **No reintenta los eventos que finance rechaza** en un `202` (`rechazados`): un evento inválido
263
+ no se arregla reenviándolo.
264
+ - **Varias pestañas comparten la cola guardada**: la última que guarda gana. Se puede perder algo
265
+ pendiente de una pestaña que se cierra dentro de los 2 s de otra; el resto ya salió.
266
+ - **El envío al irse es un intento**: si el navegador no deja salir el `keepalive`, sale en la
267
+ próxima carga del mismo dispositivo.
268
+
269
+ ## Desarrollo
270
+
271
+ ```bash
272
+ npm ci
273
+ npm run typecheck
274
+ npm test
275
+ npm run build
276
+ npm run tamano
277
+ ```
278
+
279
+ `tamano` falla si la entrada del navegador pasa de **4 KB gzip** (presupuesto del spec).
280
+ Publicar es crear un tag `vX.Y.Z` igual a la versión de `package.json`: el workflow `publish.yml`
281
+ publica en npm con provenance.
282
+
283
+ ## Licencia
284
+
285
+ [MIT](LICENSE)
@@ -0,0 +1,49 @@
1
+ import { V as ValorContexto } from './esperado-Cg2FvrBP.js';
2
+ export { e as esEsperado } from './esperado-Cg2FvrBP.js';
3
+
4
+ interface OpcionesMonitor {
5
+ /** `VITE_MONITOR_CLAVE`. Sin clave, el SDK no hace nada (desarrollo local, tests). */
6
+ clave: string | undefined;
7
+ /** Por defecto, la ingesta de finance. */
8
+ url?: string;
9
+ /** El sha corto del build. */
10
+ release?: string;
11
+ /** Mensajes que no se reportan: texto contenido en el mensaje, o una regex. */
12
+ ignorar?: ReadonlyArray<string | RegExp>;
13
+ /** Tope de la cola del dispositivo. Default 50. */
14
+ maxBuffer?: number;
15
+ /** Tope de eventos nuevos por minuto. Default 20. */
16
+ maxPorMinuto?: number;
17
+ /** Loguea a `console.debug` lo que hace. Para integrar, nunca en producción. */
18
+ debug?: boolean;
19
+ }
20
+ interface Extra {
21
+ ruta?: string;
22
+ ubicacion?: string;
23
+ contexto?: Record<string, ValorContexto>;
24
+ }
25
+ interface Usuario {
26
+ /** Un id opaco (uuid), nunca un email. */
27
+ id: string;
28
+ rol?: string;
29
+ }
30
+ declare function initMonitor(o: OpcionesMonitor): void;
31
+ declare function capturar(error: unknown, extra?: Extra): void;
32
+ /** Suma claves al contexto de los próximos eventos. Una clave en `undefined` se saca. */
33
+ declare function setContext(c: Record<string, ValorContexto | undefined>): void;
34
+ declare function setUsuario(u: Usuario): void;
35
+ declare function limpiarUsuario(): void;
36
+ /** Procesa lo capturado y manda todo lo que haya, ahora. Nunca rechaza. */
37
+ declare function flush(): Promise<void>;
38
+ /** Para `new QueryCache({ onError: onErrorQuery })`. Aditivo: no reemplaza los `onError` de cada query. */
39
+ declare function onErrorQuery(error: unknown, query?: {
40
+ queryKey?: readonly unknown[];
41
+ }): void;
42
+ /** Para `new MutationCache({ onError: onErrorMutation })`. */
43
+ declare function onErrorMutation(error: unknown, _variables?: unknown, _contexto?: unknown, mutation?: {
44
+ options?: {
45
+ mutationKey?: readonly unknown[];
46
+ };
47
+ }): void;
48
+
49
+ export { type Extra, type OpcionesMonitor, type Usuario, ValorContexto, capturar, flush, initMonitor, limpiarUsuario, onErrorMutation, onErrorQuery, setContext, setUsuario };
@@ -0,0 +1,2 @@
1
+ function ue(e){let t=2166136261;for(let n=0;n<e.length;n++)t^=e.charCodeAt(n),t=Math.imul(t,16777619);return t>>>0}function N(e){let t=(e.stack??"").split(`
2
+ `).find(n=>/^\s*at\s|@/.test(n))??"";return ue([e.origen,e.tipo_error??"",e.mensaje,t.trim()].join("|"))}function I(){return{items:[],minuto:0,enMinuto:0}}function L(e,t,n,r,i){let o=N(t),l=e.items.find(v=>!v.v&&v.h===o&&n-v.t<1e4);if(l)return l.e.repeticiones=(l.e.repeticiones??1)+1,"colapsado";let P=Math.floor(n/6e4);return P!==e.minuto&&(e.minuto=P,e.enMinuto=0),e.enMinuto>=i?"tope_por_minuto":(e.enMinuto++,e.items.push({e:t,h:o,t:n}),e.items.length>r&&e.items.splice(0,e.items.length-r),"encolado")}var le=128*1024;function V(e){let t=e.map(({e:i,h:o,t:l})=>JSON.stringify({e:i,h:o,t:l})),n=2,r=t.length;for(;r>0&&(n+t[r-1].length+1)*2<=le;)n+=t[--r].length+1;return`[${t.slice(r).join(",")}]`}function U(e){try{let t=e?JSON.parse(e):[];return Array.isArray(t)?t.filter(n=>!!n&&typeof n=="object"&&!!n.e&&typeof n.e.id_externo=="string"&&typeof n.e.mensaje=="string"&&typeof n.h=="number"&&typeof n.t=="number"):[]}catch{return[]}}var z="https://qoyskdjcjyjxcnhruhni.supabase.co/functions/v1/monitor-ingesta";var s={mensaje:1024,tipo_error:128,codigo:64,stack:8192,ubicacion:256,ruta:512,release:64,usuario_id:64,rol:64,dispositivo_id:64,user_agent:512,huella_manual:128},D="\u2026[cortado]";function g(e,t){return e.length<=t?e:e.slice(0,t-D.length)+D}var k=[1e3,2e3,4e3,3e4],X=e=>new TextEncoder().encode(e).length;function B(e,t=20){let n=[],r=X('{"eventos":[]}');for(let i of e){if(n.length>=t)break;let o=X(JSON.stringify(i.e))+(n.length?1:0);if(n.length&&r+o>61440)break;r+=o,n.push(i)}return n}function de(e){return 6e4-e%6e4}function fe(e,t,n){if(e>=200&&e<300)return{r:"ok"};if(e===429){let r=Number(t);return{r:"cuota",esperaMs:t&&r>0?r*1e3:de(n)}}return e===401?{r:"apagar"}:e===413?{r:"partir"}:e>=500?{r:"reintentar"}:{r:"descartar"}}async function $(e){let t;try{let n=typeof AbortController=="function"?new AbortController:void 0,r=new Promise(o=>{t=setTimeout(()=>{e.debug?.("timeout"),n?.abort(),o({r:"reintentar"})},e.timeoutMs)}),i=(async()=>{try{let o=await fetch(e.url,{method:"POST",headers:{authorization:`Bearer ${e.clave}`,"content-type":"application/json"},body:JSON.stringify({eventos:e.eventos}),keepalive:!!e.keepalive,signal:n?.signal}),l=await o.text();return e.debug?.(o.status,l),fe(o.status,o.headers.get("retry-after"),Date.now())}catch(o){return e.debug?.("sin respuesta",o),{r:"reintentar"}}})();return await Promise.race([i,r])}catch{return{r:"reintentar"}}finally{clearTimeout(t)}}function pe(e){return e.status??e.statusCode??e.context?.status}function J(e){try{if(!e||typeof e!="object")return!1;let t=e;if(t.name==="AbortError"||typeof t.code=="string"&&t.code.startsWith("PT4"))return!0;let n=Number(pe(t));return n===401||n===403?!0:typeof t.name=="string"&&t.name.startsWith("Auth")&&n>=400&&n<500}catch{return!1}}function q(e,t){return t.some(n=>typeof n=="string"?e.includes(n):e.search(n)>=0)}function m(){let e=typeof crypto>"u"?void 0:crypto;if(e&&typeof e.randomUUID=="function")try{return e.randomUUID()}catch{}let t=new Uint8Array(16);if(e&&typeof e.getRandomValues=="function")e.getRandomValues(t);else for(let r=0;r<16;r++)t[r]=Math.random()*256|0;t[6]=t[6]&15|64,t[8]=t[8]&63|128;let n=Array.from(t,r=>r.toString(16).padStart(2,"0")).join("");return`${n.slice(0,8)}-${n.slice(8,12)}-${n.slice(12,16)}-${n.slice(16,20)}-${n.slice(20)}`}var x="[redactado]",ge=/mail|tel|phone|dni|cuit|pass|token|secret|key|clave/i,me=/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,xe=/(^|[^\w.-])\+?\d(?:[\s-]?\d){7,14}(?![\w.-])/g;function R(e){return e.replace(me,x).replace(xe,`$1${x}`)}function K(e){return e===void 0?e:R(e)}function Y(e){let t;if(e.contexto){t={};for(let n of Object.keys(e.contexto)){let r=e.contexto[n];t[n]=ge.test(n)?x:typeof r=="string"?R(r):r}}return{...e,mensaje:R(e.mensaje),stack:K(e.stack),ruta:K(e.ruta),usuario_id:e.usuario_id&&e.usuario_id.includes("@")?x:e.usuario_id,contexto:t}}function u(e,t){if((typeof e=="number"||typeof e=="boolean")&&(e=String(e)),typeof e!="string")return;let n=e.trim();return n?g(n,t):void 0}function be(e){let t=Object.prototype.toString.call(e);if(t==="[object Object]"||t==="[object Array]")try{let n=JSON.stringify(e);if(n&&n!=="{}")return n}catch{}return t}function Ee(e){try{if(e===null||typeof e!="object"&&typeof e!="function")return{mensaje:u(String(e),s.mensaje)??"Error sin mensaje"};let t=e,n=t.context,r=typeof t.code=="string"&&("details"in t||"hint"in t);return{mensaje:u(t.message,s.mensaje)??g(be(t),s.mensaje),tipo_error:u(t.name,s.tipo_error)??(r?"PostgrestError":void 0),codigo:u(t.code??t.status??t.statusCode??n?.status,s.codigo),stack:u(t.stack,s.stack)}}catch{return{mensaje:"Error no legible"}}}function _e(e){if(!e||typeof e!="object")return;let t={};for(let r of Object.keys(e)){let i=e[r];i===null||typeof i=="boolean"?t[r]=i:typeof i=="number"?t[r]=Number.isFinite(i)?i:null:typeof i=="string"&&(t[r]=g(i,256))}let n=Object.keys(t);if(n.length){for(;n.length&&new TextEncoder().encode(JSON.stringify(t)).length>2048;)delete t[n.pop()],t._cortado=!0;return t}}function he(e){return u(e?.split(/[?#]/)[0],s.ruta)}function F(e){return Y({id_externo:m(),origen:e.origen,...Ee(e.error),ocurrido_at:new Date(e.ocurrido).toISOString(),ubicacion:u(e.ubicacion,s.ubicacion),ruta:he(e.ruta),release:u(e.release,s.release),usuario_id:u(e.usuario_id,s.usuario_id),rol:u(e.rol,s.rol),dispositivo_id:u(e.dispositivo_id,s.dispositivo_id),user_agent:u(e.user_agent,s.user_agent),contexto:_e(e.contexto),repeticiones:1})}var ee="eiai_monitor_cola",Z="eiai_monitor_dispositivo",te=2e3,ne=10,ve=3e3,Oe=100,a,b=[],c,f,T={},M,y=!1,w=!1,d,G=0,W,E=!1,H=0,C=0,S=20;function p(...e){try{a?.debug&&console.debug("[monitor]",...e)}catch{}}function Q(e){try{return localStorage.getItem(e)}catch{return null}}function re(e,t){try{localStorage.setItem(e,t)}catch{}}function Fe(e){try{if(a)return;if(typeof window>"u"||!e?.clave){e?.debug&&console.debug("[monitor] no-op: sin clave");return}a={clave:e.clave,url:e.url??z,release:e.release,ignorar:e.ignorar??[],maxBuffer:e.maxBuffer??50,maxPorMinuto:e.maxPorMinuto??20,debug:!!e.debug},addEventListener("error",ke),addEventListener("unhandledrejection",Re),addEventListener("pagehide",ie),document.addEventListener("visibilitychange",Se),ae(),p("iniciado",a.url)}catch{}}function h(e,t){try{if(!a||E||y||b.length>=Oe)return;b.push({error:e,extra:t,t:Date.now(),ruta:location.pathname,contexto:T,usuario:M}),ae()}catch{}}function Ze(e){try{T={...T,...e}}catch{}}function Ge(e){M=e}function We(){M=void 0}async function He(){try{if(!a)return;for(_();;){let e=await j();if(!e||e.r!=="ok"&&e.r!=="descartar"&&e.r!=="partir")return}}catch{}}function Qe(e,t){try{h(e,{ubicacion:oe(t?.queryKey)})}catch{}}function et(e,t,n,r){try{h(e,{ubicacion:oe(r?.options?.mutationKey)})}catch{}}function oe(e){return Array.isArray(e)&&typeof e[0]=="string"?e[0]:void 0}function ke(e){try{h(e.error??{message:e.message,stack:e.filename?`at ${e.filename}:${e.lineno}:${e.colno}`:void 0})}catch{}}function Re(e){try{h(e.reason)}catch{}}function ie(){try{_(),clearTimeout(d),d=void 0,j(!0)}catch{}}function Se(){document.visibilityState==="hidden"&&ie()}function ae(){w||(w=!0,typeof requestIdleCallback=="function"?requestIdleCallback(_,{timeout:1e3}):setTimeout(_,0))}function Te(){return c||(c=I(),c.items=U(Q(ee)),f=Q(Z)??void 0,f||(f=m(),re(Z,f)),c.items.length&&p("recuperados",c.items.length),c)}function se(){c&&re(ee,V(c.items))}function _(){if(w=!1,!(!a||y)){y=!0;try{let e=Te(),t=b;b=[];let n=0;for(let r of t)try{if(J(r.error))continue;let i=F({error:r.error,origen:"browser",ocurrido:r.t,ruta:r.extra?.ruta??r.ruta,ubicacion:r.extra?.ubicacion,release:a.release,usuario_id:r.usuario?.id,rol:r.usuario?.rol,dispositivo_id:f,user_agent:navigator.userAgent,contexto:{...r.contexto,...r.extra?.contexto}});if(q(i.mensaje,a.ignorar))continue;let o=L(e,i,r.t,a.maxBuffer,a.maxPorMinuto);p(o,i.mensaje),o!=="tope_por_minuto"&&n++}catch{}n&&se(),e.items.length&&A(e.items.length>=ne?0:te)}catch{}finally{y=!1}}}function A(e){if(E)return;let t=Math.max(Date.now()+e,C);if(d!==void 0){if(t>=G)return;clearTimeout(d)}G=t,d=setTimeout(()=>{d=void 0,j()},t-Date.now())}function j(e=!1){return W??(W=we(e).catch(()=>{}).finally(()=>{W=void 0}))}async function we(e){try{if(!a||!c||E)return;let t=c;if(Date.now()<C){A(0);return}let n=B(t.items,S);if(!n.length)return;for(let o of n)o.v=!0;let r=await $({url:a.url,clave:a.clave,eventos:n.map(o=>o.e),timeoutMs:ve,keepalive:e,debug:a.debug?p:void 0});for(let o of n)o.v=!1;let i=()=>{t.items=t.items.filter(o=>!n.includes(o))};return r.r==="ok"||r.r==="descartar"||r.r==="partir"&&n.length===1?(i(),r.r==="ok"&&(H=0),S=20):r.r==="partir"?S=n.length>>1:r.r==="apagar"?(E=!0,clearTimeout(d),p("clave rechazada: apagado")):C=Date.now()+(r.r==="cuota"?r.esperaMs:k[Math.min(H++,k.length-1)]),se(),t.items.length&&A(t.items.length>=ne||r.r!=="ok"&&r.r!=="descartar"?0:te),r}catch{return}}export{h as capturar,J as esEsperado,He as flush,Fe as initMonitor,We as limpiarUsuario,et as onErrorMutation,Qe as onErrorQuery,Ze as setContext,Ge as setUsuario};
package/dist/deno.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { V as ValorContexto } from './esperado-Cg2FvrBP.js';
2
+ export { e as esEsperado } from './esperado-Cg2FvrBP.js';
3
+
4
+ interface OpcionesServidor {
5
+ /** El secreto `MONITOR_CLAVE`. Sin clave, no hace nada. */
6
+ clave: string | undefined;
7
+ /** Por defecto, la ingesta de finance. */
8
+ url?: string;
9
+ release?: string;
10
+ /** El nombre de la función: va como `ubicacion` de cada evento. */
11
+ funcion: string;
12
+ /** `edge` (default) o `servidor` (Node, Railway). */
13
+ origen?: 'edge' | 'servidor';
14
+ }
15
+ interface Extra {
16
+ ruta?: string;
17
+ ubicacion?: string;
18
+ contexto?: Record<string, ValorContexto>;
19
+ }
20
+ interface Monitor {
21
+ readonly funcion: string;
22
+ capturar(error: unknown, extra?: Extra): void;
23
+ /** Manda lo capturado: 2 s por intento y un reintento. Nunca rechaza. */
24
+ flush(): Promise<void>;
25
+ }
26
+ declare function initMonitor(o: OpcionesServidor): Monitor;
27
+ interface OpcionesHandler {
28
+ /** Además de los esperados de siempre (PT4xx, AbortError, 401/403). */
29
+ esEsperado?: (error: unknown) => boolean;
30
+ /** La respuesta cuando la función lanza (con los headers de CORS de la función). Default: 500 JSON. */
31
+ aRespuesta?: (error: unknown) => Response;
32
+ }
33
+ /**
34
+ * Envuelve el handler de `Deno.serve`. Si responde, devuelve esa respuesta tal cual y reporta
35
+ * las 5xx (sin el cuerpo: puede traer datos). Si lanza, lo reporta y devuelve `aRespuesta(error)`
36
+ * o un 500 JSON. Al final manda todo lo capturado en el request, también lo que la función
37
+ * capturó a mano antes de contestar un 200.
38
+ */
39
+ declare function withHandler<A extends unknown[]>(monitor: Monitor, handler: (req: Request, ...args: A) => Response | Promise<Response>, opciones?: OpcionesHandler): (req: Request, ...args: A) => Promise<Response>;
40
+
41
+ export { type Extra, type Monitor, type OpcionesHandler, type OpcionesServidor, ValorContexto, initMonitor, withHandler };
package/dist/deno.js ADDED
@@ -0,0 +1 @@
1
+ var E="https://qoyskdjcjyjxcnhruhni.supabase.co/functions/v1/monitor-ingesta";var s={mensaje:1024,tipo_error:128,codigo:64,stack:8192,ubicacion:256,ruta:512,release:64,usuario_id:64,rol:64,dispositivo_id:64,user_agent:512,huella_manual:128},m="\u2026[cortado]";function l(e,t){return e.length<=t?e:e.slice(0,t-m.length)+m}var x=e=>new TextEncoder().encode(e).length;function _(e,t=20){let n=[],r=x('{"eventos":[]}');for(let i of e){if(n.length>=t)break;let o=x(JSON.stringify(i.e))+(n.length?1:0);if(n.length&&r+o>61440)break;r+=o,n.push(i)}return n}function T(e){return 6e4-e%6e4}function S(e,t,n){if(e>=200&&e<300)return{r:"ok"};if(e===429){let r=Number(t);return{r:"cuota",esperaMs:t&&r>0?r*1e3:T(n)}}return e===401?{r:"apagar"}:e===413?{r:"partir"}:e>=500?{r:"reintentar"}:{r:"descartar"}}async function b(e){let t;try{let n=typeof AbortController=="function"?new AbortController:void 0,r=new Promise(o=>{t=setTimeout(()=>{e.debug?.("timeout"),n?.abort(),o({r:"reintentar"})},e.timeoutMs)}),i=(async()=>{try{let o=await fetch(e.url,{method:"POST",headers:{authorization:`Bearer ${e.clave}`,"content-type":"application/json"},body:JSON.stringify({eventos:e.eventos}),keepalive:!!e.keepalive,signal:n?.signal}),a=await o.text();return e.debug?.(o.status,a),S(o.status,o.headers.get("retry-after"),Date.now())}catch(o){return e.debug?.("sin respuesta",o),{r:"reintentar"}}})();return await Promise.race([i,r])}catch{return{r:"reintentar"}}finally{clearTimeout(t)}}function j(e){return e.status??e.statusCode??e.context?.status}function h(e){try{if(!e||typeof e!="object")return!1;let t=e;if(t.name==="AbortError"||typeof t.code=="string"&&t.code.startsWith("PT4"))return!0;let n=Number(j(t));return n===401||n===403?!0:typeof t.name=="string"&&t.name.startsWith("Auth")&&n>=400&&n<500}catch{return!1}}function R(){let e=typeof crypto>"u"?void 0:crypto;if(e&&typeof e.randomUUID=="function")try{return e.randomUUID()}catch{}let t=new Uint8Array(16);if(e&&typeof e.getRandomValues=="function")e.getRandomValues(t);else for(let r=0;r<16;r++)t[r]=Math.random()*256|0;t[6]=t[6]&15|64,t[8]=t[8]&63|128;let n=Array.from(t,r=>r.toString(16).padStart(2,"0")).join("");return`${n.slice(0,8)}-${n.slice(8,12)}-${n.slice(12,16)}-${n.slice(16,20)}-${n.slice(20)}`}var p="[redactado]",A=/mail|tel|phone|dni|cuit|pass|token|secret|key|clave/i,M=/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,N=/(^|[^\w.-])\+?\d(?:[\s-]?\d){7,14}(?![\w.-])/g;function g(e){return e.replace(M,p).replace(N,`$1${p}`)}function O(e){return e===void 0?e:g(e)}function v(e){let t;if(e.contexto){t={};for(let n of Object.keys(e.contexto)){let r=e.contexto[n];t[n]=A.test(n)?p:typeof r=="string"?g(r):r}}return{...e,mensaje:g(e.mensaje),stack:O(e.stack),ruta:O(e.ruta),usuario_id:e.usuario_id&&e.usuario_id.includes("@")?p:e.usuario_id,contexto:t}}function u(e,t){if((typeof e=="number"||typeof e=="boolean")&&(e=String(e)),typeof e!="string")return;let n=e.trim();return n?l(n,t):void 0}function C(e){let t=Object.prototype.toString.call(e);if(t==="[object Object]"||t==="[object Array]")try{let n=JSON.stringify(e);if(n&&n!=="{}")return n}catch{}return t}function V(e){try{if(e===null||typeof e!="object"&&typeof e!="function")return{mensaje:u(String(e),s.mensaje)??"Error sin mensaje"};let t=e,n=t.context,r=typeof t.code=="string"&&("details"in t||"hint"in t);return{mensaje:u(t.message,s.mensaje)??l(C(t),s.mensaje),tipo_error:u(t.name,s.tipo_error)??(r?"PostgrestError":void 0),codigo:u(t.code??t.status??t.statusCode??n?.status,s.codigo),stack:u(t.stack,s.stack)}}catch{return{mensaje:"Error no legible"}}}function L(e){if(!e||typeof e!="object")return;let t={};for(let r of Object.keys(e)){let i=e[r];i===null||typeof i=="boolean"?t[r]=i:typeof i=="number"?t[r]=Number.isFinite(i)?i:null:typeof i=="string"&&(t[r]=l(i,256))}let n=Object.keys(t);if(n.length){for(;n.length&&new TextEncoder().encode(JSON.stringify(t)).length>2048;)delete t[n.pop()],t._cortado=!0;return t}}function I(e){return u(e?.split(/[?#]/)[0],s.ruta)}function w(e){return v({id_externo:R(),origen:e.origen,...V(e.error),ocurrido_at:new Date(e.ocurrido).toISOString(),ubicacion:u(e.ubicacion,s.ubicacion),ruta:I(e.ruta),release:u(e.release,s.release),usuario_id:u(e.usuario_id,s.usuario_id),rol:u(e.rol,s.rol),dispositivo_id:u(e.dispositivo_id,s.dispositivo_id),user_agent:u(e.user_agent,s.user_agent),contexto:L(e.contexto),repeticiones:1})}var X=2e3,U=50;function D(e){return new Promise(t=>setTimeout(t,e))}function re(e){let t=[],n=!e.clave,r,i=a=>b({url:e.url??E,clave:e.clave??"",eventos:a,timeoutMs:X});async function o(){let a=20;for(;t.length&&!n;){let c=_(t.map(f=>({e:f})),a).map(f=>f.e),d=await i(c);if((d.r==="reintentar"||d.r==="cuota")&&(await D(d.r==="cuota"?Math.min(d.esperaMs,1e4):1e3),d=await i(c)),d.r==="apagar"){n=!0,t.length=0;return}if(d.r==="partir"&&c.length>1){a=c.length>>1;continue}t.splice(0,c.length)}}return{funcion:e.funcion,capturar(a,c){try{if(n||h(a))return;t.length>=U&&t.shift(),t.push(w({error:a,origen:e.origen??"edge",ocurrido:Date.now(),ruta:c?.ruta,ubicacion:c?.ubicacion??e.funcion,release:e.release,contexto:c?.contexto}))}catch{}},flush(){return r??(r=o().catch(()=>{}).finally(()=>{r=void 0}))}}}function $(e){let t=e.flush();try{globalThis.EdgeRuntime?.waitUntil(t)}catch{}}function k(e){try{return new URL(e.url).pathname}catch{return}}function z(e,t){try{if(t.aRespuesta)return t.aRespuesta(e)}catch{}return new Response(JSON.stringify({error:"error_interno"}),{status:500,headers:{"content-type":"application/json"}})}function oe(e,t,n={}){return async(r,...i)=>{try{let o=await t(r,...i);return o.status>=500&&e.capturar({name:"HttpError",message:`${e.funcion}: respondi\xF3 ${o.status}`,status:o.status},{ruta:k(r)}),o}catch(o){let a=!1;try{a=!!n.esEsperado?.(o)}catch{}return a||e.capturar(o,{ruta:k(r)}),z(o,n)}finally{$(e)}}}export{h as esEsperado,re as initMonitor,oe as withHandler};
@@ -0,0 +1,11 @@
1
+ type ValorContexto = string | number | boolean | null;
2
+
3
+ /**
4
+ * - `PT4xx`: el `RAISE` de negocio de las funciones de la base, que PostgREST devuelve como 4xx.
5
+ * - `AbortError`: una petición cancelada a propósito (el usuario se fue, un timeout del cliente).
6
+ * - 401/403: sesión vencida o sin permiso. Y los `Auth*` de Supabase con status < 500
7
+ * (contraseña mal escrita, mail sin confirmar): son del usuario, no del sistema.
8
+ */
9
+ declare function esEsperado(error: unknown): boolean;
10
+
11
+ export { type ValorContexto as V, esEsperado as e };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@eiaifactory/monitor",
3
+ "version": "0.1.0",
4
+ "description": "Manda los errores de un sistema de cliente al Monitor de EI AI Factory, sin que el usuario final lo note.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/eiaifactory/monitor.git"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/browser.d.ts",
14
+ "default": "./dist/browser.js"
15
+ },
16
+ "./deno": {
17
+ "types": "./dist/deno.d.ts",
18
+ "default": "./dist/deno.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "sideEffects": false,
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "build": "tsup",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "tamano": "node scripts/tamano.mjs"
33
+ },
34
+ "devDependencies": {
35
+ "happy-dom": "^20.14.5",
36
+ "tsup": "^8.5.1",
37
+ "typescript": "^5.9.3",
38
+ "vitest": "^4.1.11"
39
+ }
40
+ }