Metadata-Version: 2.4
Name: torina
Version: 0.1.1
Summary: Trace ingestion client for Torina — captures LLM agent traces and forwards them over HTTP.
Author: noahwg
Author-email: noahwg <nwg7199@gmail.com>
License-Expression: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Monitoring
Requires-Dist: wrapt>=1.14
Requires-Python: >=3.8
Project-URL: Homepage, https://github.com/Tribemails/torina-python-lib
Project-URL: Issues, https://github.com/Tribemails/torina-python-lib/issues
Description-Content-Type: text/markdown

# torina

Captures LLM agent traces (currently: LangChain) and forwards them to a
Torina HTTP ingestion endpoint via a single POST per trace, authenticated
with an `x-api-key` header. Not built on OpenTelemetry — it patches
LangChain's own callback system directly, so the only dependency is
[wrapt](https://github.com/GrahamDumpleton/wrapt).

## Install

```bash
uv add torina
# or
pip install torina
```

## Usage

```python
import os
import torina

torina.auto_instrument()
torina.init_logger(
    api_key=os.environ["TORINA_API_KEY"],
    project="My Project (Python)",
    endpoint="https://ingest.torina.example/v1/traces",  # or set TORINA_ENDPOINT
)
```

`auto_instrument()` patches LangChain automatically if it's installed, so
every `agent.invoke()`/`.stream()` call anywhere in your app gets captured
with no per-call-site changes. A trace is uploaded whole — one HTTP request
per completed trace (every `llm`/`tool` span it contains, plus any `chain`
span that carries real data — empty structural wrapper chains, like
LangGraph's internal per-step nodes, are pruned), not one request per span.

Every trace carries `payload["source"]` (`"langchain"` today — the only
integration so far) so traces from different sources stay distinguishable as
more get added later.

A few things get deduplicated rather than taken at face value. Tool schemas
and the system prompt are both usually identical across every LLM call in a
trace — they're a property of the agent's definition, not the individual
call, but get resent on every call because that's how chat APIs work. Both
get hoisted to `payload["tool_definitions"]` / `payload["system_prompt"]`
when they match across every `llm` span — left per-span only if a trace
genuinely varied them mid-conversation.

Spans don't carry `parent_span_id` — there's no span tree. Relationships that
matter are expressed directly instead: a `tool` span's `tool_call_id` matches
the `id` in the requesting `llm` span's `output_message.tool_calls`, which is
more precise than a parent/child edge would be anyway (LangChain's own run
tree only reflects LangGraph's internal step wrappers, not which `llm` call
actually caused which `tool` call).

Conversation grouping, the agent's name, and arbitrary metadata all come from
LangChain's own `config`/`create_agent(name=...)`, no Torina-specific code
needed:

```python
agent = create_agent(..., name="weather-agent")  # -> payload["agent_name"]

agent.invoke(
    {"messages": [...]},
    config={
        "configurable": {"thread_id": thread_id},  # -> payload["conversation_id"]
        "metadata": {"user_id": "u_123", "plan": "enterprise"},  # -> payload["metadata"]
    },
)
```

For manual control over a specific call site instead of the global patch,
`TorinaCallbackHandler` is available directly (requires `langchain-core`):

```python
from torina.langchain import TorinaCallbackHandler

agent.invoke({"messages": [...]}, config={"callbacks": [TorinaCallbackHandler()]})
```

### dry_run

```python
torina.init_logger(dry_run=True)  # no api_key or endpoint needed
```

Traces are logged instead of sent — useful for local testing before you have
a real endpoint. Same code path either way; `dry_run` only swaps the network
call for a log line.

## Development

This project uses [uv](https://docs.astral.sh/uv/).

```bash
uv sync
uv run pytest
uv run ruff check .
```

## Releasing

Publishing to PyPI happens via [`.github/workflows/publish.yml`](.github/workflows/publish.yml),
triggered by a GitHub release. It uses [trusted publishing](https://docs.pypi.org/trusted-publishers/)
(OIDC), so no PyPI API token is stored in the repo.

1. Bump `version` in [`pyproject.toml`](pyproject.toml).
2. Commit and push to `main`.
3. Tag and create a release:
   ```bash
   git tag vX.Y.Z
   git push origin vX.Y.Z
   gh release create vX.Y.Z --title vX.Y.Z --generate-notes
   ```
4. Publishing the release triggers the workflow, which runs `uv build` and
   `uv publish`. Check the Actions tab for the run, then confirm at
   [pypi.org/project/torina](https://pypi.org/project/torina/).
