argus-obs-semconv 1.0.0a3__tar.gz
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.
- argus_obs_semconv-1.0.0a3/.gitignore +30 -0
- argus_obs_semconv-1.0.0a3/PKG-INFO +62 -0
- argus_obs_semconv-1.0.0a3/README.md +35 -0
- argus_obs_semconv-1.0.0a3/generated/go/attributes.go +119 -0
- argus_obs_semconv-1.0.0a3/pyproject.toml +50 -0
- argus_obs_semconv-1.0.0a3/src/argus_semconv/__init__.py +62 -0
- argus_obs_semconv-1.0.0a3/src/argus_semconv/_content.py +108 -0
- argus_obs_semconv-1.0.0a3/src/argus_semconv/_metrics_api.py +124 -0
- argus_obs_semconv-1.0.0a3/src/argus_semconv/attributes.py +135 -0
- argus_obs_semconv-1.0.0a3/src/argus_semconv/genai.py +413 -0
- argus_obs_semconv-1.0.0a3/src/argus_semconv/guardrails.py +184 -0
- argus_obs_semconv-1.0.0a3/src/argus_semconv/py.typed +0 -0
- argus_obs_semconv-1.0.0a3/src/argus_semconv/steps.py +150 -0
- argus_obs_semconv-1.0.0a3/tests/test_content_capture.py +130 -0
- argus_obs_semconv-1.0.0a3/tests/test_genai_conventions.py +187 -0
- argus_obs_semconv-1.0.0a3/tests/test_guardrails.py +207 -0
- argus_obs_semconv-1.0.0a3/tests/test_metricas_genai.py +112 -0
- argus_obs_semconv-1.0.0a3/tests/test_noop_guarantee.py +133 -0
- argus_obs_semconv-1.0.0a3/tests/test_semconv_model.py +47 -0
- argus_obs_semconv-1.0.0a3/uv.lock +206 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
.env
|
|
2
|
+
.env.*
|
|
3
|
+
!.env.example
|
|
4
|
+
!.env.*.example
|
|
5
|
+
*.env.local
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
.venv/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
*.egg-info/
|
|
14
|
+
.DS_Store
|
|
15
|
+
|
|
16
|
+
# Configuracion del dead man's switch: lleva credenciales de aviso.
|
|
17
|
+
deadman/deadman.json
|
|
18
|
+
|
|
19
|
+
# Secretos montados en contenedores. Nunca al repositorio.
|
|
20
|
+
platform/secrets/
|
|
21
|
+
|
|
22
|
+
# El índice se GENERA desde dist/ con scripts/construir_indice.py.
|
|
23
|
+
# Versionar ruedas es versionar binarios que ya sabemos reproducir.
|
|
24
|
+
platform/indice/simple/
|
|
25
|
+
|
|
26
|
+
# La coordinacion con otros equipos vive FUERA del repositorio, en
|
|
27
|
+
# ~/Documents/Victor/<equipo>_argus/. Aqui hay incidentes suyos, rutas de su
|
|
28
|
+
# codigo y sus identificadores de backlog: es informacion de ellos y este
|
|
29
|
+
# repositorio es publico. Ver docs/coordinacion.md.
|
|
30
|
+
docs/solicitudes/
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: argus-obs-semconv
|
|
3
|
+
Version: 1.0.0a3
|
|
4
|
+
Summary: Convenciones semanticas de Argus. Para LIBRERIAS: depende solo de la API de OpenTelemetry.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Root1V/argus-obs
|
|
6
|
+
Project-URL: Repository, https://github.com/Root1V/argus-obs
|
|
7
|
+
Project-URL: Issues, https://github.com/Root1V/argus-obs/issues
|
|
8
|
+
Author: Emeric Espiritu
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: genai,llm,observability,opentelemetry,semantic-conventions
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: System :: Monitoring
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: opentelemetry-api<2,>=1.30
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: opentelemetry-sdk<2,>=1.30; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
25
|
+
Requires-Dist: pyyaml>=6; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# argus-semconv
|
|
29
|
+
|
|
30
|
+
Convenciones semánticas de Argus para **librerías**.
|
|
31
|
+
|
|
32
|
+
## Cuándo usar este paquete y cuándo no
|
|
33
|
+
|
|
34
|
+
| Escribes… | Importa | Por qué |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| Una **librería** (Axonium, synaptum, un SDK tuyo) | `argus-semconv` | Solo depende de `opentelemetry-api`. No impone SDK ni exportadores a quien te use |
|
|
37
|
+
| Una **aplicación** (servicio, worker, CLI) | `argus-sdk` | Es quien configura e inicializa la telemetría |
|
|
38
|
+
|
|
39
|
+
## La garantía
|
|
40
|
+
|
|
41
|
+
Si la aplicación que importa tu librería **no** ha inicializado un SDK de
|
|
42
|
+
OpenTelemetry, toda la instrumentación de este paquete es **no-op con coste
|
|
43
|
+
cero**. Si **sí** lo ha inicializado, se enciende sola usando *su*
|
|
44
|
+
configuración, *su* endpoint y *su* muestreo.
|
|
45
|
+
|
|
46
|
+
Eso significa que puedes instrumentar tus librerías sin imponer nada a nadie.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from argus_semconv import genai
|
|
50
|
+
|
|
51
|
+
# En Axonium, dentro del cliente de inferencia:
|
|
52
|
+
with genai("chat", provider="ollama", request_model=model) as g:
|
|
53
|
+
resp = await self._call_backend(...)
|
|
54
|
+
g.usage(input_tokens=resp.prompt_tokens, output_tokens=resp.completion_tokens)
|
|
55
|
+
g.backend(backend_id=backend.id, ttft_ms=resp.ttft_ms)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Las constantes están generadas
|
|
59
|
+
|
|
60
|
+
`attributes.py` se genera desde `libs/semconv-model/argus.yaml`. No lo edites a
|
|
61
|
+
mano: ejecuta `python tools/gen_semconv.py`. Un test de CI verifica que no hay
|
|
62
|
+
deriva entre el modelo y lo commiteado.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# argus-semconv
|
|
2
|
+
|
|
3
|
+
Convenciones semánticas de Argus para **librerías**.
|
|
4
|
+
|
|
5
|
+
## Cuándo usar este paquete y cuándo no
|
|
6
|
+
|
|
7
|
+
| Escribes… | Importa | Por qué |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Una **librería** (Axonium, synaptum, un SDK tuyo) | `argus-semconv` | Solo depende de `opentelemetry-api`. No impone SDK ni exportadores a quien te use |
|
|
10
|
+
| Una **aplicación** (servicio, worker, CLI) | `argus-sdk` | Es quien configura e inicializa la telemetría |
|
|
11
|
+
|
|
12
|
+
## La garantía
|
|
13
|
+
|
|
14
|
+
Si la aplicación que importa tu librería **no** ha inicializado un SDK de
|
|
15
|
+
OpenTelemetry, toda la instrumentación de este paquete es **no-op con coste
|
|
16
|
+
cero**. Si **sí** lo ha inicializado, se enciende sola usando *su*
|
|
17
|
+
configuración, *su* endpoint y *su* muestreo.
|
|
18
|
+
|
|
19
|
+
Eso significa que puedes instrumentar tus librerías sin imponer nada a nadie.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from argus_semconv import genai
|
|
23
|
+
|
|
24
|
+
# En Axonium, dentro del cliente de inferencia:
|
|
25
|
+
with genai("chat", provider="ollama", request_model=model) as g:
|
|
26
|
+
resp = await self._call_backend(...)
|
|
27
|
+
g.usage(input_tokens=resp.prompt_tokens, output_tokens=resp.completion_tokens)
|
|
28
|
+
g.backend(backend_id=backend.id, ttft_ms=resp.ttft_ms)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Las constantes están generadas
|
|
32
|
+
|
|
33
|
+
`attributes.py` se genera desde `libs/semconv-model/argus.yaml`. No lo edites a
|
|
34
|
+
mano: ejecuta `python tools/gen_semconv.py`. Un test de CI verifica que no hay
|
|
35
|
+
deriva entre el modelo y lo commiteado.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// GENERADO AUTOMATICAMENTE. NO EDITAR A MANO.
|
|
2
|
+
//
|
|
3
|
+
// Fuente: libs/semconv-model/argus.yaml
|
|
4
|
+
// Regenerar: python tools/gen_semconv.py
|
|
5
|
+
|
|
6
|
+
package semconv
|
|
7
|
+
|
|
8
|
+
const (
|
|
9
|
+
SemconvVersion = "1.0.0"
|
|
10
|
+
GenAISemconvVersion = "1.39.0"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// identity: Identidad de aplicacion y componente
|
|
14
|
+
const (
|
|
15
|
+
ServiceNamespace = "service.namespace"
|
|
16
|
+
ServiceName = "service.name"
|
|
17
|
+
ServiceVersion = "service.version"
|
|
18
|
+
ServiceInstanceID = "service.instance.id"
|
|
19
|
+
DeploymentEnvironmentName = "deployment.environment.name"
|
|
20
|
+
ArgusComponentRole = "argus.component.role"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// langfuse_bridge: Atributos que Langfuse interpreta de forma especial
|
|
24
|
+
const (
|
|
25
|
+
LangfuseEnvironment = "langfuse.environment"
|
|
26
|
+
LangfuseRelease = "langfuse.release"
|
|
27
|
+
LangfuseObservationType = "langfuse.observation.type"
|
|
28
|
+
LangfuseTraceName = "langfuse.trace.name"
|
|
29
|
+
LangfuseTraceTags = "langfuse.trace.tags"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// attribution: Etiquetas de atribucion para FinOps y analisis
|
|
33
|
+
const (
|
|
34
|
+
ArgusApp = "argus.app"
|
|
35
|
+
ArgusFeature = "argus.feature"
|
|
36
|
+
ArgusUseCase = "argus.use_case"
|
|
37
|
+
ArgusRunID = "argus.run.id"
|
|
38
|
+
ArgusTenant = "argus.tenant"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
// wide_event: Campos del evento ancho canonico
|
|
42
|
+
const (
|
|
43
|
+
ArgusEvent = "argus.event"
|
|
44
|
+
ArgusOutcome = "argus.outcome"
|
|
45
|
+
ArgusDurationMs = "argus.duration_ms"
|
|
46
|
+
ArgusStepDepth = "argus.step.depth"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
// hotpath: Marcas que enrutan al camino caliente de deteccion
|
|
50
|
+
const (
|
|
51
|
+
ArgusHot = "argus.hot"
|
|
52
|
+
ArgusSloBreached = "argus.slo.breached"
|
|
53
|
+
ArgusSloThresholdMs = "argus.slo.threshold_ms"
|
|
54
|
+
ArgusGuardrail = "argus.guardrail"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
// errors: Clasificacion de errores
|
|
58
|
+
const (
|
|
59
|
+
ErrorType = "error.type"
|
|
60
|
+
ArgusErrorRetryable = "argus.error.retryable"
|
|
61
|
+
ArgusErrorRetryPolicy = "argus.error.retry_policy"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
// genai: Convenciones GenAI de OpenTelemetry
|
|
65
|
+
const (
|
|
66
|
+
GenAIOperationName = "gen_ai.operation.name"
|
|
67
|
+
GenAIProviderName = "gen_ai.provider.name"
|
|
68
|
+
GenAIRequestModel = "gen_ai.request.model"
|
|
69
|
+
GenAIResponseModel = "gen_ai.response.model"
|
|
70
|
+
GenAIResponseID = "gen_ai.response.id"
|
|
71
|
+
GenAIResponseFinishReasons = "gen_ai.response.finish_reasons"
|
|
72
|
+
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
|
|
73
|
+
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
|
|
74
|
+
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
|
|
75
|
+
GenAIRequestTemperature = "gen_ai.request.temperature"
|
|
76
|
+
GenAIRequestMaxTokens = "gen_ai.request.max_tokens"
|
|
77
|
+
GenAIRequestTopP = "gen_ai.request.top_p"
|
|
78
|
+
GenAIConversationID = "gen_ai.conversation.id"
|
|
79
|
+
GenAIAgentName = "gen_ai.agent.name"
|
|
80
|
+
GenAIAgentID = "gen_ai.agent.id"
|
|
81
|
+
GenAIToolName = "gen_ai.tool.name"
|
|
82
|
+
GenAIToolType = "gen_ai.tool.type"
|
|
83
|
+
GenAIToolCallID = "gen_ai.tool.call.id"
|
|
84
|
+
GenAIInputMessages = "gen_ai.input.messages"
|
|
85
|
+
GenAIOutputMessages = "gen_ai.output.messages"
|
|
86
|
+
GenAISystemInstructions = "gen_ai.system_instructions"
|
|
87
|
+
GenAIToolCallArguments = "gen_ai.tool.call.arguments"
|
|
88
|
+
GenAIToolCallResult = "gen_ai.tool.call.result"
|
|
89
|
+
GenAIContentTruncated = "gen_ai.content.truncated"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
// inference: Detalles de inferencia local que solo conoce el SDK cliente
|
|
93
|
+
const (
|
|
94
|
+
ArgusBackendID = "argus.backend.id"
|
|
95
|
+
ArgusBackendCircuitState = "argus.backend.circuit_state"
|
|
96
|
+
ArgusBackendFallback = "argus.backend.fallback"
|
|
97
|
+
ArgusCostUSD = "argus.cost_usd"
|
|
98
|
+
ArgusTtftMs = "argus.ttft_ms"
|
|
99
|
+
ArgusFirstTokenMs = "argus.first_token_ms"
|
|
100
|
+
ArgusTokensPerSecond = "argus.tokens_per_second"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// retrieval: Recuperacion de documentos
|
|
104
|
+
const (
|
|
105
|
+
ArgusRetrievalStore = "argus.retrieval.store"
|
|
106
|
+
ArgusRetrievalTopK = "argus.retrieval.top_k"
|
|
107
|
+
ArgusRetrievalDocCount = "argus.retrieval.doc_count"
|
|
108
|
+
ArgusRetrievalScoreMin = "argus.retrieval.score_min"
|
|
109
|
+
ArgusRetrievalScoreMax = "argus.retrieval.score_max"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
// Metricas
|
|
113
|
+
const (
|
|
114
|
+
MetricGenAIClientOperationDuration = "gen_ai.client.operation.duration"
|
|
115
|
+
MetricGenAIClientTokenUsage = "gen_ai.client.token.usage"
|
|
116
|
+
MetricGenAIServerTimeToFirstToken = "gen_ai.server.time_to_first_token"
|
|
117
|
+
MetricArgusStepDuration = "argus.step.duration"
|
|
118
|
+
MetricArgusCostUSD = "argus.cost.usd"
|
|
119
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "argus-obs-semconv"
|
|
3
|
+
version = "1.0.0a3"
|
|
4
|
+
description = "Convenciones semanticas de Argus. Para LIBRERIAS: depende solo de la API de OpenTelemetry."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Emeric Espiritu" }]
|
|
9
|
+
keywords = ["observability", "opentelemetry", "semantic-conventions", "genai", "llm"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Topic :: System :: Monitoring",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
# ATENCION: esta lista de dependencias es un contrato, no un detalle.
|
|
22
|
+
#
|
|
23
|
+
# Este paquete lo importan LIBRERIAS (Axonium, synaptum, ...), no aplicaciones.
|
|
24
|
+
# Una libreria que depende del SDK impone en silencio su version del SDK y sus
|
|
25
|
+
# opiniones sobre exportadores a TODO el que dependa de ella. Con quince o mas
|
|
26
|
+
# aplicaciones importando Axonium, eso es un infierno de dependencias.
|
|
27
|
+
#
|
|
28
|
+
# Por eso: SOLO la API, y con rango abierto dentro de la mayor. NUNCA una
|
|
29
|
+
# version exacta, NUNCA el SDK, NUNCA un exportador.
|
|
30
|
+
dependencies = [
|
|
31
|
+
"opentelemetry-api>=1.30,<2",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "opentelemetry-sdk>=1.30,<2", "pyyaml>=6"]
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["hatchling"]
|
|
39
|
+
build-backend = "hatchling.build"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/argus_semconv"]
|
|
43
|
+
|
|
44
|
+
# Quien encuentre esto en PyPI tiene derecho a saber de donde sale y donde
|
|
45
|
+
# reportar. Un paquete sin origen publico es la misma opacidad de cadena de
|
|
46
|
+
# suministro que nos objeto un equipo consumidor (D-075).
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://github.com/Root1V/argus-obs"
|
|
49
|
+
Repository = "https://github.com/Root1V/argus-obs"
|
|
50
|
+
Issues = "https://github.com/Root1V/argus-obs/issues"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Convenciones semanticas de Argus, para LIBRERIAS.
|
|
2
|
+
|
|
3
|
+
Este paquete depende SOLO de `opentelemetry-api`. Nunca del SDK, nunca de un
|
|
4
|
+
exportador. Esa restriccion es el contrato:
|
|
5
|
+
|
|
6
|
+
- Si la aplicacion que te importa no inicializo un SDK, todo esto es no-op con
|
|
7
|
+
coste cero, y tu libreria funciona exactamente igual que sin instrumentar.
|
|
8
|
+
- Si lo inicializo, tu instrumentacion se enciende sola usando SU
|
|
9
|
+
configuracion, SU endpoint y SU muestreo.
|
|
10
|
+
|
|
11
|
+
Una libreria que dependiera del SDK impondria en silencio su version y sus
|
|
12
|
+
opiniones sobre exportadores a todo el que dependa de ella.
|
|
13
|
+
|
|
14
|
+
Las aplicaciones no usan este paquete directamente: usan `argus-sdk`, que lo
|
|
15
|
+
re-exporta ademas de configurar la telemetria.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from . import attributes
|
|
21
|
+
from ._content import capture_enabled, mask
|
|
22
|
+
from .genai import GenAISpan, agent, documents, genai, retrieval, tool
|
|
23
|
+
from .guardrails import AgentRun, Budget, GuardrailBreach, current_run
|
|
24
|
+
from .steps import Step, instrument, step
|
|
25
|
+
|
|
26
|
+
# La version del PAQUETE, distinta de la de las convenciones: el paquete puede
|
|
27
|
+
# publicarse varias veces sin que cambien las convenciones.
|
|
28
|
+
try:
|
|
29
|
+
from importlib.metadata import version as _version
|
|
30
|
+
|
|
31
|
+
__version__ = _version("argus-obs-semconv")
|
|
32
|
+
except Exception: # noqa: BLE001
|
|
33
|
+
__version__ = "0.0.0.dev0"
|
|
34
|
+
|
|
35
|
+
#: Version del MODELO de convenciones (libs/semconv-model/argus.yaml).
|
|
36
|
+
SEMCONV_VERSION = attributes.SEMCONV_VERSION
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
# Convenciones generadas
|
|
40
|
+
"attributes",
|
|
41
|
+
# GenAI
|
|
42
|
+
"genai",
|
|
43
|
+
"retrieval",
|
|
44
|
+
"tool",
|
|
45
|
+
"agent",
|
|
46
|
+
"documents",
|
|
47
|
+
"GenAISpan",
|
|
48
|
+
# Guardarrailes de agentes
|
|
49
|
+
"Budget",
|
|
50
|
+
"GuardrailBreach",
|
|
51
|
+
"AgentRun",
|
|
52
|
+
"current_run",
|
|
53
|
+
# Unidades de trabajo
|
|
54
|
+
"step",
|
|
55
|
+
"instrument",
|
|
56
|
+
"Step",
|
|
57
|
+
# Contenido
|
|
58
|
+
"capture_enabled",
|
|
59
|
+
"mask",
|
|
60
|
+
"SEMCONV_VERSION",
|
|
61
|
+
"__version__",
|
|
62
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Captura y enmascarado de contenido de prompts.
|
|
2
|
+
|
|
3
|
+
Dos decisiones que conviene entender antes de tocar este fichero.
|
|
4
|
+
|
|
5
|
+
1. El contenido va en ATRIBUTOS de span, no en eventos.
|
|
6
|
+
|
|
7
|
+
La guia canonica de las semconv GenAI dice que el contenido debe ir en
|
|
8
|
+
eventos de span, para poder descartarlo en el Collector sin tocar codigo.
|
|
9
|
+
El razonamiento es bueno. Pero Langfuse lee `gen_ai.input.messages` y
|
|
10
|
+
`gen_ai.output.messages` de los ATRIBUTOS del span. Si seguimos la guia al
|
|
11
|
+
pie de la letra, Langfuse ingiere el span y lo muestra sin input ni output,
|
|
12
|
+
que es justo lo que lo hace util.
|
|
13
|
+
|
|
14
|
+
Resolucion: emitir en atributos, y borrarlos en la rama del Collector que
|
|
15
|
+
va a ClickHouse (donde son decenas de KB sin ninguna consulta que los use).
|
|
16
|
+
|
|
17
|
+
2. Enmascarar aqui es la PRIMERA capa, no la unica.
|
|
18
|
+
|
|
19
|
+
El Collector tiene un `redaction` processor como red de seguridad. Esa
|
|
20
|
+
segunda capa es la que importa de verdad: significa que un fallo de
|
|
21
|
+
instrumentacion en una app no se convierte en una fuga en el almacen.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from typing import Any, Final
|
|
31
|
+
|
|
32
|
+
_TRUE: Final = frozenset({"1", "true", "yes", "on"})
|
|
33
|
+
|
|
34
|
+
# Umbral por defecto. Un prompt entero puede ser de decenas de KB; un atributo
|
|
35
|
+
# de span no es sitio para eso.
|
|
36
|
+
_DEFAULT_MAX_BYTES: Final = 8192
|
|
37
|
+
|
|
38
|
+
_PATTERNS: Final[tuple[tuple[re.Pattern[str], str], ...]] = (
|
|
39
|
+
(re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"), "[EMAIL]"),
|
|
40
|
+
(re.compile(r"\b(?:\d[ -]*?){13,19}\b"), "[CARD]"),
|
|
41
|
+
# Telefono: EXIGE separadores o prefijo internacional. Una tirada suelta de
|
|
42
|
+
# diez digitos es casi siempre un identificador, un timestamp o un contador,
|
|
43
|
+
# no un telefono. Redactarla destruye datos utiles y no protege nada.
|
|
44
|
+
# Mismo criterio que el `redaction` processor del Collector: las dos capas
|
|
45
|
+
# tienen que coincidir o los datos salen distintos segun por donde pasen.
|
|
46
|
+
(re.compile(r"\+\d{1,3}[-.\s]\d{2,4}[-.\s]\d{2,4}[-.\s]?\d{0,4}"), "[PHONE]"),
|
|
47
|
+
(re.compile(r"\b\d{3}[-.\s]\d{3}[-.\s]\d{4}\b"), "[PHONE]"),
|
|
48
|
+
(re.compile(r"\b\d{8}[A-HJ-NP-TV-Z]\b"), "[DNI]"),
|
|
49
|
+
(re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b"), "[IBAN]"),
|
|
50
|
+
(re.compile(r"(?i)\b(?:sk|pk)-[A-Za-z0-9_\-]{16,}"), "[KEY]"),
|
|
51
|
+
(re.compile(r"(?i)\bBearer\s+[A-Za-z0-9_\-.]{16,}"), "[TOKEN]"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def capture_enabled() -> bool:
|
|
56
|
+
"""Si se captura el contenido de prompts y respuestas.
|
|
57
|
+
|
|
58
|
+
Por defecto NO. Se activa en desarrollo y de forma selectiva por aplicacion
|
|
59
|
+
en produccion, donde la evaluacion aporta.
|
|
60
|
+
"""
|
|
61
|
+
val = os.getenv("ARGUS_CAPTURE_CONTENT", "")
|
|
62
|
+
if val:
|
|
63
|
+
return val.strip().lower() in _TRUE
|
|
64
|
+
# Compatibilidad con la variable estandar de OpenTelemetry.
|
|
65
|
+
return os.getenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "").strip().lower() in _TRUE
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def max_bytes() -> int:
|
|
69
|
+
try:
|
|
70
|
+
return max(0, int(os.getenv("ARGUS_CONTENT_MAX_BYTES", str(_DEFAULT_MAX_BYTES))))
|
|
71
|
+
except ValueError:
|
|
72
|
+
return _DEFAULT_MAX_BYTES
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def mask(text: str) -> str:
|
|
76
|
+
"""Redacta PII conocida. Primera capa; el Collector es la segunda."""
|
|
77
|
+
for pattern, replacement in _PATTERNS:
|
|
78
|
+
text = pattern.sub(replacement, text)
|
|
79
|
+
return text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def serialize(
|
|
83
|
+
payload: Any,
|
|
84
|
+
*,
|
|
85
|
+
masker: Callable[[str], str] | None = None,
|
|
86
|
+
limit: int | None = None,
|
|
87
|
+
) -> tuple[str, bool]:
|
|
88
|
+
"""Serializa a JSON, enmascara y trunca.
|
|
89
|
+
|
|
90
|
+
Devuelve `(texto, truncado)`. Nunca lanza: si el payload no es
|
|
91
|
+
serializable cae a `repr`, porque perder telemetria es aceptable y tumbar
|
|
92
|
+
la aplicacion del usuario no lo es.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
raw = json.dumps(payload, ensure_ascii=False, default=str)
|
|
96
|
+
except (TypeError, ValueError):
|
|
97
|
+
raw = repr(payload)
|
|
98
|
+
|
|
99
|
+
raw = (masker or mask)(raw)
|
|
100
|
+
|
|
101
|
+
cap = max_bytes() if limit is None else limit
|
|
102
|
+
if cap and len(raw.encode("utf-8")) > cap:
|
|
103
|
+
# Cortamos por bytes y descartamos el ultimo caracter posiblemente
|
|
104
|
+
# partido a mitad de secuencia UTF-8.
|
|
105
|
+
raw = raw.encode("utf-8")[:cap].decode("utf-8", errors="ignore")
|
|
106
|
+
return raw, True
|
|
107
|
+
|
|
108
|
+
return raw, False
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Instrumentos de metricas GenAI, emitidos desde la API de OpenTelemetry.
|
|
2
|
+
|
|
3
|
+
Por que vive aqui y no en el SDK de aplicacion: si las metricas hubiera que
|
|
4
|
+
emitirlas a mano, nadie las emitiria. Las convenciones dicen que
|
|
5
|
+
`gen_ai.client.operation.duration` y `gen_ai.client.token.usage` van desde el
|
|
6
|
+
dia uno, y eso solo se cumple si salen SOLAS del mismo context manager que ya
|
|
7
|
+
se usa para las trazas.
|
|
8
|
+
|
|
9
|
+
La API de metricas de OpenTelemetry tiene la misma propiedad que la de trazas:
|
|
10
|
+
sin SDK configurado devuelve instrumentos no operativos con coste cero. Asi que
|
|
11
|
+
una libreria puede emitir metricas sin imponer nada a quien la importe, igual
|
|
12
|
+
que con los spans.
|
|
13
|
+
|
|
14
|
+
Los BUCKETS no se fijan aqui —eso es configuracion del SDK— sino en las vistas
|
|
15
|
+
de `argus-obs-sdk`, que los aplica por nombre de instrumento cuando esta
|
|
16
|
+
presente.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from opentelemetry import metrics
|
|
24
|
+
|
|
25
|
+
from . import attributes as A
|
|
26
|
+
|
|
27
|
+
_meter = metrics.get_meter("argus-semconv", A.SEMCONV_VERSION)
|
|
28
|
+
|
|
29
|
+
# Creados una vez al importar. Sin SDK son no-ops.
|
|
30
|
+
_duracion = _meter.create_histogram(
|
|
31
|
+
A.M_GEN_AI_CLIENT_OPERATION_DURATION,
|
|
32
|
+
unit=A.M_GEN_AI_CLIENT_OPERATION_DURATION_UNIT,
|
|
33
|
+
description="Duracion de la operacion GenAI",
|
|
34
|
+
)
|
|
35
|
+
_tokens = _meter.create_histogram(
|
|
36
|
+
A.M_GEN_AI_CLIENT_TOKEN_USAGE,
|
|
37
|
+
unit=A.M_GEN_AI_CLIENT_TOKEN_USAGE_UNIT,
|
|
38
|
+
description="Tokens consumidos, con el tipo como dimension",
|
|
39
|
+
)
|
|
40
|
+
_ttft = _meter.create_histogram(
|
|
41
|
+
A.M_GEN_AI_SERVER_TIME_TO_FIRST_TOKEN,
|
|
42
|
+
unit=A.M_GEN_AI_SERVER_TIME_TO_FIRST_TOKEN_UNIT,
|
|
43
|
+
description="Tiempo hasta el primer token",
|
|
44
|
+
)
|
|
45
|
+
_coste = _meter.create_counter(
|
|
46
|
+
A.M_ARGUS_COST_USD,
|
|
47
|
+
unit=A.M_ARGUS_COST_USD_UNIT,
|
|
48
|
+
description="Coste atribuido",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _base(operation: str, provider: str, model: str | None) -> dict[str, Any]:
|
|
53
|
+
attrs: dict[str, Any] = {
|
|
54
|
+
A.GEN_AI_OPERATION_NAME: operation,
|
|
55
|
+
A.GEN_AI_PROVIDER_NAME: provider,
|
|
56
|
+
}
|
|
57
|
+
if model:
|
|
58
|
+
attrs[A.GEN_AI_REQUEST_MODEL] = model
|
|
59
|
+
return attrs
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def record_duration(
|
|
63
|
+
*, operation: str, provider: str, model: str | None,
|
|
64
|
+
seconds: float, error_type: str | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
attrs = _base(operation, provider, model)
|
|
67
|
+
if error_type:
|
|
68
|
+
attrs[A.ERROR_TYPE] = error_type
|
|
69
|
+
try:
|
|
70
|
+
_duracion.record(seconds, attrs)
|
|
71
|
+
except Exception: # noqa: BLE001 - la telemetria nunca tumba la app
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def record_tokens(
|
|
76
|
+
*, operation: str, provider: str, model: str | None,
|
|
77
|
+
input_tokens: int | None = None, output_tokens: int | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""`gen_ai.token.type` va como DIMENSION, no como dos instrumentos.
|
|
80
|
+
|
|
81
|
+
Es lo que dice la especificacion y lo que permite agregar por modelo sin
|
|
82
|
+
unir series a mano.
|
|
83
|
+
"""
|
|
84
|
+
attrs = _base(operation, provider, model)
|
|
85
|
+
try:
|
|
86
|
+
if input_tokens:
|
|
87
|
+
_tokens.record(input_tokens, {**attrs, "gen_ai.token.type": "input"})
|
|
88
|
+
if output_tokens:
|
|
89
|
+
_tokens.record(output_tokens, {**attrs, "gen_ai.token.type": "output"})
|
|
90
|
+
except Exception: # noqa: BLE001
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def record_ttft(*, provider: str, model: str | None, seconds: float, backend_id: str | None = None) -> None:
|
|
95
|
+
attrs = _base("chat", provider, model)
|
|
96
|
+
attrs.pop(A.GEN_AI_OPERATION_NAME, None)
|
|
97
|
+
if backend_id:
|
|
98
|
+
attrs[A.ARGUS_BACKEND_ID] = backend_id
|
|
99
|
+
try:
|
|
100
|
+
_ttft.record(seconds, attrs)
|
|
101
|
+
except Exception: # noqa: BLE001
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def record_cost(
|
|
106
|
+
*, cost_usd: float, model: str | None,
|
|
107
|
+
app: str | None = None, feature: str | None = None, use_case: str | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Dimensiones pensadas para poder ATRIBUIR el coste.
|
|
110
|
+
|
|
111
|
+
Sin app, funcionalidad y caso de uso, el coste llega como un numero opaco a
|
|
112
|
+
fin de mes y no se puede optimizar nada.
|
|
113
|
+
"""
|
|
114
|
+
attrs = {
|
|
115
|
+
A.ARGUS_APP: app or "unknown",
|
|
116
|
+
A.ARGUS_FEATURE: feature or "unknown",
|
|
117
|
+
A.ARGUS_USE_CASE: use_case or "unknown",
|
|
118
|
+
}
|
|
119
|
+
if model:
|
|
120
|
+
attrs[A.GEN_AI_REQUEST_MODEL] = model
|
|
121
|
+
try:
|
|
122
|
+
_coste.add(cost_usd, attrs)
|
|
123
|
+
except Exception: # noqa: BLE001
|
|
124
|
+
pass
|