oopstrace 0.1.0__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.
- oopstrace-0.1.0/.gitignore +247 -0
- oopstrace-0.1.0/PKG-INFO +77 -0
- oopstrace-0.1.0/README.md +65 -0
- oopstrace-0.1.0/oopstrace/__init__.py +21 -0
- oopstrace-0.1.0/oopstrace/client.py +577 -0
- oopstrace-0.1.0/oopstrace/patches/__init__.py +21 -0
- oopstrace-0.1.0/oopstrace/patches/_anthropic.py +81 -0
- oopstrace-0.1.0/oopstrace/patches/_openai.py +67 -0
- oopstrace-0.1.0/pyproject.toml +20 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# ===================
|
|
4
|
+
# Editor
|
|
5
|
+
# ===================
|
|
6
|
+
*.swp
|
|
7
|
+
*.swo
|
|
8
|
+
*~
|
|
9
|
+
|
|
10
|
+
# ===================
|
|
11
|
+
# Python
|
|
12
|
+
# ===================
|
|
13
|
+
|
|
14
|
+
# Byte-compiled / optimized / DLL files
|
|
15
|
+
__pycache__/
|
|
16
|
+
*__pycache__*
|
|
17
|
+
*.py[cod]
|
|
18
|
+
*$py.class
|
|
19
|
+
|
|
20
|
+
# C extensions
|
|
21
|
+
*.so
|
|
22
|
+
|
|
23
|
+
# Distribution / packaging
|
|
24
|
+
.Python
|
|
25
|
+
build/
|
|
26
|
+
develop-eggs/
|
|
27
|
+
dist/
|
|
28
|
+
downloads/
|
|
29
|
+
eggs/
|
|
30
|
+
.eggs/
|
|
31
|
+
lib64/
|
|
32
|
+
parts/
|
|
33
|
+
sdist/
|
|
34
|
+
var/
|
|
35
|
+
wheels/
|
|
36
|
+
share/python-wheels/
|
|
37
|
+
*.egg-info/
|
|
38
|
+
.installed.cfg
|
|
39
|
+
*.egg
|
|
40
|
+
MANIFEST
|
|
41
|
+
|
|
42
|
+
# Virtual environments
|
|
43
|
+
.env
|
|
44
|
+
.venv
|
|
45
|
+
venv/
|
|
46
|
+
ENV/
|
|
47
|
+
env.bak/
|
|
48
|
+
venv.bak/
|
|
49
|
+
.conda
|
|
50
|
+
|
|
51
|
+
# Testing
|
|
52
|
+
htmlcov/
|
|
53
|
+
.tox/
|
|
54
|
+
.nox/
|
|
55
|
+
.coverage
|
|
56
|
+
.coverage.*
|
|
57
|
+
.cache
|
|
58
|
+
nosetests.xml
|
|
59
|
+
coverage.xml
|
|
60
|
+
*.cover
|
|
61
|
+
*.py,cover
|
|
62
|
+
.hypothesis/
|
|
63
|
+
.pytest_cache/
|
|
64
|
+
**/.pytest_cache
|
|
65
|
+
cover/
|
|
66
|
+
|
|
67
|
+
# Type checkers
|
|
68
|
+
.mypy_cache/
|
|
69
|
+
.dmypy.json
|
|
70
|
+
dmypy.json
|
|
71
|
+
.pytype/
|
|
72
|
+
.pyre/
|
|
73
|
+
|
|
74
|
+
# Linters
|
|
75
|
+
.ruff_cache/
|
|
76
|
+
|
|
77
|
+
# Jupyter
|
|
78
|
+
.ipynb_checkpoints
|
|
79
|
+
**/.ipynb_checkpoints/
|
|
80
|
+
|
|
81
|
+
# Sphinx documentation
|
|
82
|
+
docs/_build/
|
|
83
|
+
|
|
84
|
+
# PyBuilder
|
|
85
|
+
.pybuilder/
|
|
86
|
+
target/
|
|
87
|
+
|
|
88
|
+
# pdm
|
|
89
|
+
.pdm.toml
|
|
90
|
+
.pdm-python
|
|
91
|
+
.pdm-build/
|
|
92
|
+
__pypackages__/
|
|
93
|
+
|
|
94
|
+
# Cython debug symbols
|
|
95
|
+
cython_debug/
|
|
96
|
+
|
|
97
|
+
# PyPI
|
|
98
|
+
.pypirc
|
|
99
|
+
|
|
100
|
+
# ===================
|
|
101
|
+
# Node.js / Next.js
|
|
102
|
+
# ===================
|
|
103
|
+
|
|
104
|
+
# Dependencies
|
|
105
|
+
node_modules/
|
|
106
|
+
**/node_modules
|
|
107
|
+
/.pnp
|
|
108
|
+
.pnp.js
|
|
109
|
+
/.pnpm-store
|
|
110
|
+
.pnpm-store
|
|
111
|
+
|
|
112
|
+
# Next.js
|
|
113
|
+
.next/
|
|
114
|
+
**/.next/*
|
|
115
|
+
/out/
|
|
116
|
+
next-env.d.ts
|
|
117
|
+
|
|
118
|
+
# Turbo
|
|
119
|
+
.turbo
|
|
120
|
+
**/.turbo/*
|
|
121
|
+
|
|
122
|
+
# Build
|
|
123
|
+
/build
|
|
124
|
+
**/dist
|
|
125
|
+
|
|
126
|
+
# Debug logs
|
|
127
|
+
npm-debug.log*
|
|
128
|
+
yarn-debug.log*
|
|
129
|
+
yarn-error.log*
|
|
130
|
+
.pnpm-debug.log*
|
|
131
|
+
|
|
132
|
+
# Yarn
|
|
133
|
+
.yarn
|
|
134
|
+
|
|
135
|
+
# TypeScript
|
|
136
|
+
*.tsbuildinfo
|
|
137
|
+
|
|
138
|
+
# Vercel
|
|
139
|
+
.vercel
|
|
140
|
+
|
|
141
|
+
# Generated
|
|
142
|
+
/generated
|
|
143
|
+
|
|
144
|
+
# ===================
|
|
145
|
+
# Environment Files
|
|
146
|
+
# ===================
|
|
147
|
+
|
|
148
|
+
# Local env files - do not commit any .env files except examples
|
|
149
|
+
.env*
|
|
150
|
+
!.env.example
|
|
151
|
+
!.env.development
|
|
152
|
+
!.env.test.example
|
|
153
|
+
|
|
154
|
+
# Local config files
|
|
155
|
+
*.local.*
|
|
156
|
+
|
|
157
|
+
# ===================
|
|
158
|
+
# IDE / Editors
|
|
159
|
+
# ===================
|
|
160
|
+
|
|
161
|
+
# JetBrains (IntelliJ, PyCharm, WebStorm)
|
|
162
|
+
.idea/
|
|
163
|
+
|
|
164
|
+
# VS Code (keep extensions.json if needed)
|
|
165
|
+
# .vscode/
|
|
166
|
+
|
|
167
|
+
# Cursor
|
|
168
|
+
.cursorrules
|
|
169
|
+
.cursorignore
|
|
170
|
+
.cursorindexingignore
|
|
171
|
+
|
|
172
|
+
# ===================
|
|
173
|
+
# Database
|
|
174
|
+
# ===================
|
|
175
|
+
|
|
176
|
+
# SQLite
|
|
177
|
+
*.sqlite
|
|
178
|
+
*.sqlite-journal
|
|
179
|
+
*.db
|
|
180
|
+
*.db-journal
|
|
181
|
+
traceroot.db
|
|
182
|
+
|
|
183
|
+
# Prisma
|
|
184
|
+
/prisma/db.sqlite
|
|
185
|
+
/prisma/db.sqlite-journal
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ===================
|
|
189
|
+
# Testing / Coverage
|
|
190
|
+
# ===================
|
|
191
|
+
|
|
192
|
+
/coverage
|
|
193
|
+
/certs/
|
|
194
|
+
test-results/
|
|
195
|
+
web/test-results/*
|
|
196
|
+
|
|
197
|
+
# ===================
|
|
198
|
+
# System Files
|
|
199
|
+
# ===================
|
|
200
|
+
|
|
201
|
+
.DS_Store
|
|
202
|
+
**/.DS_Store
|
|
203
|
+
*.pem
|
|
204
|
+
|
|
205
|
+
# ===================
|
|
206
|
+
# Claude / AI Tools
|
|
207
|
+
# ===================
|
|
208
|
+
|
|
209
|
+
.claude/tsc-cache
|
|
210
|
+
**/.refactor/
|
|
211
|
+
**/.playwright-mcp/
|
|
212
|
+
|
|
213
|
+
# ===================
|
|
214
|
+
# Project Specific
|
|
215
|
+
# ===================
|
|
216
|
+
|
|
217
|
+
# OpenAPI spec copied during build
|
|
218
|
+
/public/openapi*.yml
|
|
219
|
+
|
|
220
|
+
# Data files (keep test fixtures)
|
|
221
|
+
*.csv
|
|
222
|
+
!tests/**/fixtures/*.csv
|
|
223
|
+
*.hdf
|
|
224
|
+
*.hdf5
|
|
225
|
+
*.parquet
|
|
226
|
+
|
|
227
|
+
# Scratch/working directories
|
|
228
|
+
/.scratch/
|
|
229
|
+
/.worktrees/
|
|
230
|
+
|
|
231
|
+
# References (third-party code for study)
|
|
232
|
+
# Keep tracked but ignore generated content within
|
|
233
|
+
references/**/venv/
|
|
234
|
+
references/**/__pycache__/
|
|
235
|
+
references/**/.venv/
|
|
236
|
+
references/**/node_modules/
|
|
237
|
+
|
|
238
|
+
# ===================
|
|
239
|
+
# Terraform
|
|
240
|
+
# ===================
|
|
241
|
+
*.tfvars
|
|
242
|
+
!*.tfvars.example
|
|
243
|
+
.terraform/
|
|
244
|
+
*.tfstate
|
|
245
|
+
*.tfstate.backup
|
|
246
|
+
.terraform.lock.hcl
|
|
247
|
+
.worktrees/
|
oopstrace-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oopstrace
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Oopstrace Python SDK — LLM observability with automatic batching
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: httpx>=0.24.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# OopsTrace Python SDK
|
|
14
|
+
|
|
15
|
+
LLM observability for Python. Trace every call, span, token count, and cost — visible instantly at [app.oopstrace.com](https://app.oopstrace.com).
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install oopstrace
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import oopstrace
|
|
27
|
+
|
|
28
|
+
oopstrace.init(
|
|
29
|
+
api_key="tr_...", # from app.oopstrace.com → project → Access Keys
|
|
30
|
+
project_id="...", # your project ID
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@oopstrace.trace
|
|
34
|
+
def run_agent(user_input: str) -> str:
|
|
35
|
+
with oopstrace.span("llm-call", span_kind="LLM") as s:
|
|
36
|
+
s.set_output("Hello from OopsTrace")
|
|
37
|
+
return "Hello from OopsTrace"
|
|
38
|
+
|
|
39
|
+
run_agent("test")
|
|
40
|
+
oopstrace.flush()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Auto-instrument OpenAI / Anthropic
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
oopstrace.init(api_key="tr_...", project_id="...", auto_instrument=True)
|
|
47
|
+
|
|
48
|
+
# All openai.chat.completions.create() and anthropic.messages.create()
|
|
49
|
+
# calls are now traced automatically — no decorators needed.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Manual spans
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
@oopstrace.trace
|
|
56
|
+
def pipeline(query: str):
|
|
57
|
+
with oopstrace.span("retrieval", span_kind="TOOL") as s:
|
|
58
|
+
docs = retrieve(query)
|
|
59
|
+
s.set_input(query)
|
|
60
|
+
s.set_output(str(docs))
|
|
61
|
+
|
|
62
|
+
with oopstrace.span("generate", span_kind="LLM") as s:
|
|
63
|
+
answer = llm(query, docs)
|
|
64
|
+
s.set_model("gpt-4o")
|
|
65
|
+
s.set_tokens(input=100, output=50)
|
|
66
|
+
return answer
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Self-hosted
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
oopstrace.init(
|
|
73
|
+
api_key="tr_...",
|
|
74
|
+
project_id="...",
|
|
75
|
+
endpoint="https://app.yourdomain.com", # point at your own server
|
|
76
|
+
)
|
|
77
|
+
```
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# OopsTrace Python SDK
|
|
2
|
+
|
|
3
|
+
LLM observability for Python. Trace every call, span, token count, and cost — visible instantly at [app.oopstrace.com](https://app.oopstrace.com).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install oopstrace
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import oopstrace
|
|
15
|
+
|
|
16
|
+
oopstrace.init(
|
|
17
|
+
api_key="tr_...", # from app.oopstrace.com → project → Access Keys
|
|
18
|
+
project_id="...", # your project ID
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
@oopstrace.trace
|
|
22
|
+
def run_agent(user_input: str) -> str:
|
|
23
|
+
with oopstrace.span("llm-call", span_kind="LLM") as s:
|
|
24
|
+
s.set_output("Hello from OopsTrace")
|
|
25
|
+
return "Hello from OopsTrace"
|
|
26
|
+
|
|
27
|
+
run_agent("test")
|
|
28
|
+
oopstrace.flush()
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Auto-instrument OpenAI / Anthropic
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
oopstrace.init(api_key="tr_...", project_id="...", auto_instrument=True)
|
|
35
|
+
|
|
36
|
+
# All openai.chat.completions.create() and anthropic.messages.create()
|
|
37
|
+
# calls are now traced automatically — no decorators needed.
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Manual spans
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
@oopstrace.trace
|
|
44
|
+
def pipeline(query: str):
|
|
45
|
+
with oopstrace.span("retrieval", span_kind="TOOL") as s:
|
|
46
|
+
docs = retrieve(query)
|
|
47
|
+
s.set_input(query)
|
|
48
|
+
s.set_output(str(docs))
|
|
49
|
+
|
|
50
|
+
with oopstrace.span("generate", span_kind="LLM") as s:
|
|
51
|
+
answer = llm(query, docs)
|
|
52
|
+
s.set_model("gpt-4o")
|
|
53
|
+
s.set_tokens(input=100, output=50)
|
|
54
|
+
return answer
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Self-hosted
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
oopstrace.init(
|
|
61
|
+
api_key="tr_...",
|
|
62
|
+
project_id="...",
|
|
63
|
+
endpoint="https://app.yourdomain.com", # point at your own server
|
|
64
|
+
)
|
|
65
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
OopstraceClient,
|
|
3
|
+
extract_context,
|
|
4
|
+
flush,
|
|
5
|
+
get_prompt,
|
|
6
|
+
init,
|
|
7
|
+
inject_headers,
|
|
8
|
+
span,
|
|
9
|
+
trace,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"OopstraceClient",
|
|
14
|
+
"extract_context",
|
|
15
|
+
"flush",
|
|
16
|
+
"get_prompt",
|
|
17
|
+
"init",
|
|
18
|
+
"inject_headers",
|
|
19
|
+
"span",
|
|
20
|
+
"trace",
|
|
21
|
+
]
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""Oopstrace Python SDK — manual instrumentation with background batching.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
import oopstrace
|
|
5
|
+
|
|
6
|
+
client = oopstrace.OopstraceClient(api_key="tr_...", project_id="my-project")
|
|
7
|
+
|
|
8
|
+
@client.trace
|
|
9
|
+
def my_agent(user_input: str) -> str:
|
|
10
|
+
with client.span("call-llm", span_kind="LLM"):
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
# Or use module-level helpers after init:
|
|
14
|
+
oopstrace.init(api_key="tr_...", project_id="my-project")
|
|
15
|
+
|
|
16
|
+
@oopstrace.trace
|
|
17
|
+
def my_fn(): ...
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import contextlib
|
|
24
|
+
import functools
|
|
25
|
+
import inspect
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import queue
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
import uuid
|
|
33
|
+
from contextlib import contextmanager
|
|
34
|
+
from contextvars import ContextVar
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from datetime import UTC, datetime
|
|
37
|
+
from typing import Any, Callable, Generator
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
_DEFAULT_ENDPOINT = "https://api.oopstrace.dev"
|
|
44
|
+
_FLUSH_INTERVAL = 2.0 # seconds
|
|
45
|
+
_MAX_BATCH = 100
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class _SpanData:
|
|
50
|
+
span_id: str
|
|
51
|
+
trace_id: str
|
|
52
|
+
parent_span_id: str | None
|
|
53
|
+
name: str
|
|
54
|
+
span_kind: str
|
|
55
|
+
start_ns: int
|
|
56
|
+
end_ns: int | None = None
|
|
57
|
+
status: str = "OK"
|
|
58
|
+
status_message: str | None = None
|
|
59
|
+
input: str | None = None
|
|
60
|
+
output: str | None = None
|
|
61
|
+
metadata: dict | None = None
|
|
62
|
+
model_name: str | None = None
|
|
63
|
+
input_tokens: int | None = None
|
|
64
|
+
output_tokens: int | None = None
|
|
65
|
+
user_id: str | None = None
|
|
66
|
+
session_id: str | None = None
|
|
67
|
+
attributes: dict = field(default_factory=dict)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class _TraceContext:
|
|
72
|
+
trace_id: str
|
|
73
|
+
root_span_id: str
|
|
74
|
+
current_span_id: str
|
|
75
|
+
user_id: str | None = None
|
|
76
|
+
session_id: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_ctx_var: ContextVar[_TraceContext | None] = ContextVar("oopstrace_ctx", default=None)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _to_b64(hex_str: str) -> str:
|
|
83
|
+
return base64.b64encode(bytes.fromhex(hex_str)).decode()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _ns_to_str(ns: int) -> str:
|
|
87
|
+
return str(ns)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _build_span_otel(span: _SpanData, project_id: str) -> dict:
|
|
91
|
+
attrs: list[dict] = []
|
|
92
|
+
|
|
93
|
+
def _add(key: str, val: Any) -> None:
|
|
94
|
+
if val is None:
|
|
95
|
+
return
|
|
96
|
+
if isinstance(val, str):
|
|
97
|
+
attrs.append({"key": key, "value": {"stringValue": val}})
|
|
98
|
+
elif isinstance(val, bool):
|
|
99
|
+
attrs.append({"key": key, "value": {"boolValue": val}})
|
|
100
|
+
elif isinstance(val, int):
|
|
101
|
+
attrs.append({"key": key, "value": {"intValue": val}})
|
|
102
|
+
elif isinstance(val, float):
|
|
103
|
+
attrs.append({"key": key, "value": {"doubleValue": val}})
|
|
104
|
+
|
|
105
|
+
_add("oopstrace.span.type", span.span_kind)
|
|
106
|
+
if span.input is not None:
|
|
107
|
+
_add("oopstrace.span.input", span.input)
|
|
108
|
+
if span.output is not None:
|
|
109
|
+
_add("oopstrace.span.output", span.output)
|
|
110
|
+
if span.model_name:
|
|
111
|
+
_add("oopstrace.llm.model", span.model_name)
|
|
112
|
+
if span.input_tokens is not None:
|
|
113
|
+
_add("gen_ai.usage.input_tokens", span.input_tokens)
|
|
114
|
+
if span.output_tokens is not None:
|
|
115
|
+
_add("gen_ai.usage.output_tokens", span.output_tokens)
|
|
116
|
+
if span.user_id:
|
|
117
|
+
_add("user.id", span.user_id)
|
|
118
|
+
if span.session_id:
|
|
119
|
+
_add("session.id", span.session_id)
|
|
120
|
+
if span.metadata:
|
|
121
|
+
_add("oopstrace.span.metadata", json.dumps(span.metadata))
|
|
122
|
+
for k, v in span.attributes.items():
|
|
123
|
+
_add(k, v)
|
|
124
|
+
|
|
125
|
+
otel_span: dict = {
|
|
126
|
+
"traceId": _to_b64(span.trace_id),
|
|
127
|
+
"spanId": _to_b64(span.span_id),
|
|
128
|
+
"name": span.name,
|
|
129
|
+
"kind": "SPAN_KIND_INTERNAL",
|
|
130
|
+
"startTimeUnixNano": _ns_to_str(span.start_ns),
|
|
131
|
+
"endTimeUnixNano": _ns_to_str(span.end_ns or span.start_ns),
|
|
132
|
+
"attributes": attrs,
|
|
133
|
+
"status": {"code": "STATUS_CODE_ERROR" if span.status == "ERROR" else "STATUS_CODE_OK"},
|
|
134
|
+
}
|
|
135
|
+
if span.parent_span_id:
|
|
136
|
+
otel_span["parentSpanId"] = _to_b64(span.parent_span_id)
|
|
137
|
+
if span.status_message:
|
|
138
|
+
otel_span["status"]["message"] = span.status_message
|
|
139
|
+
|
|
140
|
+
return otel_span
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _BatchExporter:
|
|
144
|
+
"""Background daemon thread that drains a queue and ships batches every 2 s."""
|
|
145
|
+
|
|
146
|
+
def __init__(self, endpoint: str, api_key: str, project_id: str) -> None:
|
|
147
|
+
self._endpoint = endpoint.rstrip("/")
|
|
148
|
+
self._api_key = api_key
|
|
149
|
+
self._project_id = project_id
|
|
150
|
+
self._q: queue.Queue[_SpanData] = queue.Queue()
|
|
151
|
+
self._stop = threading.Event()
|
|
152
|
+
self._t = threading.Thread(target=self._run, daemon=True, name="oopstrace-exporter")
|
|
153
|
+
self._t.start()
|
|
154
|
+
|
|
155
|
+
def enqueue(self, span: _SpanData) -> None:
|
|
156
|
+
self._q.put_nowait(span)
|
|
157
|
+
|
|
158
|
+
def flush(self, timeout: float = 5.0) -> None:
|
|
159
|
+
"""Drain queue and send synchronously. Blocks up to *timeout* seconds."""
|
|
160
|
+
deadline = time.monotonic() + timeout
|
|
161
|
+
pending: list[_SpanData] = []
|
|
162
|
+
while time.monotonic() < deadline:
|
|
163
|
+
try:
|
|
164
|
+
pending.append(self._q.get_nowait())
|
|
165
|
+
except queue.Empty:
|
|
166
|
+
break
|
|
167
|
+
if pending:
|
|
168
|
+
self._send(pending)
|
|
169
|
+
|
|
170
|
+
def shutdown(self) -> None:
|
|
171
|
+
self._stop.set()
|
|
172
|
+
self.flush()
|
|
173
|
+
self._t.join(timeout=3.0)
|
|
174
|
+
|
|
175
|
+
def _run(self) -> None:
|
|
176
|
+
while not self._stop.is_set():
|
|
177
|
+
time.sleep(_FLUSH_INTERVAL)
|
|
178
|
+
batch: list[_SpanData] = []
|
|
179
|
+
while len(batch) < _MAX_BATCH:
|
|
180
|
+
try:
|
|
181
|
+
batch.append(self._q.get_nowait())
|
|
182
|
+
except queue.Empty:
|
|
183
|
+
break
|
|
184
|
+
if batch:
|
|
185
|
+
self._send(batch)
|
|
186
|
+
|
|
187
|
+
def _send(self, spans: list[_SpanData]) -> None:
|
|
188
|
+
# Group spans by trace_id
|
|
189
|
+
by_trace: dict[str, list[_SpanData]] = {}
|
|
190
|
+
for s in spans:
|
|
191
|
+
by_trace.setdefault(s.trace_id, []).append(s)
|
|
192
|
+
|
|
193
|
+
scope_spans = [
|
|
194
|
+
{
|
|
195
|
+
"scope": {"name": "oopstrace-sdk", "version": "0.1.0"},
|
|
196
|
+
"spans": [_build_span_otel(s, self._project_id) for s in trace_spans],
|
|
197
|
+
}
|
|
198
|
+
for trace_spans in by_trace.values()
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
payload = {
|
|
202
|
+
"resourceSpans": [
|
|
203
|
+
{
|
|
204
|
+
"resource": {
|
|
205
|
+
"attributes": [
|
|
206
|
+
{"key": "service.name", "value": {"stringValue": self._project_id}}
|
|
207
|
+
]
|
|
208
|
+
},
|
|
209
|
+
"scopeSpans": scope_spans,
|
|
210
|
+
}
|
|
211
|
+
]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
with httpx.Client(timeout=10.0) as client:
|
|
216
|
+
resp = client.post(
|
|
217
|
+
f"{self._endpoint}/api/v1/public/traces",
|
|
218
|
+
json=payload,
|
|
219
|
+
headers={
|
|
220
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
221
|
+
"Content-Type": "application/json",
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
if resp.status_code >= 400:
|
|
225
|
+
logger.warning("Oopstrace export failed %s: %s", resp.status_code, resp.text[:200])
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
logger.warning("Oopstrace export error: %s", exc)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _new_id(bytes_len: int = 16) -> str:
|
|
231
|
+
return uuid.uuid4().bytes[:bytes_len].hex()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class OopstraceClient:
|
|
235
|
+
"""Main SDK client. Create one instance per application."""
|
|
236
|
+
|
|
237
|
+
def __init__(
|
|
238
|
+
self,
|
|
239
|
+
api_key: str | None = None,
|
|
240
|
+
project_id: str | None = None,
|
|
241
|
+
endpoint: str | None = None,
|
|
242
|
+
enabled: bool = True,
|
|
243
|
+
auto_instrument: bool = False,
|
|
244
|
+
) -> None:
|
|
245
|
+
self._api_key = api_key or os.environ.get("OOPSTRACE_API_KEY", "")
|
|
246
|
+
self._project_id = project_id or os.environ.get("OOPSTRACE_PROJECT_ID", "default")
|
|
247
|
+
self._endpoint = endpoint or os.environ.get("OOPSTRACE_ENDPOINT", _DEFAULT_ENDPOINT)
|
|
248
|
+
self._enabled = enabled and bool(self._api_key)
|
|
249
|
+
if self._enabled:
|
|
250
|
+
self._exporter = _BatchExporter(self._endpoint, self._api_key, self._project_id)
|
|
251
|
+
else:
|
|
252
|
+
self._exporter = None
|
|
253
|
+
if auto_instrument:
|
|
254
|
+
from oopstrace.patches import apply_all
|
|
255
|
+
apply_all(self)
|
|
256
|
+
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
# Context managers
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
@contextmanager
|
|
262
|
+
def start_trace(
|
|
263
|
+
self,
|
|
264
|
+
name: str,
|
|
265
|
+
*,
|
|
266
|
+
user_id: str | None = None,
|
|
267
|
+
session_id: str | None = None,
|
|
268
|
+
input: Any = None,
|
|
269
|
+
metadata: dict | None = None,
|
|
270
|
+
remote_context: dict | None = None,
|
|
271
|
+
) -> Generator[_SpanData, None, None]:
|
|
272
|
+
"""Open a root span that defines a new trace.
|
|
273
|
+
|
|
274
|
+
Pass *remote_context* (from :meth:`extract_context`) to continue a
|
|
275
|
+
distributed trace started by another agent process. The local root
|
|
276
|
+
span will be inserted as a child of the remote span while sharing the
|
|
277
|
+
same ``trace_id``, so all agents appear in one unified causal graph.
|
|
278
|
+
"""
|
|
279
|
+
if remote_context:
|
|
280
|
+
trace_id = remote_context["trace_id"]
|
|
281
|
+
parent_span_id: str | None = remote_context.get("parent_span_id")
|
|
282
|
+
else:
|
|
283
|
+
trace_id = _new_id(16)
|
|
284
|
+
parent_span_id = None
|
|
285
|
+
|
|
286
|
+
span_id = _new_id(8)
|
|
287
|
+
start_ns = time.time_ns()
|
|
288
|
+
ctx = _TraceContext(
|
|
289
|
+
trace_id=trace_id,
|
|
290
|
+
root_span_id=span_id,
|
|
291
|
+
current_span_id=span_id,
|
|
292
|
+
user_id=user_id,
|
|
293
|
+
session_id=session_id,
|
|
294
|
+
)
|
|
295
|
+
tok = _ctx_var.set(ctx)
|
|
296
|
+
span = _SpanData(
|
|
297
|
+
span_id=span_id,
|
|
298
|
+
trace_id=trace_id,
|
|
299
|
+
parent_span_id=parent_span_id,
|
|
300
|
+
name=name,
|
|
301
|
+
span_kind="SPAN",
|
|
302
|
+
start_ns=start_ns,
|
|
303
|
+
user_id=user_id,
|
|
304
|
+
session_id=session_id,
|
|
305
|
+
input=json.dumps(input) if input is not None and not isinstance(input, str) else input,
|
|
306
|
+
metadata=metadata,
|
|
307
|
+
)
|
|
308
|
+
try:
|
|
309
|
+
yield span
|
|
310
|
+
except Exception as exc:
|
|
311
|
+
span.status = "ERROR"
|
|
312
|
+
span.status_message = str(exc)
|
|
313
|
+
raise
|
|
314
|
+
finally:
|
|
315
|
+
span.end_ns = time.time_ns()
|
|
316
|
+
_ctx_var.reset(tok)
|
|
317
|
+
if self._exporter:
|
|
318
|
+
self._exporter.enqueue(span)
|
|
319
|
+
|
|
320
|
+
@contextmanager
|
|
321
|
+
def start_span(
|
|
322
|
+
self,
|
|
323
|
+
name: str,
|
|
324
|
+
*,
|
|
325
|
+
span_kind: str = "SPAN",
|
|
326
|
+
input: Any = None,
|
|
327
|
+
output: Any = None,
|
|
328
|
+
model_name: str | None = None,
|
|
329
|
+
input_tokens: int | None = None,
|
|
330
|
+
output_tokens: int | None = None,
|
|
331
|
+
metadata: dict | None = None,
|
|
332
|
+
attributes: dict | None = None,
|
|
333
|
+
) -> Generator[_SpanData, None, None]:
|
|
334
|
+
"""Open a child span within the current trace."""
|
|
335
|
+
ctx = _ctx_var.get()
|
|
336
|
+
if ctx is None:
|
|
337
|
+
# Auto-create a trace if called outside a trace context
|
|
338
|
+
with self.start_trace(name) as root_span:
|
|
339
|
+
yield root_span
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
span_id = _new_id(8)
|
|
343
|
+
parent_id = ctx.current_span_id
|
|
344
|
+
old_current = ctx.current_span_id
|
|
345
|
+
ctx.current_span_id = span_id
|
|
346
|
+
start_ns = time.time_ns()
|
|
347
|
+
|
|
348
|
+
def _enc(v: Any) -> str | None:
|
|
349
|
+
if v is None:
|
|
350
|
+
return None
|
|
351
|
+
return json.dumps(v) if not isinstance(v, str) else v
|
|
352
|
+
|
|
353
|
+
span = _SpanData(
|
|
354
|
+
span_id=span_id,
|
|
355
|
+
trace_id=ctx.trace_id,
|
|
356
|
+
parent_span_id=parent_id,
|
|
357
|
+
name=name,
|
|
358
|
+
span_kind=span_kind.upper(),
|
|
359
|
+
start_ns=start_ns,
|
|
360
|
+
input=_enc(input),
|
|
361
|
+
output=_enc(output),
|
|
362
|
+
model_name=model_name,
|
|
363
|
+
input_tokens=input_tokens,
|
|
364
|
+
output_tokens=output_tokens,
|
|
365
|
+
metadata=metadata,
|
|
366
|
+
attributes=attributes or {},
|
|
367
|
+
user_id=ctx.user_id,
|
|
368
|
+
session_id=ctx.session_id,
|
|
369
|
+
)
|
|
370
|
+
try:
|
|
371
|
+
yield span
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
span.status = "ERROR"
|
|
374
|
+
span.status_message = str(exc)
|
|
375
|
+
raise
|
|
376
|
+
finally:
|
|
377
|
+
span.end_ns = time.time_ns()
|
|
378
|
+
ctx.current_span_id = old_current
|
|
379
|
+
if self._exporter:
|
|
380
|
+
self._exporter.enqueue(span)
|
|
381
|
+
|
|
382
|
+
# ------------------------------------------------------------------
|
|
383
|
+
# Decorators
|
|
384
|
+
# ------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
def trace(
|
|
387
|
+
self,
|
|
388
|
+
fn: Callable | None = None,
|
|
389
|
+
*,
|
|
390
|
+
name: str | None = None,
|
|
391
|
+
user_id: str | None = None,
|
|
392
|
+
session_id: str | None = None,
|
|
393
|
+
):
|
|
394
|
+
"""Decorator: wrap the function in a root trace span."""
|
|
395
|
+
|
|
396
|
+
def _decorator(f: Callable) -> Callable:
|
|
397
|
+
trace_name = name or f.__name__
|
|
398
|
+
|
|
399
|
+
@functools.wraps(f)
|
|
400
|
+
def _sync_wrapper(*args, **kwargs):
|
|
401
|
+
with self.start_trace(trace_name, user_id=user_id, session_id=session_id) as s:
|
|
402
|
+
result = f(*args, **kwargs)
|
|
403
|
+
if isinstance(result, str):
|
|
404
|
+
s.output = result
|
|
405
|
+
return result
|
|
406
|
+
|
|
407
|
+
@functools.wraps(f)
|
|
408
|
+
async def _async_wrapper(*args, **kwargs):
|
|
409
|
+
with self.start_trace(trace_name, user_id=user_id, session_id=session_id) as s:
|
|
410
|
+
result = await f(*args, **kwargs)
|
|
411
|
+
if isinstance(result, str):
|
|
412
|
+
s.output = result
|
|
413
|
+
return result
|
|
414
|
+
|
|
415
|
+
return _async_wrapper if inspect.iscoroutinefunction(f) else _sync_wrapper
|
|
416
|
+
|
|
417
|
+
return _decorator(fn) if fn is not None else _decorator
|
|
418
|
+
|
|
419
|
+
def span(
|
|
420
|
+
self,
|
|
421
|
+
fn: Callable | None = None,
|
|
422
|
+
*,
|
|
423
|
+
name: str | None = None,
|
|
424
|
+
span_kind: str = "SPAN",
|
|
425
|
+
):
|
|
426
|
+
"""Decorator: wrap the function in a child span."""
|
|
427
|
+
|
|
428
|
+
def _decorator(f: Callable) -> Callable:
|
|
429
|
+
span_name = name or f.__name__
|
|
430
|
+
|
|
431
|
+
@functools.wraps(f)
|
|
432
|
+
def _sync_wrapper(*args, **kwargs):
|
|
433
|
+
with self.start_span(span_name, span_kind=span_kind) as s:
|
|
434
|
+
result = f(*args, **kwargs)
|
|
435
|
+
if isinstance(result, str):
|
|
436
|
+
s.output = result
|
|
437
|
+
return result
|
|
438
|
+
|
|
439
|
+
@functools.wraps(f)
|
|
440
|
+
async def _async_wrapper(*args, **kwargs):
|
|
441
|
+
with self.start_span(span_name, span_kind=span_kind) as s:
|
|
442
|
+
result = await f(*args, **kwargs)
|
|
443
|
+
if isinstance(result, str):
|
|
444
|
+
s.output = result
|
|
445
|
+
return result
|
|
446
|
+
|
|
447
|
+
return _async_wrapper if inspect.iscoroutinefunction(f) else _sync_wrapper
|
|
448
|
+
|
|
449
|
+
return _decorator(fn) if fn is not None else _decorator
|
|
450
|
+
|
|
451
|
+
# ------------------------------------------------------------------
|
|
452
|
+
# Distributed trace propagation (W3C traceparent)
|
|
453
|
+
# ------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
def inject_headers(self, headers: dict | None = None) -> dict:
|
|
456
|
+
"""Inject W3C ``traceparent`` header into *headers* for an outgoing call.
|
|
457
|
+
|
|
458
|
+
Use this before making an HTTP request to another agent so the remote
|
|
459
|
+
process can link its spans into the same causal graph::
|
|
460
|
+
|
|
461
|
+
headers = client.inject_headers({"Content-Type": "application/json"})
|
|
462
|
+
httpx.post(agent_url, json=payload, headers=headers)
|
|
463
|
+
"""
|
|
464
|
+
headers = dict(headers) if headers else {}
|
|
465
|
+
ctx = _ctx_var.get()
|
|
466
|
+
if ctx:
|
|
467
|
+
headers["traceparent"] = f"00-{ctx.trace_id}-{ctx.current_span_id}-01"
|
|
468
|
+
return headers
|
|
469
|
+
|
|
470
|
+
def extract_context(self, headers: dict) -> dict | None:
|
|
471
|
+
"""Parse a W3C ``traceparent`` header from an incoming request.
|
|
472
|
+
|
|
473
|
+
Returns a ``remote_context`` dict ready to pass to :meth:`start_trace`,
|
|
474
|
+
or ``None`` if no valid header is present::
|
|
475
|
+
|
|
476
|
+
ctx = client.extract_context(request.headers)
|
|
477
|
+
with client.start_trace("AgentB", remote_context=ctx) as span:
|
|
478
|
+
...
|
|
479
|
+
"""
|
|
480
|
+
tp = headers.get("traceparent") or headers.get("Traceparent")
|
|
481
|
+
if not tp:
|
|
482
|
+
return None
|
|
483
|
+
parts = tp.split("-")
|
|
484
|
+
if len(parts) != 4:
|
|
485
|
+
return None
|
|
486
|
+
return {"trace_id": parts[1], "parent_span_id": parts[2]}
|
|
487
|
+
|
|
488
|
+
# ------------------------------------------------------------------
|
|
489
|
+
# Prompt management
|
|
490
|
+
# ------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
def get_prompt(self, slug: str, version: int | None = None) -> str | None:
|
|
493
|
+
"""Fetch a prompt template from the Prompt CMS."""
|
|
494
|
+
params = {} if version is None else {"version": version}
|
|
495
|
+
try:
|
|
496
|
+
with httpx.Client(timeout=5.0) as client:
|
|
497
|
+
resp = client.get(
|
|
498
|
+
f"{self._endpoint}/api/v1/public/prompts/{slug}",
|
|
499
|
+
params=params,
|
|
500
|
+
headers={"Authorization": f"Bearer {self._api_key}"},
|
|
501
|
+
)
|
|
502
|
+
if resp.status_code == 200:
|
|
503
|
+
return resp.json().get("template")
|
|
504
|
+
if resp.status_code == 404:
|
|
505
|
+
return None
|
|
506
|
+
except Exception as exc:
|
|
507
|
+
logger.warning("Oopstrace get_prompt error: %s", exc)
|
|
508
|
+
return None
|
|
509
|
+
|
|
510
|
+
def flush(self, timeout: float = 5.0) -> None:
|
|
511
|
+
"""Block until all pending spans are exported."""
|
|
512
|
+
if self._exporter:
|
|
513
|
+
self._exporter.flush(timeout=timeout)
|
|
514
|
+
|
|
515
|
+
def shutdown(self) -> None:
|
|
516
|
+
"""Flush and stop the background exporter thread."""
|
|
517
|
+
if self._exporter:
|
|
518
|
+
self._exporter.shutdown()
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# ---------------------------------------------------------------------------
|
|
522
|
+
# Module-level convenience API
|
|
523
|
+
# ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
_default_client: OopstraceClient | None = None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def init(
|
|
529
|
+
api_key: str | None = None,
|
|
530
|
+
project_id: str | None = None,
|
|
531
|
+
endpoint: str | None = None,
|
|
532
|
+
auto_instrument: bool = False,
|
|
533
|
+
) -> OopstraceClient:
|
|
534
|
+
"""Initialize the module-level default client.
|
|
535
|
+
|
|
536
|
+
Set ``auto_instrument=True`` to automatically patch OpenAI and Anthropic
|
|
537
|
+
SDK calls — no decorators needed for basic LLM tracing.
|
|
538
|
+
"""
|
|
539
|
+
global _default_client
|
|
540
|
+
_default_client = OopstraceClient(
|
|
541
|
+
api_key=api_key,
|
|
542
|
+
project_id=project_id,
|
|
543
|
+
endpoint=endpoint,
|
|
544
|
+
auto_instrument=auto_instrument,
|
|
545
|
+
)
|
|
546
|
+
return _default_client
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _get_client() -> OopstraceClient:
|
|
550
|
+
global _default_client
|
|
551
|
+
if _default_client is None:
|
|
552
|
+
_default_client = OopstraceClient()
|
|
553
|
+
return _default_client
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def trace(fn: Callable | None = None, *, name: str | None = None, user_id: str | None = None, session_id: str | None = None):
|
|
557
|
+
return _get_client().trace(fn, name=name, user_id=user_id, session_id=session_id)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def span(fn: Callable | None = None, *, name: str | None = None, span_kind: str = "SPAN"):
|
|
561
|
+
return _get_client().span(fn, name=name, span_kind=span_kind)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def get_prompt(slug: str, version: int | None = None) -> str | None:
|
|
565
|
+
return _get_client().get_prompt(slug, version=version)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def flush(timeout: float = 5.0) -> None:
|
|
569
|
+
_get_client().flush(timeout=timeout)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def inject_headers(headers: dict | None = None) -> dict:
|
|
573
|
+
return _get_client().inject_headers(headers)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def extract_context(headers: dict) -> dict | None:
|
|
577
|
+
return _get_client().extract_context(headers)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Monkey-patching registry for auto-instrumentation.
|
|
2
|
+
|
|
3
|
+
Call apply_all(client) to patch every SDK that is installed.
|
|
4
|
+
Individual patch functions are safe to call when the target library is absent.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from oopstrace.client import OopstraceClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def apply_all(client: "OopstraceClient") -> None:
|
|
16
|
+
"""Apply all available patches against *client*."""
|
|
17
|
+
from oopstrace.patches._anthropic import patch_anthropic
|
|
18
|
+
from oopstrace.patches._openai import patch_openai
|
|
19
|
+
|
|
20
|
+
patch_openai(client)
|
|
21
|
+
patch_anthropic(client)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Auto-instrumentation patch for the anthropic SDK.
|
|
2
|
+
|
|
3
|
+
Wraps `Messages.create` and `AsyncMessages.create` so that every Anthropic
|
|
4
|
+
call is automatically captured as a child LLM span — no decorators required.
|
|
5
|
+
|
|
6
|
+
The patch is idempotent: calling patch_anthropic() twice is safe.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from oopstrace.client import OopstraceClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _extract_last_user_text(messages: list[dict]) -> str:
|
|
19
|
+
"""Pull the text content out of the last user message."""
|
|
20
|
+
for msg in reversed(messages):
|
|
21
|
+
if msg.get("role") == "user":
|
|
22
|
+
content = msg.get("content", "")
|
|
23
|
+
if isinstance(content, str):
|
|
24
|
+
return content
|
|
25
|
+
if isinstance(content, list):
|
|
26
|
+
for block in content:
|
|
27
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
28
|
+
return block.get("text", "")
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def patch_anthropic(client: "OopstraceClient") -> None:
|
|
33
|
+
"""Monkey-patch anthropic SDK classes to emit LLM spans automatically."""
|
|
34
|
+
try:
|
|
35
|
+
from anthropic.resources.messages import AsyncMessages, Messages
|
|
36
|
+
except ImportError:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if getattr(Messages, "_oopstrace_patched", False):
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
_orig_sync = Messages.create
|
|
43
|
+
_orig_async = AsyncMessages.create
|
|
44
|
+
|
|
45
|
+
@functools.wraps(_orig_sync)
|
|
46
|
+
def _sync_create(self_m, *args: Any, **kwargs: Any):
|
|
47
|
+
model = kwargs.get("model") or ""
|
|
48
|
+
messages = kwargs.get("messages") or []
|
|
49
|
+
inp = _extract_last_user_text(messages)
|
|
50
|
+
|
|
51
|
+
with client.start_span("anthropic.messages", span_kind="LLM", input=inp, model_name=model) as s:
|
|
52
|
+
result = _orig_sync(self_m, *args, **kwargs)
|
|
53
|
+
if hasattr(result, "usage") and result.usage:
|
|
54
|
+
s.input_tokens = result.usage.input_tokens
|
|
55
|
+
s.output_tokens = result.usage.output_tokens
|
|
56
|
+
if hasattr(result, "content") and result.content:
|
|
57
|
+
first = result.content[0]
|
|
58
|
+
if hasattr(first, "text"):
|
|
59
|
+
s.output = first.text
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
@functools.wraps(_orig_async)
|
|
63
|
+
async def _async_create(self_m, *args: Any, **kwargs: Any):
|
|
64
|
+
model = kwargs.get("model") or ""
|
|
65
|
+
messages = kwargs.get("messages") or []
|
|
66
|
+
inp = _extract_last_user_text(messages)
|
|
67
|
+
|
|
68
|
+
with client.start_span("anthropic.messages", span_kind="LLM", input=inp, model_name=model) as s:
|
|
69
|
+
result = await _orig_async(self_m, *args, **kwargs)
|
|
70
|
+
if hasattr(result, "usage") and result.usage:
|
|
71
|
+
s.input_tokens = result.usage.input_tokens
|
|
72
|
+
s.output_tokens = result.usage.output_tokens
|
|
73
|
+
if hasattr(result, "content") and result.content:
|
|
74
|
+
first = result.content[0]
|
|
75
|
+
if hasattr(first, "text"):
|
|
76
|
+
s.output = first.text
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
Messages.create = _sync_create
|
|
80
|
+
AsyncMessages.create = _async_create
|
|
81
|
+
Messages._oopstrace_patched = True
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Auto-instrumentation patch for the openai SDK (>= 1.0).
|
|
2
|
+
|
|
3
|
+
Wraps `Completions.create` and `AsyncCompletions.create` so that every chat
|
|
4
|
+
call is automatically captured as a child LLM span — no decorators required.
|
|
5
|
+
|
|
6
|
+
The patch is idempotent: calling patch_openai() twice is safe.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from oopstrace.client import OopstraceClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def patch_openai(client: "OopstraceClient") -> None:
|
|
19
|
+
"""Monkey-patch openai SDK classes to emit LLM spans automatically."""
|
|
20
|
+
try:
|
|
21
|
+
from openai.resources.chat.completions import AsyncCompletions, Completions
|
|
22
|
+
except ImportError:
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
if getattr(Completions, "_oopstrace_patched", False):
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
_orig_sync = Completions.create
|
|
29
|
+
_orig_async = AsyncCompletions.create
|
|
30
|
+
|
|
31
|
+
@functools.wraps(_orig_sync)
|
|
32
|
+
def _sync_create(self_c, *args: Any, **kwargs: Any):
|
|
33
|
+
model = kwargs.get("model") or ""
|
|
34
|
+
messages = kwargs.get("messages") or []
|
|
35
|
+
inp = (messages[-1].get("content") or "") if messages else ""
|
|
36
|
+
|
|
37
|
+
with client.start_span("openai.chat", span_kind="LLM", input=inp, model_name=model) as s:
|
|
38
|
+
result = _orig_sync(self_c, *args, **kwargs)
|
|
39
|
+
if hasattr(result, "usage") and result.usage:
|
|
40
|
+
s.input_tokens = result.usage.prompt_tokens
|
|
41
|
+
s.output_tokens = result.usage.completion_tokens
|
|
42
|
+
if hasattr(result, "choices") and result.choices:
|
|
43
|
+
content = result.choices[0].message.content
|
|
44
|
+
if content:
|
|
45
|
+
s.output = content
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
@functools.wraps(_orig_async)
|
|
49
|
+
async def _async_create(self_c, *args: Any, **kwargs: Any):
|
|
50
|
+
model = kwargs.get("model") or ""
|
|
51
|
+
messages = kwargs.get("messages") or []
|
|
52
|
+
inp = (messages[-1].get("content") or "") if messages else ""
|
|
53
|
+
|
|
54
|
+
with client.start_span("openai.chat", span_kind="LLM", input=inp, model_name=model) as s:
|
|
55
|
+
result = await _orig_async(self_c, *args, **kwargs)
|
|
56
|
+
if hasattr(result, "usage") and result.usage:
|
|
57
|
+
s.input_tokens = result.usage.prompt_tokens
|
|
58
|
+
s.output_tokens = result.usage.completion_tokens
|
|
59
|
+
if hasattr(result, "choices") and result.choices:
|
|
60
|
+
content = result.choices[0].message.content
|
|
61
|
+
if content:
|
|
62
|
+
s.output = content
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
Completions.create = _sync_create
|
|
66
|
+
AsyncCompletions.create = _async_create
|
|
67
|
+
Completions._oopstrace_patched = True
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "oopstrace"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Oopstrace Python SDK — LLM observability with automatic batching"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.24.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest", "pytest-asyncio"]
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["oopstrace"]
|