logspine 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.
- logspine-0.1.0/.gitignore +52 -0
- logspine-0.1.0/LICENSE +21 -0
- logspine-0.1.0/MANIFEST.in +3 -0
- logspine-0.1.0/PKG-INFO +109 -0
- logspine-0.1.0/README.md +92 -0
- logspine-0.1.0/logspine/__init__.py +4 -0
- logspine-0.1.0/logspine/_version.py +1 -0
- logspine-0.1.0/logspine/client.py +117 -0
- logspine-0.1.0/logspine/instrumentation/__init__.py +0 -0
- logspine-0.1.0/logspine/instrumentation/anthropic.py +109 -0
- logspine-0.1.0/logspine/instrumentation/openai.py +106 -0
- logspine-0.1.0/pyproject.toml +22 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# dependencies
|
|
2
|
+
node_modules/
|
|
3
|
+
.pnpm-store/
|
|
4
|
+
|
|
5
|
+
# build outputs
|
|
6
|
+
dist/
|
|
7
|
+
.next/
|
|
8
|
+
.turbo/
|
|
9
|
+
out/
|
|
10
|
+
build/
|
|
11
|
+
|
|
12
|
+
# env
|
|
13
|
+
.env
|
|
14
|
+
.env.local
|
|
15
|
+
.env.*.local
|
|
16
|
+
|
|
17
|
+
# logs
|
|
18
|
+
npm-debug.log*
|
|
19
|
+
yarn-debug.log*
|
|
20
|
+
yarn-error.log*
|
|
21
|
+
pnpm-debug.log*
|
|
22
|
+
|
|
23
|
+
# OS
|
|
24
|
+
.DS_Store
|
|
25
|
+
Thumbs.db
|
|
26
|
+
|
|
27
|
+
# editor
|
|
28
|
+
.vscode/
|
|
29
|
+
.idea/
|
|
30
|
+
*.swp
|
|
31
|
+
|
|
32
|
+
# wrangler / CF
|
|
33
|
+
.wrangler/
|
|
34
|
+
.dev.vars
|
|
35
|
+
|
|
36
|
+
# tsbuildinfo
|
|
37
|
+
*.tsbuildinfo
|
|
38
|
+
|
|
39
|
+
# coverage
|
|
40
|
+
coverage/
|
|
41
|
+
|
|
42
|
+
# Tinybird CLI local state (contains admin token!)
|
|
43
|
+
.tinyb
|
|
44
|
+
.tinyenv
|
|
45
|
+
|
|
46
|
+
# pnpm temp files from interrupted installs
|
|
47
|
+
_tmp_*
|
|
48
|
+
|
|
49
|
+
# outreach tool outputs (may contain PII, large files)
|
|
50
|
+
tools/helicone-stargazers-raw.json
|
|
51
|
+
tools/helicone-stargazers-filtered.json
|
|
52
|
+
tools/helicone-stargazers-filtered.csv
|
logspine-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adam Kallen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
logspine-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: logspine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in observability for AI agents — Python SDK
|
|
5
|
+
Project-URL: Homepage, https://www.logspine.dev
|
|
6
|
+
Project-URL: Documentation, https://www.logspine.dev/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/logspine-dev/logspine
|
|
8
|
+
Author-email: Adam Kallen <hello@logspine.dev>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Provides-Extra: anthropic
|
|
13
|
+
Requires-Dist: anthropic>=0.25.0; extra == 'anthropic'
|
|
14
|
+
Provides-Extra: openai
|
|
15
|
+
Requires-Dist: openai>=1.0.0; extra == 'openai'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# logspine · Python SDK
|
|
19
|
+
|
|
20
|
+
Drop-in observability for AI agents. Track every OpenAI and Anthropic call
|
|
21
|
+
with cost, tokens, and latency — no code changes to your prompts.
|
|
22
|
+
|
|
23
|
+
Full docs: [logspine.dev/docs](https://www.logspine.dev/docs)
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install logspine
|
|
31
|
+
|
|
32
|
+
# with OpenAI support
|
|
33
|
+
pip install "logspine[openai]"
|
|
34
|
+
|
|
35
|
+
# with Anthropic support
|
|
36
|
+
pip install "logspine[anthropic]"
|
|
37
|
+
|
|
38
|
+
# both
|
|
39
|
+
pip install "logspine[openai,anthropic]"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Quick start — OpenAI
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import openai
|
|
48
|
+
from logspine import LogspineClient
|
|
49
|
+
|
|
50
|
+
logspine = LogspineClient(api_key="lsk_live_...")
|
|
51
|
+
client = logspine.instrument_openai(openai.OpenAI())
|
|
52
|
+
|
|
53
|
+
# Every call is now tracked automatically
|
|
54
|
+
response = client.chat.completions.create(
|
|
55
|
+
model="gpt-4o-mini",
|
|
56
|
+
messages=[{"role": "user", "content": "Say hello in five words."}],
|
|
57
|
+
)
|
|
58
|
+
print(response.choices[0].message.content)
|
|
59
|
+
|
|
60
|
+
logspine.flush() # call before process exit
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Quick start — Anthropic
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import anthropic
|
|
69
|
+
from logspine import LogspineClient
|
|
70
|
+
|
|
71
|
+
logspine = LogspineClient(api_key="lsk_live_...")
|
|
72
|
+
client = logspine.instrument_anthropic(anthropic.Anthropic())
|
|
73
|
+
|
|
74
|
+
response = client.messages.create(
|
|
75
|
+
model="claude-sonnet-4-5",
|
|
76
|
+
max_tokens=256,
|
|
77
|
+
messages=[{"role": "user", "content": "Say hello in five words."}],
|
|
78
|
+
)
|
|
79
|
+
print(response.content[0].text)
|
|
80
|
+
|
|
81
|
+
logspine.flush()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Optional span metadata
|
|
87
|
+
|
|
88
|
+
Pass a `logspine` dict as an extra kwarg to tag individual calls:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
client.chat.completions.create(
|
|
92
|
+
model="gpt-4o",
|
|
93
|
+
messages=[...],
|
|
94
|
+
logspine={
|
|
95
|
+
"trace_id": "my-session-abc123",
|
|
96
|
+
"name": "summarise_document",
|
|
97
|
+
"user_id": "usr_42",
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Links
|
|
105
|
+
|
|
106
|
+
- [Docs](https://www.logspine.dev/docs)
|
|
107
|
+
- [Dashboard](https://www.logspine.dev/dashboard)
|
|
108
|
+
- [Pricing](https://www.logspine.dev/pricing)
|
|
109
|
+
- [GitHub](https://github.com/logspine-dev/logspine)
|
logspine-0.1.0/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# logspine · Python SDK
|
|
2
|
+
|
|
3
|
+
Drop-in observability for AI agents. Track every OpenAI and Anthropic call
|
|
4
|
+
with cost, tokens, and latency — no code changes to your prompts.
|
|
5
|
+
|
|
6
|
+
Full docs: [logspine.dev/docs](https://www.logspine.dev/docs)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install logspine
|
|
14
|
+
|
|
15
|
+
# with OpenAI support
|
|
16
|
+
pip install "logspine[openai]"
|
|
17
|
+
|
|
18
|
+
# with Anthropic support
|
|
19
|
+
pip install "logspine[anthropic]"
|
|
20
|
+
|
|
21
|
+
# both
|
|
22
|
+
pip install "logspine[openai,anthropic]"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Quick start — OpenAI
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import openai
|
|
31
|
+
from logspine import LogspineClient
|
|
32
|
+
|
|
33
|
+
logspine = LogspineClient(api_key="lsk_live_...")
|
|
34
|
+
client = logspine.instrument_openai(openai.OpenAI())
|
|
35
|
+
|
|
36
|
+
# Every call is now tracked automatically
|
|
37
|
+
response = client.chat.completions.create(
|
|
38
|
+
model="gpt-4o-mini",
|
|
39
|
+
messages=[{"role": "user", "content": "Say hello in five words."}],
|
|
40
|
+
)
|
|
41
|
+
print(response.choices[0].message.content)
|
|
42
|
+
|
|
43
|
+
logspine.flush() # call before process exit
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Quick start — Anthropic
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import anthropic
|
|
52
|
+
from logspine import LogspineClient
|
|
53
|
+
|
|
54
|
+
logspine = LogspineClient(api_key="lsk_live_...")
|
|
55
|
+
client = logspine.instrument_anthropic(anthropic.Anthropic())
|
|
56
|
+
|
|
57
|
+
response = client.messages.create(
|
|
58
|
+
model="claude-sonnet-4-5",
|
|
59
|
+
max_tokens=256,
|
|
60
|
+
messages=[{"role": "user", "content": "Say hello in five words."}],
|
|
61
|
+
)
|
|
62
|
+
print(response.content[0].text)
|
|
63
|
+
|
|
64
|
+
logspine.flush()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Optional span metadata
|
|
70
|
+
|
|
71
|
+
Pass a `logspine` dict as an extra kwarg to tag individual calls:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
client.chat.completions.create(
|
|
75
|
+
model="gpt-4o",
|
|
76
|
+
messages=[...],
|
|
77
|
+
logspine={
|
|
78
|
+
"trace_id": "my-session-abc123",
|
|
79
|
+
"name": "summarise_document",
|
|
80
|
+
"user_id": "usr_42",
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Links
|
|
88
|
+
|
|
89
|
+
- [Docs](https://www.logspine.dev/docs)
|
|
90
|
+
- [Dashboard](https://www.logspine.dev/dashboard)
|
|
91
|
+
- [Pricing](https://www.logspine.dev/pricing)
|
|
92
|
+
- [GitHub](https://github.com/logspine-dev/logspine)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Logspine Python SDK — AI agent observability.
|
|
3
|
+
https://www.logspine.dev
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
import json
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
|
|
14
|
+
DEFAULT_ENDPOINT = "https://ingest.logspine.dev"
|
|
15
|
+
FLUSH_INTERVAL = 2.0 # seconds
|
|
16
|
+
MAX_BATCH_SIZE = 100
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LogspineClient:
|
|
20
|
+
"""
|
|
21
|
+
Drop-in observability client for AI agents.
|
|
22
|
+
|
|
23
|
+
Usage::
|
|
24
|
+
|
|
25
|
+
from logspine import LogspineClient
|
|
26
|
+
import openai
|
|
27
|
+
|
|
28
|
+
logspine = LogspineClient(api_key="lsk_live_...")
|
|
29
|
+
client = logspine.instrument_openai(openai.OpenAI())
|
|
30
|
+
|
|
31
|
+
# Every call is now tracked
|
|
32
|
+
response = client.chat.completions.create(...)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: str,
|
|
38
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
39
|
+
flush_interval: float = FLUSH_INTERVAL,
|
|
40
|
+
) -> None:
|
|
41
|
+
if not api_key:
|
|
42
|
+
raise ValueError("api_key is required")
|
|
43
|
+
self._api_key = api_key
|
|
44
|
+
self._endpoint = endpoint.rstrip("/")
|
|
45
|
+
self._queue: list[dict[str, Any]] = []
|
|
46
|
+
self._lock = threading.Lock()
|
|
47
|
+
self._flush_interval = flush_interval
|
|
48
|
+
self._timer: Optional[threading.Timer] = None
|
|
49
|
+
self._schedule_flush()
|
|
50
|
+
|
|
51
|
+
# ── Instrumentation ──────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
def instrument_openai(self, client: Any) -> Any:
|
|
54
|
+
"""Wrap an OpenAI client to track every call automatically."""
|
|
55
|
+
from .instrumentation.openai import instrument
|
|
56
|
+
return instrument(client, self)
|
|
57
|
+
|
|
58
|
+
def instrument_anthropic(self, client: Any) -> Any:
|
|
59
|
+
"""Wrap an Anthropic client to track every call automatically."""
|
|
60
|
+
from .instrumentation.anthropic import instrument
|
|
61
|
+
return instrument(client, self)
|
|
62
|
+
|
|
63
|
+
# ── Span tracking ────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def _record_span(self, span: dict[str, Any]) -> None:
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._queue.append(span)
|
|
68
|
+
if len(self._queue) >= MAX_BATCH_SIZE:
|
|
69
|
+
self._flush_locked()
|
|
70
|
+
|
|
71
|
+
def flush(self) -> None:
|
|
72
|
+
"""Send all buffered spans immediately. Call before process exit."""
|
|
73
|
+
with self._lock:
|
|
74
|
+
self._flush_locked()
|
|
75
|
+
|
|
76
|
+
def _flush_locked(self) -> None:
|
|
77
|
+
if not self._queue:
|
|
78
|
+
return
|
|
79
|
+
batch = self._queue[:]
|
|
80
|
+
self._queue.clear()
|
|
81
|
+
# Fire and forget in a thread to not block the caller
|
|
82
|
+
threading.Thread(target=self._send, args=(batch,), daemon=True).start()
|
|
83
|
+
|
|
84
|
+
def _schedule_flush(self) -> None:
|
|
85
|
+
self._timer = threading.Timer(self._flush_interval, self._auto_flush)
|
|
86
|
+
self._timer.daemon = True
|
|
87
|
+
self._timer.start()
|
|
88
|
+
|
|
89
|
+
def _auto_flush(self) -> None:
|
|
90
|
+
self.flush()
|
|
91
|
+
self._schedule_flush()
|
|
92
|
+
|
|
93
|
+
def _send(self, spans: list[dict[str, Any]]) -> None:
|
|
94
|
+
import urllib.request
|
|
95
|
+
import urllib.error
|
|
96
|
+
|
|
97
|
+
payload = json.dumps({"spans": spans}).encode()
|
|
98
|
+
req = urllib.request.Request(
|
|
99
|
+
f"{self._endpoint}/v1/ingest",
|
|
100
|
+
data=payload,
|
|
101
|
+
headers={
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
"x-logspine-key": self._api_key,
|
|
104
|
+
"x-logspine-sdk": f"python/{__version__}",
|
|
105
|
+
},
|
|
106
|
+
method="POST",
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
with urllib.request.urlopen(req, timeout=5):
|
|
110
|
+
pass
|
|
111
|
+
except Exception:
|
|
112
|
+
pass # Never raise — observability must never break the app
|
|
113
|
+
|
|
114
|
+
def __del__(self) -> None:
|
|
115
|
+
if self._timer:
|
|
116
|
+
self._timer.cancel()
|
|
117
|
+
self.flush()
|
|
File without changes
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Anthropic instrumentation for Logspine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ..client import LogspineClient
|
|
11
|
+
|
|
12
|
+
# Cost per 1M tokens in USD (as of mid-2026)
|
|
13
|
+
PRICING: dict[str, dict[str, float]] = {
|
|
14
|
+
"claude-opus-4": {"input": 15.00, "output": 75.00},
|
|
15
|
+
"claude-sonnet-4": {"input": 3.00, "output": 15.00},
|
|
16
|
+
"claude-3-5-sonnet": {"input": 3.00, "output": 15.00},
|
|
17
|
+
"claude-3-5-haiku": {"input": 0.80, "output": 4.00},
|
|
18
|
+
"claude-3-haiku": {"input": 0.25, "output": 1.25},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cost(model: str, input_tokens: int, output_tokens: int) -> float:
|
|
23
|
+
key = next((k for k in PRICING if model.startswith(k)), None)
|
|
24
|
+
if not key:
|
|
25
|
+
return 0.0
|
|
26
|
+
p = PRICING[key]
|
|
27
|
+
return (input_tokens * p["input"] + output_tokens * p["output"]) / 1_000_000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def instrument(anthropic_client: Any, logspine: "LogspineClient") -> Any:
|
|
31
|
+
"""Wrap an anthropic.Anthropic() instance to track every LLM call."""
|
|
32
|
+
original_create = anthropic_client.messages.create
|
|
33
|
+
|
|
34
|
+
def tracked_create(*args: Any, **kwargs: Any) -> Any:
|
|
35
|
+
meta: dict[str, Any] = kwargs.pop("logspine", {}) or {}
|
|
36
|
+
start = time.time()
|
|
37
|
+
error_msg = ""
|
|
38
|
+
response = None
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
response = original_create(*args, **kwargs)
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
error_msg = str(exc)
|
|
44
|
+
raise
|
|
45
|
+
finally:
|
|
46
|
+
end = time.time()
|
|
47
|
+
|
|
48
|
+
model = kwargs.get("model", "")
|
|
49
|
+
messages = kwargs.get("messages", [])
|
|
50
|
+
system = kwargs.get("system", "")
|
|
51
|
+
|
|
52
|
+
input_tokens = 0
|
|
53
|
+
output_tokens = 0
|
|
54
|
+
output_text = ""
|
|
55
|
+
|
|
56
|
+
if response is not None:
|
|
57
|
+
usage = getattr(response, "usage", None)
|
|
58
|
+
if usage:
|
|
59
|
+
input_tokens = getattr(usage, "input_tokens", 0) or 0
|
|
60
|
+
output_tokens = getattr(usage, "output_tokens", 0) or 0
|
|
61
|
+
content = getattr(response, "content", [])
|
|
62
|
+
if content:
|
|
63
|
+
first = content[0]
|
|
64
|
+
output_text = getattr(first, "text", "") or ""
|
|
65
|
+
|
|
66
|
+
cost = _cost(model, input_tokens, output_tokens)
|
|
67
|
+
|
|
68
|
+
# Build input representation — include system prompt if present
|
|
69
|
+
input_payload: dict[str, Any] = {"messages": messages}
|
|
70
|
+
if system:
|
|
71
|
+
input_payload["system"] = system
|
|
72
|
+
|
|
73
|
+
span: dict[str, Any] = {
|
|
74
|
+
"trace_id": meta.get("trace_id", _new_id()),
|
|
75
|
+
"span_id": _new_id(),
|
|
76
|
+
"parent_id": meta.get("parent_id", ""),
|
|
77
|
+
"name": meta.get("name", f"messages/{model}"),
|
|
78
|
+
"start_ts": _iso(start),
|
|
79
|
+
"end_ts": _iso(end),
|
|
80
|
+
"provider": "anthropic",
|
|
81
|
+
"model": model,
|
|
82
|
+
"prompt_tokens": input_tokens,
|
|
83
|
+
"completion_tokens": output_tokens,
|
|
84
|
+
"cost_usd": cost,
|
|
85
|
+
"status": "error" if error_msg else "ok",
|
|
86
|
+
"error": error_msg,
|
|
87
|
+
"input": json.dumps(input_payload)[:65536],
|
|
88
|
+
"output": output_text[:65536],
|
|
89
|
+
"metadata": json.dumps({
|
|
90
|
+
k: v for k, v in meta.items()
|
|
91
|
+
if k not in ("trace_id", "span_id", "parent_id", "name")
|
|
92
|
+
}),
|
|
93
|
+
}
|
|
94
|
+
logspine._record_span(span)
|
|
95
|
+
|
|
96
|
+
return response
|
|
97
|
+
|
|
98
|
+
anthropic_client.messages.create = tracked_create
|
|
99
|
+
return anthropic_client
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _new_id() -> str:
|
|
103
|
+
import uuid
|
|
104
|
+
return str(uuid.uuid4())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _iso(ts: float) -> str:
|
|
108
|
+
from datetime import datetime, timezone
|
|
109
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""OpenAI instrumentation for Logspine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ..client import LogspineClient
|
|
11
|
+
|
|
12
|
+
# Cost per 1M tokens in USD (as of mid-2026)
|
|
13
|
+
PRICING: dict[str, dict[str, float]] = {
|
|
14
|
+
"gpt-4o": {"input": 2.50, "output": 10.00},
|
|
15
|
+
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
|
|
16
|
+
"gpt-4-turbo": {"input": 10.00, "output": 30.00},
|
|
17
|
+
"gpt-3.5-turbo": {"input": 0.50, "output": 1.50},
|
|
18
|
+
"o1": {"input": 15.00, "output": 60.00},
|
|
19
|
+
"o1-mini": {"input": 3.00, "output": 12.00},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cost(model: str, prompt: int, completion: int) -> float:
|
|
24
|
+
key = next((k for k in PRICING if model.startswith(k)), None)
|
|
25
|
+
if not key:
|
|
26
|
+
return 0.0
|
|
27
|
+
p = PRICING[key]
|
|
28
|
+
return (prompt * p["input"] + completion * p["output"]) / 1_000_000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def instrument(openai_client: Any, logspine: "LogspineClient") -> Any:
|
|
32
|
+
"""Wrap an openai.OpenAI() instance to track every LLM call."""
|
|
33
|
+
original_create = openai_client.chat.completions.create
|
|
34
|
+
|
|
35
|
+
def tracked_create(*args: Any, **kwargs: Any) -> Any:
|
|
36
|
+
meta: dict[str, Any] = kwargs.pop("logspine", {}) or {}
|
|
37
|
+
start = time.time()
|
|
38
|
+
error_msg = ""
|
|
39
|
+
response = None
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
response = original_create(*args, **kwargs)
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
error_msg = str(exc)
|
|
45
|
+
raise
|
|
46
|
+
finally:
|
|
47
|
+
end = time.time()
|
|
48
|
+
duration_ms = int((end - start) * 1000)
|
|
49
|
+
|
|
50
|
+
model = kwargs.get("model", "")
|
|
51
|
+
messages = kwargs.get("messages", [])
|
|
52
|
+
|
|
53
|
+
prompt_tokens = 0
|
|
54
|
+
completion_tokens = 0
|
|
55
|
+
output_text = ""
|
|
56
|
+
|
|
57
|
+
if response is not None:
|
|
58
|
+
usage = getattr(response, "usage", None)
|
|
59
|
+
if usage:
|
|
60
|
+
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
|
|
61
|
+
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
|
|
62
|
+
choices = getattr(response, "choices", [])
|
|
63
|
+
if choices:
|
|
64
|
+
msg = getattr(choices[0], "message", None)
|
|
65
|
+
if msg:
|
|
66
|
+
output_text = getattr(msg, "content", "") or ""
|
|
67
|
+
|
|
68
|
+
cost = _cost(model, prompt_tokens, completion_tokens)
|
|
69
|
+
|
|
70
|
+
span: dict[str, Any] = {
|
|
71
|
+
"trace_id": meta.get("trace_id", _new_id()),
|
|
72
|
+
"span_id": _new_id(),
|
|
73
|
+
"parent_id": meta.get("parent_id", ""),
|
|
74
|
+
"name": meta.get("name", f"chat/{model}"),
|
|
75
|
+
"start_ts": _iso(start),
|
|
76
|
+
"end_ts": _iso(end),
|
|
77
|
+
"provider": "openai",
|
|
78
|
+
"model": model,
|
|
79
|
+
"prompt_tokens": prompt_tokens,
|
|
80
|
+
"completion_tokens": completion_tokens,
|
|
81
|
+
"cost_usd": cost,
|
|
82
|
+
"status": "error" if error_msg else "ok",
|
|
83
|
+
"error": error_msg,
|
|
84
|
+
"input": json.dumps(messages)[:65536],
|
|
85
|
+
"output": output_text[:65536],
|
|
86
|
+
"metadata": json.dumps({
|
|
87
|
+
k: v for k, v in meta.items()
|
|
88
|
+
if k not in ("trace_id", "span_id", "parent_id", "name")
|
|
89
|
+
}),
|
|
90
|
+
}
|
|
91
|
+
logspine._record_span(span)
|
|
92
|
+
|
|
93
|
+
return response
|
|
94
|
+
|
|
95
|
+
openai_client.chat.completions.create = tracked_create
|
|
96
|
+
return openai_client
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _new_id() -> str:
|
|
100
|
+
import uuid
|
|
101
|
+
return str(uuid.uuid4())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _iso(ts: float) -> str:
|
|
105
|
+
from datetime import datetime, timezone
|
|
106
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "logspine"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Drop-in observability for AI agents — Python SDK"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Adam Kallen", email = "hello@logspine.dev" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
openai = ["openai>=1.0.0"]
|
|
17
|
+
anthropic = ["anthropic>=0.25.0"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://www.logspine.dev"
|
|
21
|
+
Documentation = "https://www.logspine.dev/docs"
|
|
22
|
+
Repository = "https://github.com/logspine-dev/logspine"
|