torina 0.1.1__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from torina._instrumentation import auto_instrument
2
+ from torina._logger import init_logger
3
+
4
+ __version__ = "0.1.0"
5
+
6
+ __all__ = ["auto_instrument", "init_logger", "__version__"]
torina/_config.py ADDED
@@ -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()
torina/_forwarder.py ADDED
@@ -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)
torina/_http.py ADDED
@@ -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()
torina/_logger.py ADDED
@@ -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}
torina/langchain.py ADDED
@@ -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
torina/py.typed ADDED
File without changes
@@ -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/).
@@ -0,0 +1,12 @@
1
+ torina/__init__.py,sha256=Wz7oLA12CWCzasF-aZ4d-mYHhufDpc59kSDJB7WIC_c,175
2
+ torina/_config.py,sha256=kbFvLQQbkB-28Cv-DRAseeeCe_lLezC_i0REgTb8wW8,302
3
+ torina/_forwarder.py,sha256=q-aEOn00MaojchIC6hlWzhxJwaSLCMlHGut8gIoTMjI,4480
4
+ torina/_http.py,sha256=e1afbzUDtB_f82-xsIfuwgZ19RtkiLX__LbpT7Bp0ds,936
5
+ torina/_instrumentation.py,sha256=C5hMeyec-4FGf8j8CYFIhDK-yPu_rsOIscrwM6kDbis,321
6
+ torina/_logger.py,sha256=8J4Zz7pfGt2dCWAQqVvgtDETy0Ev-1CWI_QXq2wKu04,1217
7
+ torina/_trace_tracker.py,sha256=KXI45fiidb0PTaOgT9fopZdUj6_keHBZcvxzKFf6zxE,2340
8
+ torina/langchain.py,sha256=CFrr0_sSD8e3EGN60I7MSFnHZQV5wTMrE2MmSzfGWIw,8151
9
+ torina/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ torina-0.1.1.dist-info/WHEEL,sha256=l3MmIxu8qaet7ng2J9fFoJnYGj8IREj7jXTbgsuzmy4,81
11
+ torina-0.1.1.dist-info/METADATA,sha256=Qr8UabNEgEYBFF7bvUH-GaHwjAgSmE2qWGkfNo7bM0c,5102
12
+ torina-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.32
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any