# Capturar los errores de tu aplicación

Integra la captura de errores con los SDK en dos líneas, sin que el envío pueda romper tu aplicación, y resuelve o silencia lo que llega a UTILIA OS.

Con dos líneas, tu aplicación envía a UTILIA OS los errores que nadie trata y los que tú atrapas. Llegan agrupados a **Actividad › Errores**, en la ficha de tu aplicación, donde tu equipo los resuelve, los silencia o los convierte en un ticket, y tu servidor recibe un aviso por cada cambio. La captura nunca lanza: si UTILIA OS no responde o falta un permiso, tu aplicación sigue exactamente igual.

**De un fallo a tu equipo**

1. **Capturar** (install y capture): El SDK saca el mensaje, la pila y dónde nació el fallo.
2. **Poner en cola** (Tu aplicación): Junta los repetidos y envía en lotes de hasta 25.
3. **Agrupar** (UTILIA OS): Suma la ocurrencia al grupo del mismo fallo y limpia el contexto.
4. [**Revisar**](https://os.utilia.ai/docs/developers/api/errors-tab) (Pestaña Errores): Tu equipo lo resuelve, lo silencia o abre un ticket.

No lo confundas con [`UtiliaSDKError`](https://os.utilia.ai/docs/developers/sdk/error-handling), el error que lanza el SDK cuando falla una llamada a la API.

## Antes de empezar

La captura llega con la versión **4.6.0** de los dos SDK ([Versiones y compatibilidad](https://os.utilia.ai/docs/developers/sdk/versions)). Tu aplicación externa necesita el permiso de cada cosa que hagas:

| Para | Permiso de la aplicación |
| - | - |
| Enviar errores: `install`, `capture`, `report` y `reportBatch` | «Reportar errores al backend» (`analytics:errors:write`) |
| Listar, contar y ver el detalle | «Leer errores reportados» (`analytics:errors:read`) |
| Resolver, reabrir y silenciar | «Gestionar errores reportados» (`analytics:errors:manage`) |

Los permisos se conceden en la ficha de la aplicación: lo explica [Permisos de la API](https://os.utilia.ai/docs/developers/api/scopes).

> **Atención: En el navegador, la clave queda a la vista**
>
> Cualquiera puede leer la clave en el código de tu página. Para capturar errores en el navegador, usa una aplicación externa dedicada solo a eso, con «Reportar errores al backend» y ningún otro permiso, y declara tus dominios en sus **Dominios autorizados** ([CORS](https://os.utilia.ai/docs/developers/getting-started/rate-limits-and-cors#cors-desde-qué-webs-se-puede-llamar)). Nunca uses en el navegador la clave de una aplicación que factura o cobra.

## Integración en dos líneas

La primera línea va al arrancar tu aplicación; la segunda, donde ya atrapas un error. El cliente se crea como siempre ([Configurar el cliente](https://os.utilia.ai/docs/developers/sdk/configuration)).

### Navegador

```typescript
import { UtiliaSDK } from '@utilia-os/sdk-js';

const sdk = new UtiliaSDK({
  baseURL: 'https://os.utilia.ai/api',
  apiKey: 'utl_...', // aplicación con solo analytics:errors:write
});

// 1. Al arrancar: captura sola lo que nadie trata
sdk.errors.install({ release: '2.4.1', environment: 'production' });

// 2. Donde ya atrapas un error
try {
  await guardarPedido(pedido);
} catch (error) {
  sdk.errors.capture(error, { module: 'pedidos', context: { pedidoId: pedido.id } });
  mostrarAviso('No se pudo guardar el pedido');
}
```

### Node.js

```typescript
import { UtiliaSDK } from '@utilia-os/sdk-js';

const sdk = new UtiliaSDK({
  baseURL: 'https://os.utilia.ai/api',
  apiKey: process.env.UTILIA_API_KEY!,
});

// 1. Al arrancar
sdk.errors.install({ release: process.env.APP_VERSION, environment: 'production', module: 'api' });

// 2. Donde ya atrapas un error
app.post('/pagos', async (req, res) => {
  try {
    res.json(await cobrar(req.body));
  } catch (error) {
    sdk.errors.capture(error, { module: 'pagos', severity: 'critical', requestId: req.id });
    res.status(500).json({ message: 'No se pudo cobrar' });
  }
});
```

### Python

```python
import os

from utilia_sdk import UtiliaSDKSync

sdk = UtiliaSDKSync(base_url="https://os.utilia.ai/api", api_key=os.environ["UTILIA_API_KEY"])

# 1. Al arrancar
sdk.errors.install(release="2.4.1", environment="production")

# 2. Donde ya atrapas un error
try:
    cobrar(pedido)
except Exception:
    sdk.errors.capture_exception(module="pagos", context={"pedido_id": pedido.id})
    raise
```

### Python asíncrono

```python
import os
from contextlib import asynccontextmanager

from fastapi import FastAPI
from utilia_sdk import UtiliaSDK

sdk = UtiliaSDK(base_url="https://os.utilia.ai/api", api_key=os.environ["UTILIA_API_KEY"])


@asynccontextmanager
async def lifespan(app: FastAPI):
    # 1. Al arrancar, con el bucle de asyncio ya en marcha
    sdk.errors.install(release="2.4.1", environment="production")
    yield
    await sdk.errors.flush(2.0)


app = FastAPI(lifespan=lifespan)


@app.post("/pagos")
async def pagar(pedido: Pedido):
    try:
        return await cobrar(pedido)
    except Exception:
        # 2. Donde ya atrapas un error
        sdk.errors.capture_exception(module="pagos", severity="critical")
        raise
```

Lo que conviene saber de estas dos líneas:

- **`capture` no lanza nunca ni hace falta esperarlo.** `capture` y `captureMessage` (en Python, `capture_exception` y `capture_message`) devuelven lo que contestó el servidor, o `null` (`None`) si el error se descartó o no se pudo enviar. En Python devuelven un `concurrent.futures.Future`; en código asíncrono, si quieres esperarlo, `await asyncio.wrap_future(futuro)`.
- **Saca solo los datos del error.** El mensaje, con el tipo delante (`TypeError: …`, `ValueError: …`), la pila y el archivo, la línea y la función del primer marco que es de tu código. En JavaScript entiende las pilas de Chrome, Node, Firefox y Safari y prefiere tu código al de `node_modules`. En Python, `capture_exception()` sin argumentos toma la excepción que se está tratando, así que va dentro de un `except`.
- **`captureMessage` (`capture_message`) envía un aviso sin pila**, para algo que no es una excepción pero quieres registrar.
- **Cada `capture` admite** `module`, `severity`, `context`, `tags` (se suman a las de `install`), `fingerprint`, `userId`, `requestId`, `endpoint` y `method`. `userId` es el identificador de la persona en **tu** sistema, no en UTILIA OS, y se guarda en `context.externalUserRef`.
- **`install` tampoco lanza.** Llamarlo otra vez sustituye la configuración, y devuelve una función que la deshace: quita los ganchos y vuelve a los valores por defecto, aunque lo que ya está en la cola se sigue enviando.

## Qué se captura solo

Con `install`, el SDK escucha lo que tu aplicación no trata:

| Dónde | Qué escucha | Gravedad con la que llega |
| - | - | - |
| Navegador | Los eventos `error` y `unhandledrejection` de la ventana | `high` |
| Node.js | `uncaughtExceptionMonitor`: excepciones sin tratar y, con el modo por defecto de Node, promesas rechazadas sin tratar | `critical` |
| Python | `sys.excepthook` | `critical` |
| Python | `threading.excepthook` y el manejador de excepciones de `asyncio` | `high` |

Los que capturas tú llevan la gravedad que indiques; si no indicas ninguna, se registran como `low`.

- **No cambia cómo se comporta tu aplicación.** En el navegador la consola sigue mostrando el error; en Node, el proceso termina igual que sin el SDK; en Python, el gancho anterior sigue funcionando y el traceback se imprime igual. `KeyboardInterrupt` y `SystemExit` no se capturan.
- **En el navegador**, el contexto lleva la dirección de la página sin la consulta ni el ancla (`context.url`), y se ignoran los errores de scripts de otro dominio que llegan sin detalle («Script error.»).
- **En JavaScript**, los capturados solos llevan `context.mechanism`: `window.onerror`, `unhandledrejection`, `uncaughtException` o `unhandledRejection`.
- **En Python asíncrono**, llama a `install` con el bucle de `asyncio` en marcha para que enganche su manejador de excepciones.
- **Tu framework puede llegar antes.** Si Express, Django o FastAPI convierten el fallo en una respuesta 500, el error ya está tratado y no llega a estos ganchos: llama a `capture` en el manejador de errores del framework.

Para no capturar nada solo y enviar únicamente lo que atrapas, pon `captureUnhandled: false` (`capture_unhandled=False`).

### Dos matices de Node.js

**Las promesas rechazadas.** El SDK no añade un manejador `unhandledRejection` propio: con solo existir, Node deja de terminar el proceso ante una promesa rechazada sin tratar, y el SDK cambiaría el comportamiento de tu aplicación. Por eso depende de cómo arranques Node:

**¿Llegan solas las promesas rechazadas?**

¿Tu aplicación ya escuchaba unhandledRejection cuando llamaste a install?

- Sí: **Llegan solas**. El SDK añade su manejador junto al tuyo. No cambia nada: el tuyo sigue decidiendo qué pasa.
- No: ¿Arrancas Node con --unhandled-rejections=warn o none?
  - No: **Llegan solas**. Con el modo por defecto, Node las convierte en excepciones y las recoge uncaughtExceptionMonitor.
  - Sí: **No llegan solas**. Llama a capture donde trates esas promesas.

_Desde la versión 15, Node convierte por defecto una promesa rechazada sin tratar en una excepción._

**La excepción que tumba el proceso.** Cuando una excepción sin tratar va a terminar el proceso, Node lo cierra en el acto, sin dar tiempo a enviar nada: el error se queda en la cola y se pierde. Si quieres recibir también esos, añade tu propio manejador, que espere a la cola y salga:

```javascript
process.on('uncaughtException', async (error) => {
  console.error(error);
  await sdk.errors.flush(2000);
  process.exit(1);
});
```

Con este manejador, la captura la sigue haciendo `install`; el tuyo solo da tiempo a enviarla. En Python no hace falta: `install` deja registrado un vaciado al salir del programa, de hasta 2 segundos, así que el error que lo tumba sí llega.

## Sustituir el patrón anterior

> **Atención: No envíes errores desde unhandledRejection**
>
> El patrón `process.on('unhandledRejection', () => void sdk.errors.report(...))` provoca un bucle: si el envío falla, su promesa rechazada vuelve a disparar el manejador, que vuelve a enviar, y el cupo se agota en segundos. Además, con ese manejador puesto, Node deja de terminar el proceso ante una promesa rechazada. Quítalo y llama a `install`.

```typescript
// Antes: bucle si el envío falla
process.on('unhandledRejection', (reason) => {
  void sdk.errors.report({ message: String(reason), module: 'servidor' });
});

// Ahora
sdk.errors.install({ module: 'servidor' });
```

En Python, lo mismo con un manejador de `asyncio` que lanzaba `sdk.errors.report` en una tarea: quítalo y llama a `install` con el bucle en marcha.

## Ajustar lo que se envía

Opciones de `install`, todas opcionales:

| JavaScript | Python | Por defecto | Qué hace |
| - | - | - | - |
| `release` | `release` | Ninguna | Versión de tu aplicación, hasta 100 caracteres. |
| `environment` | `environment` | Ninguno | Entorno (`production`, `staging`…), hasta 40 caracteres. Se guarda en minúsculas. |
| `module` | `module` | `app` | Área de los errores capturados cuando `capture` no dice otra. |
| `tags` | `tags` | Ninguna | Etiquetas comunes a todos los errores. |
| `captureUnhandled` | `capture_unhandled` | Activado | Captura sola lo que nadie trata. |
| `beforeSend` | `before_send` | Ninguna | Revisa cada error antes de enviarlo. |
| `sampleRate` | `sample_rate` | 1 | Fracción de errores que se envían, de 0 a 1. |
| `maxQueue` | `max_queue` | 100 | Errores que caben en la cola; lo que no cabe se descarta. |
| `flushIntervalMs` | `flush_interval` | 2000 ms (2,0 s) | Cada cuánto sale un lote. En Python va en segundos. |

### Versión y entorno

Con `release`, cada grupo guarda la primera y la última versión en que se vio: la lista de la pestaña Errores marca «Nuevo en 2.4.1», el listado se filtra por `firstRelease` y, si un error resuelto reaparece, el aviso dice en qué versión. El entorno no separa grupos: el mismo fallo en `staging` y en `production` cuenta como uno.

### Agrupación propia

Dos errores son el mismo cuando coinciden el módulo, el mensaje normalizado y el primer marco de la pila que es de tu código (no de `node_modules` ni de las librerías de Python). Al normalizar, los identificadores largos (UUID, números de cuatro cifras o más, hexadecimales), las fechas, los correos, las IP y la consulta de las direcciones se cambian por un comodín, así que `Pedido 48213 no encontrado` y `Pedido 51990 no encontrado` caen en el mismo grupo. Los textos entre comillas y los números de tres cifras no se tocan, porque distinguen fallos distintos. La gravedad tampoco parte el grupo: guarda la más alta que ha visto.

Si sabes mejor que nadie qué es «el mismo error», manda `fingerprint`: una lista de 1 a 8 textos de hasta 200 caracteres. Los errores del mismo módulo con la misma lista forman un grupo, y el texto `'{{default}}'` dentro de la lista inserta la agrupación automática:

#### JavaScript

```typescript
// Un grupo por fallo y por pasarela
sdk.errors.capture(error, { module: 'pagos', fingerprint: ['{{default}}', pasarela] });

// Un grupo por fallo y por entorno
sdk.errors.capture(error, { fingerprint: ['{{default}}', process.env.NODE_ENV ?? 'production'] });
```

#### Python

```python
# Un grupo por fallo y por pasarela
sdk.errors.capture_exception(module="pagos", fingerprint=["{{default}}", pasarela])
```

### Etiquetas

`tags` es un objeto plano de hasta 20 etiquetas, con nombres de hasta 50 caracteres y valores de texto (hasta 200), número o booleano, sin anidar. Sirven para leer el error de un vistazo y se guardan en `context.tags`. Las de cada `capture` se suman a las de `install`.

### Revisar antes de enviar

`beforeSend` (`before_send`) recibe cada error ya preparado y devuelve el mismo, cambiado o no, o `null` (`None`) para descartarlo. Úsalo para quitar datos que no quieres que salgan de tu aplicación o para callar el ruido. Si falla, ese error se descarta, y lo que llames a `capture` desde dentro se ignora.

#### JavaScript

```typescript
sdk.errors.install({
  beforeSend(event) {
    if (event.message.includes('ResizeObserver loop')) return null; // ruido del navegador
    if (event.context) delete event.context.telefono;
    return event;
  },
});
```

#### Python

```python
def before_send(event):
    if event.context:
        event.context.pop("telefono", None)
    return event


sdk.errors.install(before_send=before_send)
```

Con `sampleRate: 0.25` (`sample_rate=0.25`) sale, al azar, uno de cada cuatro errores; los demás devuelven `null`.

## Qué te responde UTILIA OS

`capture` resuelve con `accepted`, `status` o `reason`, y `hash`, el identificador del grupo. Con `accepted: true`, `status` dice qué ha pasado:

- `created`: un grupo nuevo. Tu servidor recibe `ERROR_REPORTED`.
- `updated`: una ocurrencia más de un grupo que ya existía.
- `regressed`: el grupo estaba resuelto y se ha reabierto solo. Tu servidor recibe `ERROR_REOPENED`.

Con `accepted: false`, `reason` dice por qué no se ha registrado. Ninguno es un fallo de tu aplicación:

| `reason` | Qué significa | ¿Repetir? |
| - | - | - |
| `rate_limited` | Más de 100 ocurrencias por minuto del mismo error. No se pierden: se suman al contador con la siguiente que entra. | No |
| `quota_exhausted` | Tu aplicación ya ha registrado hoy 200 errores distintos. Las ocurrencias de los ya conocidos siguen entrando. | No |
| `overloaded` | La plataforma se está protegiendo de una avalancha de errores. | No |
| `discarded_expected` | El contexto trae un 4xx: un error de negocio que tu aplicación ya trata. Ver el apartado siguiente. | No |
| `storage_failed` | No se pudo guardar. La cola del SDK lo repite sola, hasta tres veces. | Solo ese error |

## Límites y conservación

**Límites del registro de errores**

- Peticiones por ruta y aplicación: 120 al minuto. En cada ruta de /external/v1/errors.
- Ocurrencias del mismo error: 100 al minuto. Las que pasan se suman al contador.
- Errores distintos por aplicación: 200 al día. Por día natural en hora UTC.
- Conservación de cada grupo: 14 días. Contados desde su última ocurrencia.

* **El cupo por hora de tu clave no se toca.** Las rutas de errores tienen su propio límite de 120 peticiones por minuto, y al pasarlo responden `429` con `Retry-After`. La cola del SDK espera ese tiempo antes de repetir.
* **Los errores conocidos siempre entran.** El tope de 200 al día cuenta solo los errores nuevos; un fallo que ya tiene grupo sigue sumando ocurrencias.
* **El contexto se limpia al guardarlo.** UTILIA OS quita las credenciales y los correos y lo recorta a 16 KB. Puedes enviar hasta 64 KB: el SDK ajusta antes de enviar los campos que se pasan de sus límites y, si el contexto supera unos 60 KB, lo sustituye por `contextTruncated: true` y un extracto en `contextPreview`, para que un campo no haga rechazar el lote entero.
* **Los grupos sin resolver con un ticket o una tarea vinculados no caducan** mientras sigan así.

## Errores 4xx que no se registran

Si el contexto trae `httpStatus`, `statusCode` o `errorCode` con un código 4xx, UTILIA OS lo toma por un error de negocio o de permiso que tu aplicación ya trata, como un formulario mal rellenado o un 404 esperado, y no lo registra: `accepted: false` con `reason: 'discarded_expected'`. Los cortes pasajeros (408, 502, 503 y 504) sí se registran, con gravedad `low`.

Para registrar un 4xx que sí importa, como una dependencia que te responde 403, pon el código en `context.upstreamStatus`:

### JavaScript

```typescript
sdk.errors.captureMessage('La pasarela rechaza nuestra clave', {
  module: 'pagos',
  severity: 'high',
  context: { upstreamStatus: 403, pasarela: 'redsys' },
});
```

### Python

```python
sdk.errors.capture_message(
    "La pasarela rechaza nuestra clave",
    module="pagos",
    severity="high",
    context={"upstreamStatus": 403, "pasarela": "redsys"},
)
```

## Enviar en lote por tu cuenta

`install` y `capture` ya envían en lote. Si prefieres controlar tú el envío, por ejemplo en un proceso que vuelca errores de un registro, usa `reportBatch` (`report_batch`): de 1 a 25 errores con los mismos campos que `report`, en un solo intento. Cada resultado trae su posición (`index`): repite solo los que vuelvan con `reason: 'storage_failed'`.

### JavaScript

```typescript
const errores = [
  { message: 'Factura sin cliente', module: 'importador', release: '2.4.1' },
  { message: 'Fecha fuera de rango', module: 'importador', release: '2.4.1' },
];

const { accepted, results } = await sdk.errors.reportBatch(errores);
const pendientes = results
  .filter((r) => r.reason === 'storage_failed')
  .map((r) => errores[r.index]);
```

### Python

```python
from utilia_sdk import ReportErrorInput

errores = [
    ReportErrorInput(message="Factura sin cliente", module="importador", release="2.4.1"),
    ReportErrorInput(message="Fecha fuera de rango", module="importador", release="2.4.1"),
]

resultado = await sdk.errors.report_batch(errores)
pendientes = [errores[r.index] for r in resultado.results if r.reason == "storage_failed"]
```

`report` envía un solo error y, a diferencia de `capture`, lanza `UtiliaSDKError` si algo falla. Un `503` con `errorCode` `ERROR_STORAGE_UNAVAILABLE` se puede repetir: espera lo que diga `retryAfterMs` (`retry_after`, en segundos). Los dos endpoints, con todos sus campos, están en [Enviar los errores de tu aplicación](https://os.utilia.ai/docs/developers/api/errors).

## Resolver, reabrir y silenciar

Con «Gestionar errores reportados» (`analytics:errors:manage`), tu aplicación gestiona sus propios errores igual que tu equipo en la pestaña Errores. Actúa en nombre de la persona responsable de la aplicación, que necesita el permiso **Gestionar los errores de las aplicaciones externas**; si no lo tiene, la respuesta es `403 APP_RESPONSIBLE_PERMISSION_DENIED`.

### JavaScript

```typescript
const { data } = await sdk.errors.list({ status: 'active', severity: ['critical', 'high'] });
const detalle = await sdk.errors.get(data[0].hash); // pila, contexto y ticket vinculado

await sdk.errors.resolve(detalle.hash, 'Corregido en la 2.4.2');
await sdk.errors.silence(detalle.hash, 24); // de 1 a 672 horas (cuatro semanas)
await sdk.errors.unsilence(detalle.hash);
await sdk.errors.reopen(detalle.hash);
```

### Python

```python
from utilia_sdk import ErrorFilters

pagina = await sdk.errors.list(ErrorFilters(status="active", severity=["critical", "high"]))
detalle = await sdk.errors.get(pagina.data[0].hash)  # pila, contexto y ticket vinculado

await sdk.errors.resolve(detalle.hash, "Corregido en la 2.4.2")
await sdk.errors.silence(detalle.hash, 24)  # de 1 a 672 horas (cuatro semanas)
await sdk.errors.unsilence(detalle.hash)
await sdk.errors.reopen(detalle.hash)
```

Resolver uno ya resuelto devuelve `alreadyResolved: true` sin cambiar nada, y reabrir uno abierto, `alreadyOpen: true`. Un error silenciado sigue sumando ocurrencias, pero no cuenta como pendiente ni avisa hasta que vence el plazo.

**Un error resuelto que vuelve a ocurrir se reabre solo.** Esa ocurrencia responde `status: 'regressed'`, el grupo suma una en `regressionCount` y tu servidor recibe `ERROR_REOPENED` con `reason: 'regression'` y la versión en `release`: así sabes, sin mirar, que la corrección no arregló nada. Los estados de un grupo y qué los cambia están en [Estados de un grupo de errores](https://os.utilia.ai/docs/developers/api/errors-tab#estados-de-un-grupo-de-errores), y todos los filtros y respuestas, en [Consultar y gestionar los errores](https://os.utilia.ai/docs/developers/api/errors-manage).

## Avisos a tu servidor

Si suscribes tu aplicación a ellos, cada cambio llega como un aviso firmado:

- `ERROR_REPORTED`: un grupo nuevo, con `count`, `release` y `environment`. No se repite por cada ocurrencia.
- `ERROR_RESOLVED`: alguien lo ha resuelto, desde la pestaña Errores, por la API o al cerrar el ticket o la tarea vinculados.
- `ERROR_REOPENED`: se ha reabierto, a mano (`reason: 'manual'`) o porque volvió a ocurrir (`reason: 'regression'`).
- `ERROR_SILENCED`, `ERROR_LINKED_TO_TICKET` y `ERROR_LINKED_TO_ISSUE`: se silenció o se vinculó a un ticket o a una tarea.

El contenido de cada uno está en [Avisos: contenido y catálogo de eventos](https://os.utilia.ai/docs/developers/webhooks/events#errores-del-sistema).

## Si algo falla

- **No llega nada y la consola avisa una vez de `analytics:errors:write`.** La clave responde 401 o 403: concede «Reportar errores al backend» en la ficha de la aplicación. Mientras tanto, la cola descarta lo que intenta enviar.
- **En el navegador, las peticiones fallan con `403 ORIGIN_NOT_ALLOWED` o un error de CORS.** Declara el dominio de tu web en los **Dominios autorizados** de la aplicación.
- **`capture` devuelve `null`.** El error se descartó por `sampleRate` o `beforeSend`, la cola estaba llena (`maxQueue`) o el envío falló tras sus reintentos.
- **Los errores que tumban un proceso de Node no llegan.** Node cierra el proceso antes de enviar: añade el manejador de [Dos matices de Node.js](#dos-matices-de-nodejs).
- **Las promesas rechazadas de Node no llegan.** Arrancas con `--unhandled-rejections=warn` o `none`: llama a `capture` donde las trates.
- **Los errores de tus rutas web no salen solos.** Tu framework los convierte en respuestas 500 antes de que lleguen a los ganchos: llama a `capture` en su manejador de errores.
- **Un error no aparece y la respuesta dice `discarded_expected`.** El contexto trae un 4xx; si te importa, usa `context.upstreamStatus`.
- **`quota_exhausted` casi a diario.** Tus mensajes crean grupos distintos para el mismo fallo, normalmente por un dato variable que no se normaliza (un texto entre comillas, un número corto): quítalo del mensaje o agrupa con `fingerprint`.

## Siguiente paso

- [Pestaña de errores de la aplicación](https://os.utilia.ai/docs/developers/api/errors-tab): Qué ve tu equipo, qué puede hacer con cada grupo y qué avisos recibe tu servidor.
- [Contenido de los avisos](https://os.utilia.ai/docs/developers/webhooks/events#errores-del-sistema): Los campos de ERROR_REPORTED, ERROR_REOPENED y el resto de avisos de errores.
