Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Trace/envelope `dims`**: flat `dict[str, str]` attributes on every envelope.
Pass trace-level dims via `chronicle.record(..., dims={...})`; they are copied
onto each span. Envelope-specific dims (e.g. `model_version`) merge on top.
File storage only for now (JSONL / fixture export). Lookup by dims belongs on
the shared control plane / dashboard, not in this library.
- **OTel-style nest parents**: boundaries open a span on the Context stack before
the body runs (`start_span` / `end_span`), so nested calls set
`parent_envelope_id` to the active parent (not the last finished envelope).
Optional debug helpers: `ExecutionGraph.to_otel_tree()` /
`to_otel_waterfall()` (product UI stays shared with TokenOps / the plane).
- **Dev / CI extras**: `[dev]` installs `[otel]` (OpenTelemetry + OpenInference)
instead of full `[phoenix]`, so `arize-phoenix` is not pulled into pytest
collection (its pytest plugin has been crashing CI on Python 3.11). Use
`pip install agent-chronicle[phoenix]` when you want the Phoenix collector/UI.
- **`CHRONICLE_ENABLED`**: set to `0` / `false` / `off` / `no` to turn off LIVE
recording. `@boundary`, `wrap`, `wrap_llm`, `record()`, and `EnvelopeRecorder`
become passthrough so an agent can be run with and without Chronicle. Replay is
Expand Down
74 changes: 58 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,16 @@ walks you from install to a committed regression test.
**[Why](#why-chronicle) · [Quick start](#quick-start) · [Cut-point replay](#cut-point-replay) · [Recording](#recording-entry-points) · [Verification](#verification-layers) · [Compare](#how-chronicle-compares) · [Demos](#demos) · [FAQ](#faq) · [Roadmap](#roadmap)**

<details>
<summary><b>Key terms</b> (boundary, Envelope, trace, fixture, stub, live, cut-point)</summary>
<summary><b>Key terms</b> (boundary, Envelope, trace, dims, fixture, stub, live, cut-point)</summary>

<br>

| Term | What it means |
|---|---|
| **Boundary** | A decision point you mark: an LLM call, a tool call, or a routing choice. You choose which functions are boundaries. |
| **Envelope** | The immutable record of one boundary crossing: its input, its output, and metadata. It records I/O, not the side effects inside the function. |
| **Trace** | One whole run, as an ordered set of Envelopes. |
| **Boundary** | A decision **node** you mark: an LLM call, a tool call, or a routing choice — not the whole agent process. Orchestration stays plain code. |
| **Envelope** | The immutable record of one boundary crossing: its input, its output, and metadata. It records I/O, not the side effects inside the function. OTel: one **span**. |
| **Trace** | One whole run (typically one message turn), as an ordered set of Envelopes sharing a `trace_id`. |
| **Dims** | Flat `dict[str, str]` attributes on each envelope (e.g. `session_id`, `message_id`). Trace-level dims are passed into `record(...)` and copied onto every span. |
| **Fixture** | A trace committed to git under `fixtures/traces/`. Your permanent, replayable incident. |
| **Stub** | On replay, hand back a boundary's recorded output *without running its code*. |
| **Live** | Run the boundary's real code (to record it, or, on replay, to run your new code). |
Expand Down Expand Up @@ -93,6 +94,7 @@ deterministic.
| Input state | Assembled prompt, graph state, retrieved context chunks |
| Action / result | Structured tool calls and model completion |
| Graph linkage | `parent_envelope_id`, `sequence`, `invocation_index` for retries |
| Dims | Flat `dict[str, str]` (trace-level via `record(..., dims=...)`, plus span attrs like `model_version`) |

## Install

Expand Down Expand Up @@ -122,26 +124,32 @@ client.chat.completions.create(model="gpt-4o", messages=[...]) # recorded
> boundary (a real file delete, an API POST, a DB write) are **not** captured, so on
> replay a stubbed boundary returns the recorded output without firing them again.

**2. Or mark your own boundaries** with `@boundary`, for exact control over what counts
as an LLM, tool, or routing decision:
**2. Or mark your own boundaries** with `@boundary` on **decision nodes** (LLM,
tool, router) — not on the full agent runner. Nested calls inside a node span
parent to that node automatically (OTel-style `parent_span_id`):

```python
from chronicle import boundary

@boundary("agent", kind="llm")
def agent_plan(state: dict) -> dict:
@boundary("planner", kind="llm")
def planner(task: str) -> dict:
...

@boundary("delete_file", kind="tool")
def delete_file(path: str, environment: str) -> dict:
...

def run_agent(task: str) -> dict: # plain orchestration — no @boundary
plan = planner(task)
...
```

`@boundary` works on `async def` too, and it is **transparent**: it never changes what
your function returns or raises. A bare `@boundary` records the call by argument name,
so extractors are an optional way to trim payloads, never a requirement.

**3. Record a run and freeze it as a committed fixture** in one block:
**3. Record a run** (optional product dims for later lookup) **and freeze it as a
committed fixture** in one block:

```python
import chronicle
Expand All @@ -150,10 +158,29 @@ with chronicle.record(
"incident-001",
store=".chronicle/runs/incident.jsonl", # raw run, gitignored
export="fixtures/traces/incident-001/", # the committed fixture you keep
dims={ # flat string attrs on every envelope
"session_id": "sess_abc",
"message_id": "msg_042", # one trace ≈ one message turn
"user_id": "u1",
},
):
run_agent(...)
```

### Attribution (session / message)

Chronicle does **not** own chat history or Session↔Message storage. Pass ids as
`dims` so a control plane or dashboard can resolve feedback → trace later:

| Scenario | Dims to pass |
|---|---|
| Multi-turn chat | Same `session_id`, new `message_id` (and new `trace_id`) per user turn |
| Single-shot / S2S | `session_id` (and optional `caller_id` / `caller_type`) |

One Chronicle **trace** is one agent run — typically one message turn. Nested LLM
and tool envelopes under a graph node share that `trace_id` and parent to the
active node span.

### Which entry point should I record with?

Pick by what you already have. All four produce the **same Envelopes** and replay the
Expand Down Expand Up @@ -430,8 +457,9 @@ with chronicle.record("run-1") as session:
run_agent(...)
```

Call it inside the `record` block, or pass `session=`. Needs the OTel extra
(`pip install agent-chronicle[phoenix]`); the base install imports no OpenTelemetry.
Call it inside the `record` block, or pass `session=`. Needs the OTel SDK
(`pip install agent-chronicle[otel]`, or `[phoenix]` if you also want the Phoenix
collector/UI). The base install imports no OpenTelemetry.

</details>

Expand Down Expand Up @@ -512,11 +540,25 @@ plugin, and a docs site). Shape priorities in
<details>
<summary><b>What counts as a boundary, and how many should I add?</b></summary>

A boundary is any decision point you want to be able to freeze and replay: an LLM call,
a tool or function call, or a routing choice. You do not need to wrap everything. Start
with the calls you would actually assert on in a test: the model call that decides an
action, and each tool that has a real effect (a write, a payment, a delete). A boundary
you never stub or assert on just adds an envelope, so add them where a test would look.
A boundary is any **decision node** you want to freeze and replay: an LLM call, a
tool call, or a routing choice. Mark those nodes — not the whole `run_agent`
process. Orchestration stays plain code; if a graph node calls an LLM and a tool
while its span is open, those children parent under the node automatically.

Start with the calls you would assert on in a test: the model call that decides an
action, and each tool that has a real effect (a write, a payment, a delete). A
boundary you never stub or assert on just adds an envelope, so add them where a
test would look.
</details>

<details>
<summary><b>How do I correlate a production session / message with a Chronicle trace?</b></summary>

Pass flat string `dims` into `chronicle.record(...)`, e.g. `session_id` and
`message_id`. They are copied onto every envelope in that run. One trace ≈ one
message turn; multi-turn chats reuse `session_id` and mint a new `message_id` (and
trace) per turn. Chronicle does not store chat history — your app or dashboard maps
feedback to those ids, then to `trace_id`.
</details>

<details>
Expand Down
7 changes: 6 additions & 1 deletion chronicle/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def record(
redactors: list[Callable[[str], str]] | None = None,
export: str | Path | None = None,
retain_envelopes: bool = True,
dims: dict[str, str] | None = None,
) -> Iterator[ChronicleSession]:
"""Record a run in one block.

Expand All @@ -39,6 +40,7 @@ def record(
"incident-001",
store=".chronicle/runs/incident.jsonl",
export="fixtures/traces/incident-001/",
dims={"session_id": "sess_abc", "user_id": "u1"},
) as session:
run_agent(...)

Expand All @@ -48,6 +50,9 @@ def record(

Set ``retain_envelopes=False`` when you only need the store write (skips the
in-session list; ``export_trace`` will be empty).

``dims`` are trace-level flat string attributes copied onto every recorded
envelope (OTel-style resource/span attributes).
"""
session = reset_session()
if not is_enabled():
Expand All @@ -65,7 +70,7 @@ def record(
if redactors is not None:
session.redactors = redactors
session.retain_envelopes = retain_envelopes
session.begin_trace(trace_id)
session.begin_trace(trace_id, dims=dims)
try:
yield session
finally:
Expand Down
45 changes: 38 additions & 7 deletions chronicle/boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,15 +210,24 @@ def _record_call(
):
input_state = _capture_input(fn, args, kwargs, extract_input, cached_sig)
call_kwargs, entered = _apply_on_enter(session, boundary_id, kind, input_state, kwargs)
# Open the span before the body so nested boundaries parent here (OTel Context).
span_id, parent_id = session.start_span()
try:
try:
result = fn(*args, **call_kwargs)
except Exception as exc:
_record_failure(session, boundary_id, kind, input_state, exc)
_record_failure(
session, boundary_id, kind, input_state, exc,
envelope_id=span_id, parent_envelope_id=parent_id,
)
raise
_record_success(session, boundary_id, kind, input_state, result, extract_result, extract_metadata)
_record_success(
session, boundary_id, kind, input_state, result, extract_result, extract_metadata,
envelope_id=span_id, parent_envelope_id=parent_id,
)
return result
finally:
session.end_span()
_run_on_leave(session, boundary_id, kind, input_state, entered)


Expand All @@ -228,39 +237,61 @@ async def _record_call_async(
):
input_state = _capture_input(fn, args, kwargs, extract_input, cached_sig)
call_kwargs, entered = _apply_on_enter(session, boundary_id, kind, input_state, kwargs)
span_id, parent_id = session.start_span()
try:
try:
result = await fn(*args, **call_kwargs)
except Exception as exc:
_record_failure(session, boundary_id, kind, input_state, exc)
_record_failure(
session, boundary_id, kind, input_state, exc,
envelope_id=span_id, parent_envelope_id=parent_id,
)
raise
_record_success(session, boundary_id, kind, input_state, result, extract_result, extract_metadata)
_record_success(
session, boundary_id, kind, input_state, result, extract_result, extract_metadata,
envelope_id=span_id, parent_envelope_id=parent_id,
)
return result
finally:
session.end_span()
_run_on_leave(session, boundary_id, kind, input_state, entered)


def _record_success(session, boundary_id, kind, input_state, result, extract_result, extract_metadata):
def _record_success(
session, boundary_id, kind, input_state, result, extract_result, extract_metadata,
*,
envelope_id: str | None = None,
parent_envelope_id: str | None = None,
):
"""Record the envelope, then notify observers. Never touches the return value."""
recorded = extract_result(result) if extract_result else result
action_result = result_to_action_result(recorded, kind)
model_version, sampling_params = _call_metadata(recorded, kind, extract_metadata)
session.record_envelope(
boundary_id, kind, input_state, action_result,
model_version=model_version, sampling_params=sampling_params,
envelope_id=envelope_id, parent_envelope_id=parent_envelope_id,
)
if session.on_crossing is not None:
session.on_crossing(boundary_id, kind, input_state, result)


def _record_failure(session, boundary_id, kind, input_state, exc):
def _record_failure(
session, boundary_id, kind, input_state, exc,
*,
envelope_id: str | None = None,
parent_envelope_id: str | None = None,
):
"""Record a failed crossing so incidents that raise are still reproducible."""
action_result = ActionResult(
error=str(exc),
error_type=type(exc).__name__,
finish_reason="error",
)
session.record_envelope(boundary_id, kind, input_state, action_result)
session.record_envelope(
boundary_id, kind, input_state, action_result,
envelope_id=envelope_id, parent_envelope_id=parent_envelope_id,
)


def _call_metadata(result, kind, extract_metadata):
Expand Down
20 changes: 20 additions & 0 deletions chronicle/envelope/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ class Envelope(BaseModel):

Every envelope captures contextual metadata, input state, and action/result
at the intersection of agent nodes — the "flight data" of the agent.

OTel mapping: ``trace_id`` is the Trace; ``envelope_id`` is the Span id;
``parent_envelope_id`` is ``parent_span_id``. ``dims`` are flat string
attributes (trace-level dims are copied onto every span at record time;
envelope-level dims are span-specific).
"""

schema_version: str = "1.0"
Expand All @@ -105,15 +110,30 @@ class Envelope(BaseModel):
parent_envelope_id: str | None = None
sequence: int = 0
invocation_index: int = 1
# End time (when the envelope was written). Prefer ``started_at`` for span start.
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# Span start (OTel). None on pre-nest fixtures; waterfall falls back to timestamp.
started_at: datetime | None = None
metadata: ContextMetadata
input_state: InputState
action_result: ActionResult
# Flat string→string attributes (OTel-style). Missing on pre-0.4 fixtures.
dims: dict[str, str] = Field(default_factory=dict)

@property
def boundary_id(self) -> str:
return self.node_id

@property
def span_id(self) -> str:
"""OTel alias for ``envelope_id``."""
return self.envelope_id

@property
def parent_span_id(self) -> str | None:
"""OTel alias for ``parent_envelope_id``."""
return self.parent_envelope_id

@field_validator("timestamp", mode="before")
@classmethod
def _ensure_utc(cls, v: datetime | str) -> datetime:
Expand Down
Loading
Loading