torina 0.1.1__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.
- torina-0.1.1/PKG-INFO +142 -0
- torina-0.1.1/README.md +118 -0
- torina-0.1.1/pyproject.toml +43 -0
- torina-0.1.1/src/torina/__init__.py +6 -0
- torina-0.1.1/src/torina/_config.py +16 -0
- torina-0.1.1/src/torina/_forwarder.py +116 -0
- torina-0.1.1/src/torina/_http.py +27 -0
- torina-0.1.1/src/torina/_instrumentation.py +11 -0
- torina-0.1.1/src/torina/_logger.py +39 -0
- torina-0.1.1/src/torina/_trace_tracker.py +54 -0
- torina-0.1.1/src/torina/langchain.py +230 -0
- torina-0.1.1/src/torina/py.typed +0 -0
torina-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: torina
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Trace ingestion client for Torina — captures LLM agent traces and forwards them over HTTP.
|
|
5
|
+
Author: noahwg
|
|
6
|
+
Author-email: noahwg <nwg7199@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: System :: Monitoring
|
|
19
|
+
Requires-Dist: wrapt>=1.14
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Project-URL: Homepage, https://github.com/Tribemails/torina-python-lib
|
|
22
|
+
Project-URL: Issues, https://github.com/Tribemails/torina-python-lib/issues
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# torina
|
|
26
|
+
|
|
27
|
+
Captures LLM agent traces (currently: LangChain) and forwards them to a
|
|
28
|
+
Torina HTTP ingestion endpoint via a single POST per trace, authenticated
|
|
29
|
+
with an `x-api-key` header. Not built on OpenTelemetry — it patches
|
|
30
|
+
LangChain's own callback system directly, so the only dependency is
|
|
31
|
+
[wrapt](https://github.com/GrahamDumpleton/wrapt).
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv add torina
|
|
37
|
+
# or
|
|
38
|
+
pip install torina
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import os
|
|
45
|
+
import torina
|
|
46
|
+
|
|
47
|
+
torina.auto_instrument()
|
|
48
|
+
torina.init_logger(
|
|
49
|
+
api_key=os.environ["TORINA_API_KEY"],
|
|
50
|
+
project="My Project (Python)",
|
|
51
|
+
endpoint="https://ingest.torina.example/v1/traces", # or set TORINA_ENDPOINT
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`auto_instrument()` patches LangChain automatically if it's installed, so
|
|
56
|
+
every `agent.invoke()`/`.stream()` call anywhere in your app gets captured
|
|
57
|
+
with no per-call-site changes. A trace is uploaded whole — one HTTP request
|
|
58
|
+
per completed trace (every `llm`/`tool` span it contains, plus any `chain`
|
|
59
|
+
span that carries real data — empty structural wrapper chains, like
|
|
60
|
+
LangGraph's internal per-step nodes, are pruned), not one request per span.
|
|
61
|
+
|
|
62
|
+
Every trace carries `payload["source"]` (`"langchain"` today — the only
|
|
63
|
+
integration so far) so traces from different sources stay distinguishable as
|
|
64
|
+
more get added later.
|
|
65
|
+
|
|
66
|
+
A few things get deduplicated rather than taken at face value. Tool schemas
|
|
67
|
+
and the system prompt are both usually identical across every LLM call in a
|
|
68
|
+
trace — they're a property of the agent's definition, not the individual
|
|
69
|
+
call, but get resent on every call because that's how chat APIs work. Both
|
|
70
|
+
get hoisted to `payload["tool_definitions"]` / `payload["system_prompt"]`
|
|
71
|
+
when they match across every `llm` span — left per-span only if a trace
|
|
72
|
+
genuinely varied them mid-conversation.
|
|
73
|
+
|
|
74
|
+
Spans don't carry `parent_span_id` — there's no span tree. Relationships that
|
|
75
|
+
matter are expressed directly instead: a `tool` span's `tool_call_id` matches
|
|
76
|
+
the `id` in the requesting `llm` span's `output_message.tool_calls`, which is
|
|
77
|
+
more precise than a parent/child edge would be anyway (LangChain's own run
|
|
78
|
+
tree only reflects LangGraph's internal step wrappers, not which `llm` call
|
|
79
|
+
actually caused which `tool` call).
|
|
80
|
+
|
|
81
|
+
Conversation grouping, the agent's name, and arbitrary metadata all come from
|
|
82
|
+
LangChain's own `config`/`create_agent(name=...)`, no Torina-specific code
|
|
83
|
+
needed:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
agent = create_agent(..., name="weather-agent") # -> payload["agent_name"]
|
|
87
|
+
|
|
88
|
+
agent.invoke(
|
|
89
|
+
{"messages": [...]},
|
|
90
|
+
config={
|
|
91
|
+
"configurable": {"thread_id": thread_id}, # -> payload["conversation_id"]
|
|
92
|
+
"metadata": {"user_id": "u_123", "plan": "enterprise"}, # -> payload["metadata"]
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
For manual control over a specific call site instead of the global patch,
|
|
98
|
+
`TorinaCallbackHandler` is available directly (requires `langchain-core`):
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from torina.langchain import TorinaCallbackHandler
|
|
102
|
+
|
|
103
|
+
agent.invoke({"messages": [...]}, config={"callbacks": [TorinaCallbackHandler()]})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### dry_run
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
torina.init_logger(dry_run=True) # no api_key or endpoint needed
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Traces are logged instead of sent — useful for local testing before you have
|
|
113
|
+
a real endpoint. Same code path either way; `dry_run` only swaps the network
|
|
114
|
+
call for a log line.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
This project uses [uv](https://docs.astral.sh/uv/).
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
uv sync
|
|
122
|
+
uv run pytest
|
|
123
|
+
uv run ruff check .
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Releasing
|
|
127
|
+
|
|
128
|
+
Publishing to PyPI happens via [`.github/workflows/publish.yml`](.github/workflows/publish.yml),
|
|
129
|
+
triggered by a GitHub release. It uses [trusted publishing](https://docs.pypi.org/trusted-publishers/)
|
|
130
|
+
(OIDC), so no PyPI API token is stored in the repo.
|
|
131
|
+
|
|
132
|
+
1. Bump `version` in [`pyproject.toml`](pyproject.toml).
|
|
133
|
+
2. Commit and push to `main`.
|
|
134
|
+
3. Tag and create a release:
|
|
135
|
+
```bash
|
|
136
|
+
git tag vX.Y.Z
|
|
137
|
+
git push origin vX.Y.Z
|
|
138
|
+
gh release create vX.Y.Z --title vX.Y.Z --generate-notes
|
|
139
|
+
```
|
|
140
|
+
4. Publishing the release triggers the workflow, which runs `uv build` and
|
|
141
|
+
`uv publish`. Check the Actions tab for the run, then confirm at
|
|
142
|
+
[pypi.org/project/torina](https://pypi.org/project/torina/).
|
torina-0.1.1/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# torina
|
|
2
|
+
|
|
3
|
+
Captures LLM agent traces (currently: LangChain) and forwards them to a
|
|
4
|
+
Torina HTTP ingestion endpoint via a single POST per trace, authenticated
|
|
5
|
+
with an `x-api-key` header. Not built on OpenTelemetry — it patches
|
|
6
|
+
LangChain's own callback system directly, so the only dependency is
|
|
7
|
+
[wrapt](https://github.com/GrahamDumpleton/wrapt).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add torina
|
|
13
|
+
# or
|
|
14
|
+
pip install torina
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import os
|
|
21
|
+
import torina
|
|
22
|
+
|
|
23
|
+
torina.auto_instrument()
|
|
24
|
+
torina.init_logger(
|
|
25
|
+
api_key=os.environ["TORINA_API_KEY"],
|
|
26
|
+
project="My Project (Python)",
|
|
27
|
+
endpoint="https://ingest.torina.example/v1/traces", # or set TORINA_ENDPOINT
|
|
28
|
+
)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`auto_instrument()` patches LangChain automatically if it's installed, so
|
|
32
|
+
every `agent.invoke()`/`.stream()` call anywhere in your app gets captured
|
|
33
|
+
with no per-call-site changes. A trace is uploaded whole — one HTTP request
|
|
34
|
+
per completed trace (every `llm`/`tool` span it contains, plus any `chain`
|
|
35
|
+
span that carries real data — empty structural wrapper chains, like
|
|
36
|
+
LangGraph's internal per-step nodes, are pruned), not one request per span.
|
|
37
|
+
|
|
38
|
+
Every trace carries `payload["source"]` (`"langchain"` today — the only
|
|
39
|
+
integration so far) so traces from different sources stay distinguishable as
|
|
40
|
+
more get added later.
|
|
41
|
+
|
|
42
|
+
A few things get deduplicated rather than taken at face value. Tool schemas
|
|
43
|
+
and the system prompt are both usually identical across every LLM call in a
|
|
44
|
+
trace — they're a property of the agent's definition, not the individual
|
|
45
|
+
call, but get resent on every call because that's how chat APIs work. Both
|
|
46
|
+
get hoisted to `payload["tool_definitions"]` / `payload["system_prompt"]`
|
|
47
|
+
when they match across every `llm` span — left per-span only if a trace
|
|
48
|
+
genuinely varied them mid-conversation.
|
|
49
|
+
|
|
50
|
+
Spans don't carry `parent_span_id` — there's no span tree. Relationships that
|
|
51
|
+
matter are expressed directly instead: a `tool` span's `tool_call_id` matches
|
|
52
|
+
the `id` in the requesting `llm` span's `output_message.tool_calls`, which is
|
|
53
|
+
more precise than a parent/child edge would be anyway (LangChain's own run
|
|
54
|
+
tree only reflects LangGraph's internal step wrappers, not which `llm` call
|
|
55
|
+
actually caused which `tool` call).
|
|
56
|
+
|
|
57
|
+
Conversation grouping, the agent's name, and arbitrary metadata all come from
|
|
58
|
+
LangChain's own `config`/`create_agent(name=...)`, no Torina-specific code
|
|
59
|
+
needed:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
agent = create_agent(..., name="weather-agent") # -> payload["agent_name"]
|
|
63
|
+
|
|
64
|
+
agent.invoke(
|
|
65
|
+
{"messages": [...]},
|
|
66
|
+
config={
|
|
67
|
+
"configurable": {"thread_id": thread_id}, # -> payload["conversation_id"]
|
|
68
|
+
"metadata": {"user_id": "u_123", "plan": "enterprise"}, # -> payload["metadata"]
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For manual control over a specific call site instead of the global patch,
|
|
74
|
+
`TorinaCallbackHandler` is available directly (requires `langchain-core`):
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from torina.langchain import TorinaCallbackHandler
|
|
78
|
+
|
|
79
|
+
agent.invoke({"messages": [...]}, config={"callbacks": [TorinaCallbackHandler()]})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### dry_run
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
torina.init_logger(dry_run=True) # no api_key or endpoint needed
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Traces are logged instead of sent — useful for local testing before you have
|
|
89
|
+
a real endpoint. Same code path either way; `dry_run` only swaps the network
|
|
90
|
+
call for a log line.
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
This project uses [uv](https://docs.astral.sh/uv/).
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
uv sync
|
|
98
|
+
uv run pytest
|
|
99
|
+
uv run ruff check .
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Releasing
|
|
103
|
+
|
|
104
|
+
Publishing to PyPI happens via [`.github/workflows/publish.yml`](.github/workflows/publish.yml),
|
|
105
|
+
triggered by a GitHub release. It uses [trusted publishing](https://docs.pypi.org/trusted-publishers/)
|
|
106
|
+
(OIDC), so no PyPI API token is stored in the repo.
|
|
107
|
+
|
|
108
|
+
1. Bump `version` in [`pyproject.toml`](pyproject.toml).
|
|
109
|
+
2. Commit and push to `main`.
|
|
110
|
+
3. Tag and create a release:
|
|
111
|
+
```bash
|
|
112
|
+
git tag vX.Y.Z
|
|
113
|
+
git push origin vX.Y.Z
|
|
114
|
+
gh release create vX.Y.Z --title vX.Y.Z --generate-notes
|
|
115
|
+
```
|
|
116
|
+
4. Publishing the release triggers the workflow, which runs `uv build` and
|
|
117
|
+
`uv publish`. Check the Actions tab for the run, then confirm at
|
|
118
|
+
[pypi.org/project/torina](https://pypi.org/project/torina/).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "torina"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Trace ingestion client for Torina — captures LLM agent traces and forwards them over HTTP."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "noahwg", email = "nwg7199@gmail.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.8",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: System :: Monitoring",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"wrapt>=1.14",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/Tribemails/torina-python-lib"
|
|
30
|
+
Issues = "https://github.com/Tribemails/torina-python-lib/issues"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=7.0",
|
|
35
|
+
"ruff>=0.6",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
pythonpath = ["src"]
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["uv_build>=0.9.3,<0.10.0"]
|
|
43
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class _Config:
|
|
9
|
+
api_key: Optional[str] = None
|
|
10
|
+
endpoint: Optional[str] = None
|
|
11
|
+
project: Optional[str] = None
|
|
12
|
+
timeout: float = 10.0
|
|
13
|
+
dry_run: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
config = _Config()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from torina._config import config
|
|
8
|
+
from torina._http import HttpError, post_json
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("torina")
|
|
11
|
+
|
|
12
|
+
_SCAFFOLD_FIELDS = {"span_id", "type", "name", "start_time", "end_time"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _prune_empty_chain_spans(spans: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
16
|
+
"""Drop chain spans that carry no data beyond timing/name — e.g. LangGraph's
|
|
17
|
+
internal per-step wrapper nodes, or a root chain span once its metadata has been
|
|
18
|
+
extracted. Spans don't track parent/child relationships, so this is a plain
|
|
19
|
+
filter, nothing to re-parent."""
|
|
20
|
+
return [
|
|
21
|
+
s for s in spans if not (s.get("type") == "chain" and not (set(s.keys()) - _SCAFFOLD_FIELDS))
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _hoist_common_tool_definitions(spans: List[Dict[str, Any]]) -> Optional[Any]:
|
|
26
|
+
"""If every llm span in the trace was bound to the identical tool set (the common
|
|
27
|
+
case — tools are usually per-agent-definition, not per-call), lift it out to the
|
|
28
|
+
trace level and strip it from each span. If they differ, leave each span's copy
|
|
29
|
+
alone rather than guess which one is "right"."""
|
|
30
|
+
llm_spans = [s for s in spans if s.get("type") == "llm" and "tool_definitions" in s]
|
|
31
|
+
if not llm_spans:
|
|
32
|
+
return None
|
|
33
|
+
first = llm_spans[0]["tool_definitions"]
|
|
34
|
+
if any(s["tool_definitions"] != first for s in llm_spans):
|
|
35
|
+
return None
|
|
36
|
+
for s in llm_spans:
|
|
37
|
+
del s["tool_definitions"]
|
|
38
|
+
return first
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _hoist_common_system_prompt(spans: List[Dict[str, Any]]) -> Optional[str]:
|
|
42
|
+
"""If every llm span's first input message is the identical system prompt (the
|
|
43
|
+
common case — it's part of the agent's definition, resent unchanged with every
|
|
44
|
+
call because that's how chat APIs work, not something that varies per call),
|
|
45
|
+
lift it out to the trace level and drop it from each span's input_messages. If
|
|
46
|
+
it ever differs between spans, leave each span's copy alone."""
|
|
47
|
+
|
|
48
|
+
def system_content(s: Dict[str, Any]) -> Optional[str]:
|
|
49
|
+
messages = s.get("input_messages")
|
|
50
|
+
if messages and messages[0].get("role") == "system":
|
|
51
|
+
return messages[0].get("content")
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
llm_spans = [s for s in spans if s.get("type") == "llm" and system_content(s) is not None]
|
|
55
|
+
if not llm_spans:
|
|
56
|
+
return None
|
|
57
|
+
first = system_content(llm_spans[0])
|
|
58
|
+
if any(system_content(s) != first for s in llm_spans):
|
|
59
|
+
return None
|
|
60
|
+
for s in llm_spans:
|
|
61
|
+
s["input_messages"] = s["input_messages"][1:]
|
|
62
|
+
return first
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def forward_trace(
|
|
66
|
+
trace_id: str,
|
|
67
|
+
spans: List[Dict[str, Any]],
|
|
68
|
+
*,
|
|
69
|
+
source: str,
|
|
70
|
+
conversation_id: Optional[str] = None,
|
|
71
|
+
agent_name: Optional[str] = None,
|
|
72
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Send one completed trace to Torina.
|
|
75
|
+
|
|
76
|
+
source identifies which integration captured this trace (e.g. "langchain"),
|
|
77
|
+
so traces from different sources can be told apart once more are added.
|
|
78
|
+
conversation_id/agent_name/metadata are already-resolved values — each
|
|
79
|
+
integration is responsible for pulling them out of its own framework's
|
|
80
|
+
conventions before calling this.
|
|
81
|
+
"""
|
|
82
|
+
tool_definitions = _hoist_common_tool_definitions(spans)
|
|
83
|
+
system_prompt = _hoist_common_system_prompt(spans)
|
|
84
|
+
spans = _prune_empty_chain_spans(spans)
|
|
85
|
+
|
|
86
|
+
payload: Dict[str, Any] = {
|
|
87
|
+
"trace_id": trace_id,
|
|
88
|
+
"source": source,
|
|
89
|
+
"conversation_id": conversation_id,
|
|
90
|
+
"agent_name": agent_name,
|
|
91
|
+
"system_prompt": system_prompt,
|
|
92
|
+
"tool_definitions": tool_definitions or [],
|
|
93
|
+
"project": config.project,
|
|
94
|
+
"metadata": metadata or {},
|
|
95
|
+
"spans": spans,
|
|
96
|
+
}
|
|
97
|
+
headers = {"x-api-key": config.api_key} if config.api_key else {}
|
|
98
|
+
|
|
99
|
+
if config.dry_run:
|
|
100
|
+
redacted_headers = dict(headers)
|
|
101
|
+
if "x-api-key" in redacted_headers:
|
|
102
|
+
redacted_headers["x-api-key"] = "<redacted>"
|
|
103
|
+
logger.info(
|
|
104
|
+
"torina dry-run: would POST trace %s (%d span(s)) to %s\nheaders=%s\npayload=%s",
|
|
105
|
+
trace_id,
|
|
106
|
+
len(spans),
|
|
107
|
+
config.endpoint or "<no endpoint configured>",
|
|
108
|
+
redacted_headers,
|
|
109
|
+
json.dumps(payload, indent=2, default=str),
|
|
110
|
+
)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
post_json(config.endpoint, payload, headers, config.timeout)
|
|
115
|
+
except HttpError:
|
|
116
|
+
logger.exception("Failed to export trace %s to Torina", trace_id)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HttpError(Exception):
|
|
10
|
+
def __init__(self, status: int, message: str) -> None:
|
|
11
|
+
super().__init__(f"HTTP {status}: {message}")
|
|
12
|
+
self.status = status
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def post_json(url: str, payload: Any, headers: Dict[str, str], timeout: float) -> None:
|
|
16
|
+
body = json.dumps(payload).encode("utf-8")
|
|
17
|
+
request = urllib.request.Request(url, data=body, method="POST")
|
|
18
|
+
request.add_header("Content-Type", "application/json")
|
|
19
|
+
for key, value in headers.items():
|
|
20
|
+
request.add_header(key, value)
|
|
21
|
+
try:
|
|
22
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
23
|
+
response.read()
|
|
24
|
+
except urllib.error.HTTPError as exc:
|
|
25
|
+
raise HttpError(exc.code, exc.reason) from exc
|
|
26
|
+
except urllib.error.URLError as exc:
|
|
27
|
+
raise HttpError(0, str(exc.reason)) from exc
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def auto_instrument() -> None:
|
|
7
|
+
"""Patch every supported framework that's installed to capture Torina traces."""
|
|
8
|
+
if importlib.util.find_spec("langchain_core") is not None:
|
|
9
|
+
from torina.langchain import _patch_langchain
|
|
10
|
+
|
|
11
|
+
_patch_langchain()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from torina._config import config
|
|
7
|
+
|
|
8
|
+
_API_KEY_ENV_VAR = "TORINA_API_KEY"
|
|
9
|
+
_ENDPOINT_ENV_VAR = "TORINA_ENDPOINT"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def init_logger(
|
|
13
|
+
*,
|
|
14
|
+
api_key: Optional[str] = None,
|
|
15
|
+
project: Optional[str] = None,
|
|
16
|
+
endpoint: Optional[str] = None,
|
|
17
|
+
timeout: float = 10.0,
|
|
18
|
+
dry_run: bool = False,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Configure where Torina sends traces captured by auto_instrument().
|
|
21
|
+
|
|
22
|
+
With dry_run=True, traces are logged instead of sent, so no endpoint or
|
|
23
|
+
api_key is required. Traces are uploaded whole, one HTTP request per
|
|
24
|
+
completed trace, not per span.
|
|
25
|
+
"""
|
|
26
|
+
api_key = api_key or os.environ.get(_API_KEY_ENV_VAR)
|
|
27
|
+
endpoint = endpoint or os.environ.get(_ENDPOINT_ENV_VAR)
|
|
28
|
+
if not dry_run and not endpoint:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
f"Torina ingestion endpoint is required: pass endpoint= or set {_ENDPOINT_ENV_VAR}"
|
|
31
|
+
)
|
|
32
|
+
if not dry_run and not api_key:
|
|
33
|
+
raise ValueError(f"Torina API key is required: pass api_key= or set {_API_KEY_ENV_VAR}")
|
|
34
|
+
|
|
35
|
+
config.api_key = api_key
|
|
36
|
+
config.endpoint = endpoint
|
|
37
|
+
config.project = project
|
|
38
|
+
config.timeout = timeout
|
|
39
|
+
config.dry_run = dry_run
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TraceTracker:
|
|
8
|
+
"""Groups individual spans into complete traces using parent_span_id to find each
|
|
9
|
+
trace's boundary. parent_span_id is bookkeeping only — spans don't carry it in
|
|
10
|
+
their output, since it duplicates relationships already expressed elsewhere (e.g.
|
|
11
|
+
a tool span's tool_call_id already says which llm span requested it).
|
|
12
|
+
|
|
13
|
+
A trace is considered complete the moment its root span (the one with no
|
|
14
|
+
parent) ends — at which point every span belonging to that trace is
|
|
15
|
+
returned together and dropped from memory. A span that starts but never
|
|
16
|
+
ends (e.g. the process crashes mid-trace) leaks until process exit; there
|
|
17
|
+
is no timeout/orphan sweep in this implementation.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
self._spans: Dict[str, Dict[str, Any]] = {}
|
|
23
|
+
self._root_of: Dict[str, str] = {}
|
|
24
|
+
|
|
25
|
+
def start_span(self, span_id: Any, parent_span_id: Optional[Any], **fields: Any) -> None:
|
|
26
|
+
span_id = str(span_id)
|
|
27
|
+
parent_span_id = str(parent_span_id) if parent_span_id else None
|
|
28
|
+
with self._lock:
|
|
29
|
+
root = self._root_of[parent_span_id] if parent_span_id in self._root_of else span_id
|
|
30
|
+
self._root_of[span_id] = root
|
|
31
|
+
self._spans[span_id] = {"span_id": span_id, **fields}
|
|
32
|
+
|
|
33
|
+
def end_span(self, span_id: Any, **fields: Any) -> Optional[Dict[str, Any]]:
|
|
34
|
+
"""Record the span's completion. Returns the full trace dict once its root span ends."""
|
|
35
|
+
span_id = str(span_id)
|
|
36
|
+
with self._lock:
|
|
37
|
+
span = self._spans.get(span_id)
|
|
38
|
+
if span is None:
|
|
39
|
+
return None
|
|
40
|
+
span.update(fields)
|
|
41
|
+
|
|
42
|
+
root = self._root_of.get(span_id)
|
|
43
|
+
if root != span_id:
|
|
44
|
+
return None # trace still in progress
|
|
45
|
+
|
|
46
|
+
trace_span_ids = [sid for sid, r in self._root_of.items() if r == root]
|
|
47
|
+
spans: List[Dict[str, Any]] = [
|
|
48
|
+
self._spans.pop(sid) for sid in trace_span_ids if sid in self._spans
|
|
49
|
+
]
|
|
50
|
+
for sid in trace_span_ids:
|
|
51
|
+
self._root_of.pop(sid, None)
|
|
52
|
+
|
|
53
|
+
spans.sort(key=lambda s: s.get("start_time") or "")
|
|
54
|
+
return {"trace_id": root, "spans": spans}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from langchain_core.callbacks.base import BaseCallbackHandler
|
|
8
|
+
from wrapt import wrap_function_wrapper
|
|
9
|
+
|
|
10
|
+
from torina._forwarder import forward_trace
|
|
11
|
+
from torina._trace_tracker import TraceTracker
|
|
12
|
+
|
|
13
|
+
_tracker = TraceTracker()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _now() -> str:
|
|
17
|
+
return datetime.now(timezone.utc).isoformat()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run_name(serialized: Optional[Dict[str, Any]], kwargs: Dict[str, Any], default: str) -> str:
|
|
21
|
+
name = kwargs.get("name")
|
|
22
|
+
if name:
|
|
23
|
+
return name
|
|
24
|
+
if serialized:
|
|
25
|
+
name = serialized.get("name")
|
|
26
|
+
if name:
|
|
27
|
+
return name
|
|
28
|
+
ids = serialized.get("id")
|
|
29
|
+
if ids:
|
|
30
|
+
return ids[-1]
|
|
31
|
+
return default
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _message_content_text(content: Any) -> Any:
|
|
35
|
+
"""Collapse provider-specific content blocks (e.g. Anthropic's text/tool_use
|
|
36
|
+
blocks) down to plain text. Tool calls are dropped here since message.tool_calls
|
|
37
|
+
already captures them in a cleaner, provider-agnostic shape."""
|
|
38
|
+
if not isinstance(content, list):
|
|
39
|
+
return content
|
|
40
|
+
text_parts = [
|
|
41
|
+
block.get("text", "") for block in content if isinstance(block, dict) and block.get("type") == "text"
|
|
42
|
+
]
|
|
43
|
+
return "".join(text_parts) or None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _message_dict(message: Any) -> Dict[str, Any]:
|
|
47
|
+
result: Dict[str, Any] = {
|
|
48
|
+
"role": getattr(message, "type", "unknown"),
|
|
49
|
+
"content": _message_content_text(getattr(message, "content", None)),
|
|
50
|
+
}
|
|
51
|
+
tool_calls = getattr(message, "tool_calls", None)
|
|
52
|
+
if tool_calls:
|
|
53
|
+
result["tool_calls"] = [
|
|
54
|
+
{"id": tc.get("id"), "name": tc.get("name"), "args": tc.get("args")} for tc in tool_calls
|
|
55
|
+
]
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TorinaCallbackHandler(BaseCallbackHandler):
|
|
60
|
+
"""LangChain callback handler that captures traces and forwards them to Torina."""
|
|
61
|
+
|
|
62
|
+
def on_chain_start(
|
|
63
|
+
self, serialized, inputs, *, run_id, parent_run_id=None, tags=None, metadata=None, **kwargs
|
|
64
|
+
):
|
|
65
|
+
fields: Dict[str, Any] = {
|
|
66
|
+
"type": "chain",
|
|
67
|
+
"name": _run_name(serialized, kwargs, "chain"),
|
|
68
|
+
"start_time": _now(),
|
|
69
|
+
}
|
|
70
|
+
if parent_run_id is None and metadata:
|
|
71
|
+
fields["metadata"] = dict(metadata)
|
|
72
|
+
_tracker.start_span(run_id, parent_run_id, **fields)
|
|
73
|
+
|
|
74
|
+
def on_chain_end(self, outputs, *, run_id, parent_run_id=None, **kwargs):
|
|
75
|
+
self._end(run_id)
|
|
76
|
+
|
|
77
|
+
def on_chain_error(self, error, *, run_id, parent_run_id=None, **kwargs):
|
|
78
|
+
self._end(run_id, error=str(error))
|
|
79
|
+
|
|
80
|
+
def on_chat_model_start(
|
|
81
|
+
self, serialized, messages, *, run_id, parent_run_id=None, tags=None, metadata=None, **kwargs
|
|
82
|
+
):
|
|
83
|
+
invocation_params = kwargs.get("invocation_params") or {}
|
|
84
|
+
fields: Dict[str, Any] = {
|
|
85
|
+
"type": "llm",
|
|
86
|
+
"name": _run_name(serialized, kwargs, "chat_model"),
|
|
87
|
+
"start_time": _now(),
|
|
88
|
+
"model": invocation_params.get("model"),
|
|
89
|
+
"input_messages": [_message_dict(m) for batch in messages for m in batch],
|
|
90
|
+
}
|
|
91
|
+
tools = invocation_params.get("tools")
|
|
92
|
+
if tools:
|
|
93
|
+
fields["tool_definitions"] = tools
|
|
94
|
+
if parent_run_id is None and metadata:
|
|
95
|
+
fields["metadata"] = dict(metadata)
|
|
96
|
+
_tracker.start_span(run_id, parent_run_id, **fields)
|
|
97
|
+
|
|
98
|
+
def on_llm_start(
|
|
99
|
+
self, serialized, prompts, *, run_id, parent_run_id=None, tags=None, metadata=None, **kwargs
|
|
100
|
+
):
|
|
101
|
+
invocation_params = kwargs.get("invocation_params") or {}
|
|
102
|
+
fields: Dict[str, Any] = {
|
|
103
|
+
"type": "llm",
|
|
104
|
+
"name": _run_name(serialized, kwargs, "llm"),
|
|
105
|
+
"start_time": _now(),
|
|
106
|
+
"model": invocation_params.get("model"),
|
|
107
|
+
"input_prompts": list(prompts),
|
|
108
|
+
}
|
|
109
|
+
tools = invocation_params.get("tools")
|
|
110
|
+
if tools:
|
|
111
|
+
fields["tool_definitions"] = tools
|
|
112
|
+
if parent_run_id is None and metadata:
|
|
113
|
+
fields["metadata"] = dict(metadata)
|
|
114
|
+
_tracker.start_span(run_id, parent_run_id, **fields)
|
|
115
|
+
|
|
116
|
+
def on_llm_end(self, response, *, run_id, parent_run_id=None, tags=None, **kwargs):
|
|
117
|
+
llm_output = response.llm_output or {}
|
|
118
|
+
usage = llm_output.get("usage") or llm_output.get("token_usage") or {}
|
|
119
|
+
generations = response.generations[0] if response.generations else []
|
|
120
|
+
generation = generations[0] if generations else None
|
|
121
|
+
message = getattr(generation, "message", None)
|
|
122
|
+
if message is not None:
|
|
123
|
+
output = _message_dict(message)
|
|
124
|
+
elif generation is not None:
|
|
125
|
+
output = {"content": getattr(generation, "text", None)}
|
|
126
|
+
else:
|
|
127
|
+
output = None
|
|
128
|
+
|
|
129
|
+
self._end(
|
|
130
|
+
run_id,
|
|
131
|
+
model=llm_output.get("model") or llm_output.get("model_name"),
|
|
132
|
+
usage={
|
|
133
|
+
"input_tokens": usage.get("input_tokens") or usage.get("prompt_tokens"),
|
|
134
|
+
"output_tokens": usage.get("output_tokens") or usage.get("completion_tokens"),
|
|
135
|
+
}
|
|
136
|
+
if usage
|
|
137
|
+
else None,
|
|
138
|
+
output_message=output,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def on_llm_error(self, error, *, run_id, parent_run_id=None, tags=None, **kwargs):
|
|
142
|
+
self._end(run_id, error=str(error))
|
|
143
|
+
|
|
144
|
+
def on_tool_start(
|
|
145
|
+
self,
|
|
146
|
+
serialized,
|
|
147
|
+
input_str,
|
|
148
|
+
*,
|
|
149
|
+
run_id,
|
|
150
|
+
parent_run_id=None,
|
|
151
|
+
tags=None,
|
|
152
|
+
metadata=None,
|
|
153
|
+
inputs=None,
|
|
154
|
+
**kwargs,
|
|
155
|
+
):
|
|
156
|
+
fields: Dict[str, Any] = {
|
|
157
|
+
"type": "tool",
|
|
158
|
+
"name": _run_name(serialized, kwargs, "tool"),
|
|
159
|
+
"start_time": _now(),
|
|
160
|
+
"input": inputs if inputs is not None else input_str,
|
|
161
|
+
}
|
|
162
|
+
tool_call_id = kwargs.get("tool_call_id")
|
|
163
|
+
if tool_call_id:
|
|
164
|
+
# Links this span to the matching entry in the requesting llm span's
|
|
165
|
+
# output_message.tool_calls — how to correlate a tool call with its
|
|
166
|
+
# execution when multiple calls happen in parallel.
|
|
167
|
+
fields["tool_call_id"] = tool_call_id
|
|
168
|
+
if parent_run_id is None and metadata:
|
|
169
|
+
fields["metadata"] = dict(metadata)
|
|
170
|
+
_tracker.start_span(run_id, parent_run_id, **fields)
|
|
171
|
+
|
|
172
|
+
def on_tool_end(self, output, *, run_id, parent_run_id=None, **kwargs):
|
|
173
|
+
content = getattr(output, "content", output)
|
|
174
|
+
if not isinstance(content, (str, int, float, bool, type(None))):
|
|
175
|
+
content = str(content)
|
|
176
|
+
self._end(run_id, output=content)
|
|
177
|
+
|
|
178
|
+
def on_tool_error(self, error, *, run_id, parent_run_id=None, **kwargs):
|
|
179
|
+
self._end(run_id, error=str(error))
|
|
180
|
+
|
|
181
|
+
def _end(self, run_id: Any, **fields: Any) -> None:
|
|
182
|
+
fields.setdefault("end_time", _now())
|
|
183
|
+
trace = _tracker.end_span(run_id, **fields)
|
|
184
|
+
if trace is None:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
spans = trace["spans"]
|
|
188
|
+
root = spans[0] if spans else {}
|
|
189
|
+
raw_metadata = dict(root.pop("metadata", None) or {})
|
|
190
|
+
conversation_id = raw_metadata.pop("thread_id", None)
|
|
191
|
+
agent_name = raw_metadata.pop("lc_agent_name", None)
|
|
192
|
+
raw_metadata.pop("ls_integration", None) # internal LangChain bookkeeping, no signal
|
|
193
|
+
|
|
194
|
+
forward_trace(
|
|
195
|
+
trace["trace_id"],
|
|
196
|
+
spans,
|
|
197
|
+
source="langchain",
|
|
198
|
+
conversation_id=conversation_id,
|
|
199
|
+
agent_name=agent_name,
|
|
200
|
+
metadata=raw_metadata,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
_patch_lock = threading.Lock()
|
|
205
|
+
_patched = False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _patch_langchain() -> None:
|
|
209
|
+
global _patched
|
|
210
|
+
with _patch_lock:
|
|
211
|
+
if _patched:
|
|
212
|
+
return
|
|
213
|
+
handler = TorinaCallbackHandler()
|
|
214
|
+
wrap_function_wrapper(
|
|
215
|
+
"langchain_core.callbacks",
|
|
216
|
+
"BaseCallbackManager.__init__",
|
|
217
|
+
_init_wrapper(handler),
|
|
218
|
+
)
|
|
219
|
+
_patched = True
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _init_wrapper(handler: TorinaCallbackHandler):
|
|
223
|
+
def wrapper(wrapped, instance, args, kwargs):
|
|
224
|
+
wrapped(*args, **kwargs)
|
|
225
|
+
for existing in instance.inheritable_handlers:
|
|
226
|
+
if isinstance(existing, TorinaCallbackHandler):
|
|
227
|
+
return
|
|
228
|
+
instance.add_handler(handler, True)
|
|
229
|
+
|
|
230
|
+
return wrapper
|
|
File without changes
|