# Enviar los errores de tu aplicación

Endpoints para que tu aplicación envíe sus errores a UTILIA OS, uno a uno o en lotes de 25, con una respuesta que dice qué pasó con cada uno.

Con estos endpoints tu aplicación envía sus errores a UTILIA OS. Los iguales se agrupan en uno solo con un contador, y la respuesta dice si se ha creado un grupo, si se ha sumado a uno que ya existía o por qué no se ha registrado. Tu equipo los ve en la [pestaña Errores de la aplicación](https://os.utilia.ai/docs/developers/api/errors-tab). Con los SDK no hace falta llamar a estos endpoints a mano: `install` y `capture` lo hacen por ti ([Capturar los errores de tu aplicación](https://os.utilia.ai/docs/developers/sdk/error-reporting)).

**Un error, de tu aplicación a tu equipo**

Participantes: Tu aplicación, UTILIA OS y Tu equipo.

1. Tu aplicación → UTILIA OS: `POST /api/external/v1/errors/batch`. De 1 a 25 errores con su mensaje, módulo, pila y contexto.
2. UTILIA OS (por su cuenta): Agrupa cada error con los iguales y limpia el contexto.
3. UTILIA OS → Tu aplicación (respuesta): 201 con el resultado de cada error. accepted, status o reason, y el hash del grupo.
4. UTILIA OS → Tu aplicación: Aviso ERROR_REPORTED. Solo la primera vez que aparece un error.
5. UTILIA OS → Tu equipo: El grupo aparece en la pestaña Errores
6. UTILIA OS → Tu aplicación: Aviso ERROR_REOPENED. Si un error resuelto vuelve a ocurrir, con reason regression.

## Autenticación, plan y permisos

Todas las peticiones llevan la cabecera `X-Api-Key`, y el plan de tu organización tiene que incluir el aplicativo **Integraciones**.

| Endpoint | Permiso de la API (scope) |
| - | - |
| `POST /external/v1/errors/report` | `analytics:errors:write` |
| `POST /external/v1/errors/batch` | `analytics:errors:write` |

Sin el permiso, los endpoints responden `403 INSUFFICIENT_SCOPE` con el permiso que falta en
`requiredScopes`; lo concede un administrador de la organización. Se exige desde la primera llamada, sin periodo de gracia.
Consultar, resolver y silenciar los errores tienen sus propios permisos: están en
[Consultar y gestionar los errores](https://os.utilia.ai/docs/developers/api/errors-manage). El catálogo completo y las reglas de
concesión están en [Permisos de la API](https://os.utilia.ai/docs/developers/api/scopes).

## Límites

- **120 peticiones por minuto** en cada ruta, por aplicación. Un lote de 25 cuenta como una petición. Al pasarlo, `429` con `Retry-After`. Estas rutas **no gastan** el cupo por hora de tu clave.
- **100 ocurrencias por minuto del mismo error.** Las que pasan no se registran una a una, pero se suman al contador con la siguiente que entra.
- **200 errores distintos al día** por aplicación, en hora UTC. Las ocurrencias de los errores que ya tienen grupo siempre entran.
- **14 días de conservación**, contados desde la última ocurrencia. Un grupo sin resolver con un ticket o una tarea vinculados no caduca mientras siga así.

## Enviar un error

**`POST /api/external/v1/errors/report`**

Registra un error de tu aplicación. Permiso: analytics:errors:write.

### Cuerpo

| Nombre | Tipo | Obligatorio | Descripción |
| - | - | - | - |
| `message` | `string` | Sí | Mensaje del error, de 1 a 2000 caracteres. |
| `module` | `string` | Sí | Área de tu aplicación donde ocurrió (checkout, auth...), de 1 a 100 caracteres. Forma parte de la agrupación. |
| `severity` | `string` | No | critical, high, medium o low. Sin indicarla, low. |
| `stack` | `string` | No | Traza de la pila, hasta 10.000 caracteres. Su primer marco de tu código forma parte de la agrupación. |
| `file` | `string` | No | Archivo de origen, hasta 500 caracteres. |
| `line` | `number` | No | Línea de origen (entero). |
| `function` | `string` | No | Función de origen, hasta 200 caracteres. |
| `endpoint` | `string` | No | Ruta de la petición que falló, hasta 500 caracteres. |
| `method` | `string` | No | GET, POST, PUT, PATCH o DELETE. |
| `userId` | `string` | No | Identificador de la persona afectada en tu sistema, hasta 255 caracteres. No se relaciona con los usuarios de UTILIA OS: se guarda en context.externalUserRef. |
| `requestId` | `string` | No | Identificador de la petición, para cruzarlo con tus registros. Hasta 100 caracteres. |
| `context` | `object` | No | Datos libres, hasta 64 KB en JSON (si pasa, 400). Al guardarse se quitan credenciales y correos y se recorta a 16 KB. |
| `release` | `string` | No | Versión de tu aplicación, hasta 100 caracteres. Se guardan la primera y la última versión en que se vio el error. |
| `environment` | `string` | No | Entorno (production, staging...), hasta 40 caracteres. Se guarda en minúsculas y no separa grupos. |
| `fingerprint` | `string[]` | No | Agrupación propia: de 1 a 8 textos de hasta 200 caracteres. Los errores del mismo módulo con la misma lista forman un grupo. |
| `tags` | `object` | No | Hasta 20 etiquetas: nombres de hasta 50 caracteres y valores de texto (hasta 200), número o booleano. Se guardan en context.tags. |

**Agrupación.** Sin `fingerprint`, dos errores son el mismo cuando coinciden el módulo, el mensaje normalizado (los identificadores largos, las fechas, los correos, las IP y la consulta de las direcciones se cambian por un comodín) y el primer marco de la pila que es de tu código. Dentro de `fingerprint`, el texto `"{{default}}"` inserta esa agrupación automática: `["{{default}}", "redsys"]` separa el mismo fallo por pasarela. La gravedad no parte el grupo: guarda la más alta que ha visto.

**Gravedad y errores esperados.** Si `context` trae `httpStatus`, `statusCode` o `errorCode` con un código HTTP, decide la gravedad efectiva:

- **408, 502, 503 o 504** (un corte pasajero): se registra con gravedad `low`, declares la que declares.
- **Cualquier otro 4xx**: es un error de negocio o de permiso que tu aplicación ya trata, y no se registra (`accepted: false`, `reason: discarded_expected`).

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

### Respuesta

`201` con lo que ha pasado:

```json
{
  "success": true,
  "accepted": true,
  "status": "regressed",
  "hash": "5f2b7c1e9a0d4e3b8c6a1f7d2e9b0c4a",
  "deduplicated": true,
  "count": 37
}
```

| Nombre | Tipo | Descripción |
| - | - | - |
| `success` | `boolean` | Siempre true: la petición se ha atendido. |
| `accepted` | `boolean` | true si el error ha quedado registrado. |
| `status` | `string` | Solo con accepted. created: grupo nuevo; updated: ocurrencia sumada a un grupo existente; regressed: el grupo estaba resuelto y se ha reabierto solo. |
| `reason` | `string` | Solo sin accepted. rate_limited, quota_exhausted, overloaded o discarded_expected. |
| `hash` | `string` | Identificador del grupo, el que aceptan las rutas /:hash. Vacío con discarded_expected. |
| `deduplicated` | `boolean` | true si el error se ha sumado a un grupo que ya existía (updated o regressed). |
| `count` | `number` | Solo con accepted: ocurrencias acumuladas del grupo. |

Ningún `reason` es un fallo de tu aplicación, y ninguno se arregla repitiendo:

| `reason` | Qué significa |
| - | - |
| `rate_limited` | Más de 100 ocurrencias por minuto del mismo error. Se suman al contador con la siguiente que entra. |
| `quota_exhausted` | Tu aplicación ya ha registrado hoy 200 errores distintos. Los ya conocidos siguen entrando. |
| `overloaded` | La plataforma se está protegiendo de una avalancha de errores. |
| `discarded_expected` | El contexto trae un 4xx que no es un corte pasajero. |

Si UTILIA OS no puede guardar el error, la respuesta es `503` con la cabecera `Retry-After: 30`. Es el único caso que se repite: espera esos segundos y vuelve a enviarlo.

```json
{
  "statusCode": 503,
  "errorCode": "ERROR_STORAGE_UNAVAILABLE",
  "message": "No se ha podido guardar el error en este momento. Vuelve a enviarlo dentro de 30 segundos.",
  "retryable": true
}
```

### Ejemplos

#### curl

```bash
curl -X POST 'https://os.utilia.ai/api/external/v1/errors/report' \
  -H 'Content-Type: application/json' \
  -H 'X-Api-Key: utl_xxx' \
  -d '{
    "message": "Tiempo de espera agotado con la pasarela",
    "module": "pagos",
    "severity": "critical",
    "endpoint": "/api/pagos/cobrar",
    "method": "POST",
    "release": "2.4.1",
    "environment": "production",
    "tags": { "pasarela": "redsys", "reintento": 2 },
    "context": { "pedidoId": "ped_456" }
  }'
```

#### JavaScript

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

try {
  const result = await sdk.errors.report({
    message: 'Tiempo de espera agotado con la pasarela',
    module: 'pagos',
    severity: 'critical',
    release: '2.4.1',
    context: { pedidoId: 'ped_456' },
  });
  if (!result.accepted) console.log('No registrado:', result.reason);
} catch (error) {
  if (error instanceof UtiliaSDKError && error.errorCode === 'ERROR_STORAGE_UNAVAILABLE') {
    // Repetir pasados error.retryAfterMs milisegundos
  }
}
```

#### Python

```python
from utilia_sdk import ReportErrorInput, UtiliaSDKError

try:
    result = await sdk.errors.report(
        ReportErrorInput(
            message="Tiempo de espera agotado con la pasarela",
            module="pagos",
            severity="critical",
            release="2.4.1",
            context={"pedidoId": "ped_456"},
        )
    )
    if not result.accepted:
        print("No registrado:", result.reason)
except UtiliaSDKError as error:
    if error.error_code == "ERROR_STORAGE_UNAVAILABLE":
        pass  # Repetir pasados error.retry_after segundos
```

## Enviar varios en un lote

**`POST /api/external/v1/errors/batch`**

Registra de 1 a 25 errores con el mismo contrato que /report, en orden. Permiso: analytics:errors:write.

El cuerpo es `{ "errors": [...] }`, con de 1 a 25 errores que siguen exactamente el contrato de `POST /report`. Si alguno no es válido, el lote entero responde `400` y no se registra ninguno. El lote cuenta como una sola petición en el límite por minuto.

La respuesta, `201`, trae el resultado de cada error en el orden en que los enviaste:

```json
{
  "received": 3,
  "accepted": 1,
  "results": [
    { "index": 0, "accepted": true, "status": "created", "hash": "5f2b7c1e9a0d4e3b8c6a1f7d2e9b0c4a" },
    { "index": 1, "accepted": false, "reason": "discarded_expected", "hash": "" },
    { "index": 2, "accepted": false, "reason": "storage_failed", "hash": "" }
  ]
}
```

Además de los motivos de `/report`, un elemento puede traer `reason: storage_failed`: UTILIA OS no pudo guardarlo. El lote nunca responde `503`; **repite solo los elementos con `storage_failed`**, nunca el lote entero, porque contarías dos veces los que sí se guardaron.

```bash
curl -X POST 'https://os.utilia.ai/api/external/v1/errors/batch' \
  -H 'Content-Type: application/json' \
  -H 'X-Api-Key: utl_xxx' \
  -d '{
    "errors": [
      { "message": "Factura sin cliente", "module": "importador", "release": "2.4.1" },
      { "message": "Fecha fuera de rango", "module": "importador", "release": "2.4.1" }
    ]
  }'
```

Con los SDK, `sdk.errors.reportBatch(errores)` (`report_batch`) hace un solo intento y devuelve este mismo resultado.

## Errores

| HTTP | Cuándo |
| - | - |
| `400` | Cuerpo no válido: un campo pasa de su tamaño, `context` supera 64 KB, etiquetas mal formadas o un lote vacío o de más de 25. |
| `401` | Falta la clave de API o no es válida. |
| `403` | Falta `analytics:errors:write` (`INSUFFICIENT_SCOPE`), el plan no incluye Integraciones (`MODULE_NOT_ENABLED`), la aplicación está desactivada o el origen no está entre sus dominios autorizados. |
| `429` | Más de 120 peticiones por minuto en la ruta. Respeta `Retry-After`. |
| `503` | Solo en `/report`: `ERROR_STORAGE_UNAVAILABLE`. Repite pasados los segundos de `Retry-After`. |

Envía `x-request-id` en cada petición para poder seguirla en los registros.

## Siguiente paso

- [Capturar los errores con el SDK](https://os.utilia.ai/docs/developers/sdk/error-reporting): install y capture: la cola, los lotes y los reintentos, resueltos en dos líneas.
- [Pestaña de errores de la aplicación](https://os.utilia.ai/docs/developers/api/errors-tab): Qué ve tu equipo de lo que envías y qué avisos recibe tu servidor.
