Metadata-Version: 2.5
Name: argus-obs-sdk
Version: 1.0.0a19
Summary: SDK de observabilidad de Argus. Para APLICACIONES: configura trazas, metricas y logs con una linea.
Project-URL: Homepage, https://github.com/Root1V/argus-observability-platform
Project-URL: Repository, https://github.com/Root1V/argus-observability-platform
Project-URL: Issues, https://github.com/Root1V/argus-observability-platform/issues
Author: Emeric Espiritu
License: MIT
Keywords: genai,llm,logs,metrics,observability,opentelemetry,tracing
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Monitoring
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: argus-obs-semconv<2,>=1.0.0a19
Requires-Dist: opentelemetry-sdk<2,>=1.30
Provides-Extra: all
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2,>=1.30; extra == 'all'
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.30; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-aiokafka>=0.51b0; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-asgi>=0.51b0; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-asyncpg>=0.51b0; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-celery>=0.51b0; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.51b0; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-httpx>=0.51b0; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-redis>=0.51b0; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-requests>=0.51b0; extra == 'all'
Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.51b0; extra == 'all'
Requires-Dist: structlog>=25.1; extra == 'all'
Provides-Extra: asgi
Requires-Dist: opentelemetry-instrumentation-asgi>=0.51b0; extra == 'asgi'
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.51b0; extra == 'asgi'
Provides-Extra: celery
Requires-Dist: opentelemetry-instrumentation-celery>=0.51b0; extra == 'celery'
Provides-Extra: client
Requires-Dist: opentelemetry-instrumentation-httpx>=0.51b0; extra == 'client'
Requires-Dist: opentelemetry-instrumentation-requests>=0.51b0; extra == 'client'
Provides-Extra: dev
Requires-Dist: httpx>=0.27; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: pyyaml>=6; extra == 'dev'
Requires-Dist: starlette>=0.37; extra == 'dev'
Provides-Extra: genai
Requires-Dist: openlit>=1.34; extra == 'genai'
Provides-Extra: grpc
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2,>=1.30; extra == 'grpc'
Provides-Extra: http
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.30; extra == 'http'
Provides-Extra: kafka
Requires-Dist: opentelemetry-instrumentation-aiokafka>=0.51b0; extra == 'kafka'
Provides-Extra: logging
Requires-Dist: structlog>=25.1; extra == 'logging'
Provides-Extra: redis
Requires-Dist: opentelemetry-instrumentation-redis>=0.51b0; extra == 'redis'
Provides-Extra: sql
Requires-Dist: opentelemetry-instrumentation-asyncpg>=0.51b0; extra == 'sql'
Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.51b0; extra == 'sql'
Description-Content-Type: text/markdown

# argus-obs-sdk

SDK de observabilidad de Argus para **aplicaciones**.

```bash
pip install argus-obs-sdk
```
```python
import argus
```

> **El nombre de instalación y el de importación no coinciden, y conviene
> decirlo antes que nada.** Se instala `argus-obs-sdk` y se importa `argus`.
>
> El prefijo `-obs-` es deliberado: elegimos nombres que **no existían** en
> PyPI para que un fallo del índice no acabara instalando el paquete de un
> desconocido con el nombre que esperábamos. El módulo conservó el nombre
> corto porque es lo que se escribe cien veces.
>
> Va arriba porque es lo primero con lo que tropieza quien lo adopta.

```python
import argus
argus.init()
```

Eso configura resource, trazas, métricas, logs, propagadores y todas las
auto-instrumentaciones disponibles, leyendo la configuración del entorno.

Para servicios ASGI, una línea más:

```python
app.add_middleware(argus.middleware(app).__class__)   # o ASGIMiddleware directo
```

## Si escribes una librería, no uses este paquete

Usa [`argus-semconv`](../argus-semconv), que depende solo de
`opentelemetry-api`. Una librería que depende del SDK impone en silencio su
versión del SDK y sus opiniones sobre exportadores a todo el que dependa de
ella.

## El contrato

Verificado por tests en `tests/test_contract.py`, no por buenas intenciones:

1. **Nunca tumba la aplicación.** Si el Collector está caído, la app pierde
   telemetría, nunca latencia ni memoria.
2. **Idempotente.** `init()` dos veces es no-op la segunda.
3. **No-op si NO llamas a `init()`.** Los decoradores funcionan con coste
   cero, que es lo que permite instrumentar tus librerías sin imponer
   telemetría a quien las importe.

   > **Ojo con la otra lectura, porque cuesta cara.** «No-op sin configurar»
   > NO significa «sin endpoint no se exporta». Una vez llamas a `init()`, el
   > endpoint cae a `http://localhost:4317` y **se exporta de verdad**. La
   > única forma de no exportar es `ARGUS_DISABLED=1`. Ver *Tests y CI* abajo.
4. **Cero configuración en el caso normal.** Todo viene del entorno.
5. **Superficie pública mínima**, fijada por test.

## Variables de entorno

El contrato es de entorno, no de API de Python: un componente Go y uno Python
se configuran copiando el mismo bloque.

| Variable | Significado | Por defecto |
|---|---|---|
| `ARGUS_SERVICE` | El sub-componente (`service.name`). **Sin él se avisa**: nada se puede atribuir ni correlacionar | `unknown-service` |
| `ARGUS_NAMESPACE` | La aplicación (`service.namespace`). **Sin él se avisa**: la identidad de dos niveles desaparece y entras en el registro sin canales | = servicio |
| `ARGUS_ROLE` | `api`/`worker`/`scheduler`/`cli`/`model-server`/`frontend` | `api` |
| `ARGUS_VERSION` | Versión del componente | — |
| `ARGUS_ENVIRONMENT` | El **nivel** de despliegue: `development` \| `staging` \| `test` \| `production`. Vocabulario del estándar, no nuestro. La máquina es `host.name` y la pone el agente | **ausente** — no se inventa, y se avisa |
| `ARGUS_ENDPOINT` | Collector **agente local** | `http://localhost:4317` |
| `ARGUS_PROTOCOL` | `grpc` \| `http/protobuf` | según lo instalado |
| `ARGUS_TRUST_INBOUND` | Si adoptar un `traceparent` que llega de fuera: `never` \| `trusted` \| `always` | `never` |
| `ARGUS_PROPAGATE` | Nombre **viejo** de la anterior. Sigue funcionando | — |
| `ARGUS_TRUSTED_CIDRS` | CIDRs de confianza, separados por coma | — |
| `ARGUS_CAPTURE_CONTENT` | Capturar prompts y respuestas | `false` |
| `ARGUS_SLO_MS` | Umbral de latencia del componente | `0` (sin umbral) |
| `ARGUS_DISABLED` | Apagar toda la telemetría | `false` |

Las estándar de OpenTelemetry (`OTEL_SERVICE_NAME`,
`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_RESOURCE_ATTRIBUTES`) también se respetan,
para que una app que ya tiene OTel migre sin tocar código.



## Los avisos de arranque

`argus.init()` comprueba cuatro cosas al arrancar y **avisa sin impedir el
arranque**. Las cuatro son fallos que no dan ningún error: la aplicación
funciona, la telemetría se exporta, y los datos llegan sin servir.

| aviso | qué pasa si lo ignoras |
|---|---|
| **sin identidad** (`ARGUS_SERVICE` / `ARGUS_NAMESPACE`) | nada se puede atribuir, correlacionar ni enrutar; en el registro entras sin canales, o sea tus incidentes no llegan a nadie |
| **`localhost` desde un contenedor** | no llega ni un span, y el exportador reintenta de fondo sin error |
| **protocolo contra el puerto del otro transporte** | `UNAVAILABLE` en bucle, para las tres señales |
| **entorno fuera del vocabulario** o ausente | no se puede separar producción de desarrollo |

Los cuatro salieron de equipos que nos adoptaron, no de nuestras pruebas. Los
dos primeros los reportó un equipo que perdió una tarde con ellos; el de la
identidad, uno que descubrió 450 registros suyos que ni ellos podían
atribuirse.

**Nada se inventa para que el aviso desaparezca.** El entorno ausente queda
ausente: adivinarlo es lo que hizo que este SDK sellara `local` durante semanas
—un valor que no está en el estándar— sin que nadie lo viera.


## Tests y CI: `ARGUS_DISABLED=1`

**Si tu suite importa la app, tu suite exporta telemetría de verdad.**

La cadena no es evidente y la encontró Prosodia con 1.797 registros en el
almacén compartido:

1. `conftest.py` importa la app de FastAPI.
2. La app llama a `argus.init()` al importarse — tiene que ser antes de los
   routers.
3. Recoger los tests arranca telemetría.
4. **Sin `OTEL_EXPORTER_OTLP_ENDPOINT`, el SDK no se calla**: cae a
   `localhost:4318` y manda.

El paso 4 es el que engaña. *No hay endpoint* y *no exportar* son cosas
distintas, y solo hay una forma de la segunda:

```python
# conftest.py, en la raíz de la suite, antes de cualquier import
import os
os.environ["ARGUS_DISABLED"] = "1"
```

Se verifica contando: los registros del almacén antes y después de correr la
suite tienen que ser el mismo número.

## Procesos por lotes y CLIs

Tres cosas que no son evidentes, las tres de Prosodia al instrumentar su CLI:

**1 · `init()` va antes de configurar el logging de tu aplicación.** El puente
busca un proveedor ya configurado; si configuras después, gana lo tuyo y el
puente queda sin enganchar. Mismo orden que con el `Executor`: lo tuyo
primero, lo nuestro encima.

**2 · Hay que abrir la traza raíz a mano.** Un proceso por lotes no tiene
petición entrante que abra un span, así que sin esto los registros salen todos
sin `trace_id`:

```python
with argus.propagate.run("doblaje.cli", app="prosodia"):
    ...
```

**3 · Pasa el `namespace` explícitamente.** Si das `service` y no `namespace`,
el namespace cae al nombre del servicio — así que un driver nuevo del mismo
producto **se auto-excluye de la consulta por aplicación**:

```python
argus.init(service="prosodia-cli")                      # namespace = prosodia-cli
argus.init(service="prosodia-cli", namespace="prosodia")  # correcto
```

Desde `1.0.0a17` esto avisa al arrancar.

## Aplicaciones ASGI: engancha en el `lifespan`, no en el import

Si quieres los logs del **servidor** (los accesos y errores de uvicorn, que no
pasan por tu logging), engancharlos donde parece natural —junto a `init()`, al
importar— **se pierde sin dar error**:

```
antes de dictConfig:   ['mi-handler']
después de dictConfig: ['access']
¿sobrevivió?            False
```

uvicorn configura su logging con `dictConfig` **después** de importar la app y
reemplaza los handlers de `uvicorn.access` y `uvicorn.error`. El primer momento
en que sobrevive es el `lifespan`.

## El muestreo es de trazas, no de registros

El tail sampling del gateway conserva el 100 % de los errores, lo lento, lo
GenAI y lo que cruza una cola; **el resto va al 10 %**. Los registros no se
muestrean.

La consecuencia práctica, medida por Prosodia sobre 40 peticiones:

```
registros exportados   40 de 40
spans guardados         2 de 40
```

**Si una ruta te importa, sus spans solos no la cubren.** O emites una línea de
log por unidad de trabajo —el evento ancho— o marcas lo que importa con
`argus.hot`, o aceptas ver el 10 %.

## Propagar el contexto a otro hilo

El contexto de OpenTelemetry vive en un `contextvars.ContextVar`. `asyncio` lo
copia al crear una tarea; **`ThreadPoolExecutor` no lo copia** al hilo
trabajador. Lo que se pierde ahí no suele ser la traza entera, sino los
registros que emite la librería que corre dentro: salen con `trace_id = none`
mientras los de al lado salen bien.

```python
from argus.propagate import Executor as ThreadPoolExecutor
```

`submit` y `map` propagan solos, así que no hay que tocar ninguna llamada. Es
un reemplazo y no un ayudante a propósito: un ayudante hay que recordarlo en
cada envío, y el olvido no da error.

### Si el SDK es una dependencia **opcional** de tu aplicación

Que lo sea es lo correcto —la aplicación tiene que arrancar sin él— y entonces
el import de arriba la rompe al cargar, en una ruta que no toca telemetría.
Protégelo, **con el caso base primero**:

```python
from concurrent.futures import ThreadPoolExecutor   # sin el extra, este

try:
    from argus.propagate import Executor as ThreadPoolExecutor
except ImportError:
    pass
```

El orden no es cosmético: al revés, `mypy` lo rechaza porque asigna un
supertipo sobre un subtipo. Declarar el estándar y estrecharlo después es lo
que comprueba.

> Esta receta es de Prosodia, que la encontró adoptándolo: tienen el SDK en un
> extra `web` y la misma función la corre también una CLI que no lo instala.

### Y para un subproceso

Ahí no hay contexto que copiar: hay que pasar la cadena.

```python
subprocess.run(cmd, env=argus.propagate.inject_env(dict(os.environ)))
```

Y en el hijo, si es código tuyo:

```python
with argus.propagate.extract_env(name="mi.etapa"):
    ...
```

### Dos nombres parecidos que no tienen nada que ver

| | qué es |
|---|---|
| `argus.propagate` | el **módulo**: lleva el contexto entre hilos, procesos, colas y cabeceras |
| `ARGUS_TRUST_INBOUND` (antes `ARGUS_PROPAGATE`) | si **confiar** en un `traceparent` que llega de fuera por HTTP |

Les pusimos el mismo nombre y no debimos. El segundo es una decisión de
seguridad sobre lo que entra; el primero es cómo sale lo tuyo.


## Dos sitios donde `step` no va

**Un paso que espera a una persona no lleva SLO.** `argus.step(..., slo_ms=N)`
pone `argus.hot` por su cuenta cuando el span dura más de N, y `argus.hot`
enciende el camino caliente: una notificación en segundos. Correcto para un
paso que debía ser rápido, y una trampa exacta para una espera de aprobación,
que es operación normal. Su desenlace es `argus.outcome = suspended`.

**Desde código de workflow de Temporal, ni `argus.step` ni `argus.tool`
directamente.** El SDK resuelve el tracer global y no sabe nada de replays, así
que un span abierto ahí se vuelve a crear en cada uno. Emite desde una
Activity.

> Las dos son de Aeon, que las encontró midiendo el contador del camino
> caliente. La primera es la tercera vía por la que la misma llamada acababa
> paginando — guardrail, `codes.Error` y SLO.

## Una denegación no es un error, y lleva quién la hizo

```python
with argus.step("tool.invoke") as s:
    if not politica.permite(peticion):
        s.denied(by="policy")        # `policy` · `human` · `budget`
        return
```

`denied()` pone **las dos mitades** —`argus.outcome=denied` y
`argus.denied_by`— y lo hace en una llamada porque el fallo de este par no es
escribir un valor malo, es escribir media pareja:

| lo que falta | qué se rompe |
|---|---|
| `denied` sin `denied_by` | la denegación no se puede atribuir, y **el hueco no se ve**: un `GROUP BY` sobre un campo ausente devuelve una fila, no una queja |
| `denied_by` sin `denied` | la dimensión cuelga de un paso que siguió adelante, y contamina cualquier recuento de denegaciones |

El SDK avisa de las dos **al cerrar el paso**, no al escribir el campo: lo que
hay que mirar es lo que se emite, no lo que se ha escrito hasta esa línea.

**Tres cosas que una denegación NO es:**

- **No es un `error`.** El sistema hizo lo correcto. Contarla como fallo
  ensucia cualquier tasa de error con decisiones acertadas.
- **No es un `cancelled`**, que sugiere que alguien se arrepintió.
- **No enciende el camino caliente.** No lleva `argus.guardrail` y `denied()`
  no marca `argus.hot`. Ese campo significa «este run se paró y hay que mirarlo
  ya», y marcar cada denegación de política paginaría en todas: la fatiga de
  alertas construida desde dentro.

**Pero se conserva entera.** La política `auditoria` del gateway se queda el
**100 %** de las trazas con `argus.outcome = denied`, así que no va al 10 % del
baseline y esta dimensión no se muestrea. Las dos cosas a la vez —no paginar y
no muestrear— son el punto: se cambió una notificación de más por un registro
completo, porque el ruido se nota y un registro con huecos no.

> `argus.denied_by` es de Aeon, y su argumento era consecuencia de un consejo
> **nuestro**: les dijimos que no pusieran `argus.guardrail` en una denegación
> de política, y con eso dejamos la denegación sin ningún portador de quién
> denegó. `human` significa una persona **autenticada** desde que cerraron su
> clase de llamador.

**Dice la clase, nunca cuál.** El *quién* concreto es `user.id` /
`enduser.pseudo.id`, que son del estándar y que el gateway seudonimiza. La
cardinalidad cerrada de aquí es justo lo que permite agrupar sin tocar a nadie.

## Por qué `localhost` y no el plano central

Las aplicaciones exportan **siempre** al Collector agente de su propia máquina.
Nunca conocen la dirección del plano central. Eso da tres propiedades:

- Mover el plano central de una máquina a otra no toca ni una aplicación.
- Si el central está suspendido, las apps no se enteran ni se ralentizan: el
  agente local escribe a disco y envía cuando vuelve la conexión.
- Añadir una máquina es desplegar un agente, no reconfigurar apps.
