ctxlineage 0.1.0__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.
ctxlineage/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from ctxlineage._span import span
2
+ from ctxlineage._state import init
3
+
4
+ __all__ = ["init", "span", "__version__"]
5
+ __version__ = "0.0.1.dev0"
ctxlineage/_cli.py ADDED
@@ -0,0 +1,84 @@
1
+ """ctxlineage CLI (installed as `ctxlineage` and `ctxl`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import webbrowser
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from ctxlineage._report import html, normalize, redact
13
+
14
+
15
+ @click.group()
16
+ @click.version_option(package_name="ctxlineage")
17
+ def main() -> None:
18
+ """See exactly what context your LLM calls consumed."""
19
+
20
+
21
+ @main.command()
22
+ @click.option(
23
+ "--dir",
24
+ "-d",
25
+ "directory",
26
+ default=".ctxlineage",
27
+ show_default=True,
28
+ help="Directory containing events.jsonl.",
29
+ )
30
+ @click.option(
31
+ "--out",
32
+ "-o",
33
+ default="ctxlineage-report.html",
34
+ show_default=True,
35
+ help="Output HTML path.",
36
+ )
37
+ @click.option("--open", "open_browser", is_flag=True, help="Open the report in a browser.")
38
+ @click.option("--json", "as_json", is_flag=True, help="Print report data as JSON instead of HTML.")
39
+ @click.option(
40
+ "--redact",
41
+ "redact_patterns",
42
+ multiple=True,
43
+ metavar="PATTERN",
44
+ help="Regex; every match in prompt/output text becomes [redacted]. Repeatable. "
45
+ "Applied after matching, so token counts and match rates stay honest.",
46
+ )
47
+ def report(
48
+ directory: str,
49
+ out: str,
50
+ open_browser: bool,
51
+ as_json: bool,
52
+ redact_patterns: tuple[str, ...],
53
+ ) -> None:
54
+ """Build the HTML report from recorded events."""
55
+ events_path = Path(directory) / "events.jsonl"
56
+ if not events_path.exists():
57
+ raise click.ClickException(
58
+ f"No events found at {events_path}. "
59
+ "Run your app with ctxlineage.init() first (or pass --dir)."
60
+ )
61
+ events, skipped = normalize.load_events(events_path)
62
+ data = normalize.build_report_data(events)
63
+
64
+ redacted = 0
65
+ if redact_patterns:
66
+ try:
67
+ redacted = redact.apply(data, list(redact_patterns))
68
+ except re.error as exc:
69
+ raise click.ClickException(f"Invalid --redact pattern {exc.pattern!r}: {exc}") from exc
70
+
71
+ if as_json:
72
+ click.echo(json.dumps(data, ensure_ascii=False, indent=2))
73
+ return
74
+
75
+ out_path = Path(out)
76
+ out_path.write_text(html.render(data), encoding="utf-8")
77
+ summary = (
78
+ f"{data['stats']['calls']} call(s) across {data['stats']['sessions']} session(s)"
79
+ + (f", {skipped} malformed line(s) skipped" if skipped else "")
80
+ + (f", {redacted} match(es) redacted" if redact_patterns else "")
81
+ )
82
+ click.echo(f"Wrote {out_path} ({summary})")
83
+ if open_browser:
84
+ webbrowser.open(out_path.resolve().as_uri())
ctxlineage/_events.py ADDED
@@ -0,0 +1,66 @@
1
+ """Event construction and the append-only JSONL writer (schema v1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import threading
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ SCHEMA_VERSION = 1
13
+
14
+
15
+ def json_str(value) -> str:
16
+ """Single serialization policy for anything ctxlineage persists."""
17
+ return json.dumps(value, ensure_ascii=False, default=str)
18
+
19
+
20
+ def new_id() -> str:
21
+ return uuid.uuid4().hex
22
+
23
+
24
+ def utc_now_iso() -> str:
25
+ return datetime.now(timezone.utc).isoformat()
26
+
27
+
28
+ def make_event(
29
+ event_type: str,
30
+ session_id: str,
31
+ payload: dict,
32
+ *,
33
+ span_id: str | None = None,
34
+ call_id: str | None = None,
35
+ ) -> dict:
36
+ return {
37
+ "schema_version": SCHEMA_VERSION,
38
+ "event_type": event_type,
39
+ "session_id": session_id,
40
+ "span_id": span_id,
41
+ "call_id": call_id,
42
+ "timestamp": utc_now_iso(),
43
+ "payload": payload,
44
+ }
45
+
46
+
47
+ class EventWriter:
48
+ """Append-only JSONL writer. One event per line, no buffering.
49
+
50
+ Opens the file per write so a crash never loses buffered events and
51
+ multiple processes can append to the same file (POSIX append mode).
52
+ """
53
+
54
+ def __init__(self, directory: str | os.PathLike):
55
+ self._dir = Path(directory)
56
+ self.path = self._dir / "events.jsonl"
57
+ self._lock = threading.Lock()
58
+
59
+ def write(self, event: dict) -> None:
60
+ # via json_str: non-JSON values degrade to strings instead of
61
+ # losing the whole event.
62
+ line = json_str(event)
63
+ with self._lock:
64
+ self._dir.mkdir(parents=True, exist_ok=True)
65
+ with self.path.open("a", encoding="utf-8") as f:
66
+ f.write(line + "\n")
@@ -0,0 +1,29 @@
1
+ """SDK instrumentation orchestration. Each provider module is best-effort."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+
7
+ from ctxlineage._instrument import anthropic_patch, openai_patch
8
+
9
+ _installed_providers: list[str] | None = None
10
+ # Guards check-then-act on _installed_providers so two concurrent init() calls
11
+ # cannot both run the patch step and double-wrap every SDK method.
12
+ _install_lock = threading.Lock()
13
+
14
+
15
+ def install() -> list[str]:
16
+ """Patch every available SDK once per process. Returns installed provider names."""
17
+ global _installed_providers
18
+ if _installed_providers is not None:
19
+ return _installed_providers
20
+ with _install_lock:
21
+ if _installed_providers is not None: # another thread won the race
22
+ return _installed_providers
23
+ providers = []
24
+ if openai_patch.install():
25
+ providers.append("openai")
26
+ if anthropic_patch.install():
27
+ providers.append("anthropic")
28
+ _installed_providers = providers
29
+ return providers
@@ -0,0 +1,190 @@
1
+ """Shared plumbing for SDK patch modules.
2
+
3
+ Every provider patch stays thin: record kwargs wholesale, dump responses with
4
+ model_dump, pass unknown fields through untouched. Recording must never break
5
+ the host call: wrappers no-op when ctxlineage is unconfigured, and
6
+ _state.emit() already swallows write failures.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from collections.abc import Callable
13
+
14
+ import wrapt
15
+
16
+ from ctxlineage import _events, _state
17
+ from ctxlineage._stack import stack_summary
18
+
19
+
20
+ def base_payload(provider: str, api: str, kwargs: dict) -> dict:
21
+ return {
22
+ "provider": provider,
23
+ "api": api,
24
+ "request": dict(kwargs),
25
+ "stream": bool(kwargs.get("stream")),
26
+ "call_stack": stack_summary(),
27
+ }
28
+
29
+
30
+ def dump(obj):
31
+ try:
32
+ return obj.model_dump(mode="json")
33
+ except Exception:
34
+ return str(obj)
35
+
36
+
37
+ def finish_payload(payload: dict, start: float) -> dict:
38
+ payload["duration_ms"] = (time.monotonic() - start) * 1000
39
+ return payload
40
+
41
+
42
+ def record_response(payload: dict, result) -> None:
43
+ data = dump(result)
44
+ payload["response"] = data
45
+ payload["usage"] = data.get("usage") if isinstance(data, dict) else None
46
+ _state.emit("llm_call", payload, call_id=_events.new_id())
47
+
48
+
49
+ def record_error(payload: dict, exc: BaseException) -> None:
50
+ payload["error"] = {"type": type(exc).__name__, "message": str(exc)}
51
+ _state.emit("llm_call", payload, call_id=_events.new_id())
52
+
53
+
54
+ class StreamRecorderMixin:
55
+ """Shared chunk accounting; subclasses only differ in (a)sync plumbing."""
56
+
57
+ def _self_init(self, payload: dict, assemble: Callable[[list], dict], span_id=None) -> None:
58
+ self._self_payload = payload
59
+ self._self_assemble = assemble
60
+ self._self_span_id = span_id
61
+ self._self_chunks: list = []
62
+ self._self_done = False
63
+
64
+ def _self_add(self, chunk) -> None:
65
+ self._self_chunks.append(dump(chunk))
66
+
67
+ def _self_record_error(self, exc: BaseException) -> None:
68
+ # mid-stream failure (in-band SSE error, network drop): keep the
69
+ # partial assembly but mark the event so it is distinguishable from
70
+ # a client-side abandon. GeneratorExit is excluded at the call sites.
71
+ if not self._self_done:
72
+ self._self_payload["error"] = {"type": type(exc).__name__, "message": str(exc)}
73
+
74
+ def _self_finish(self) -> None:
75
+ if self._self_done:
76
+ return
77
+ self._self_done = True
78
+ try:
79
+ payload = self._self_payload
80
+ payload["response"] = self._self_assemble(self._self_chunks)
81
+ payload["usage"] = payload["response"].get("usage")
82
+ _state.emit("llm_call", payload, call_id=_events.new_id(), span_id=self._self_span_id)
83
+ except Exception as exc: # e.g. malformed chunk shapes: never raise into the host
84
+ _state.warn_once(
85
+ "stream_finish",
86
+ f"ctxlineage: failed to record a stream ({exc!r}); the stream itself is intact",
87
+ )
88
+
89
+
90
+ class StreamProxy(wrapt.ObjectProxy, StreamRecorderMixin):
91
+ """Recording proxy over an SDK stream.
92
+
93
+ Dunder protocol methods must be defined here: ObjectProxy does not fill
94
+ type slots, so builtins like next()/anext() bypass __getattr__ delegation
95
+ and would raise TypeError without explicit definitions. Known limitation:
96
+ isinstance(proxy, SDKStreamClass) is False when the SDK class uses a
97
+ custom metaclass __instancecheck__ (anthropic.Stream does).
98
+ """
99
+
100
+ def __init__(self, wrapped, payload: dict, assemble: Callable[[list], dict], span_id=None):
101
+ super().__init__(wrapped)
102
+ self._self_init(payload, assemble, span_id)
103
+
104
+ def __iter__(self):
105
+ try:
106
+ for chunk in self.__wrapped__:
107
+ self._self_add(chunk)
108
+ yield chunk
109
+ except Exception as exc: # not GeneratorExit: abandonment is not an error
110
+ self._self_record_error(exc)
111
+ raise
112
+ finally:
113
+ self._self_finish()
114
+
115
+ def __next__(self):
116
+ try:
117
+ chunk = self.__wrapped__.__next__()
118
+ except StopIteration:
119
+ self._self_finish()
120
+ raise
121
+ except Exception as exc:
122
+ self._self_record_error(exc)
123
+ self._self_finish()
124
+ raise
125
+ self._self_add(chunk)
126
+ return chunk
127
+
128
+ def __enter__(self):
129
+ self.__wrapped__.__enter__()
130
+ return self
131
+
132
+ def __exit__(self, *exc):
133
+ try:
134
+ return self.__wrapped__.__exit__(*exc)
135
+ finally:
136
+ self._self_finish()
137
+
138
+ def close(self):
139
+ try:
140
+ return self.__wrapped__.close()
141
+ finally:
142
+ self._self_finish()
143
+
144
+
145
+ class AsyncStreamProxy(wrapt.ObjectProxy, StreamRecorderMixin):
146
+ """Async twin of StreamProxy; see its docstring for the dunder rationale."""
147
+
148
+ def __init__(self, wrapped, payload: dict, assemble: Callable[[list], dict], span_id=None):
149
+ super().__init__(wrapped)
150
+ self._self_init(payload, assemble, span_id)
151
+
152
+ async def __aiter__(self):
153
+ try:
154
+ async for chunk in self.__wrapped__:
155
+ self._self_add(chunk)
156
+ yield chunk
157
+ except Exception as exc: # not GeneratorExit: abandonment is not an error
158
+ self._self_record_error(exc)
159
+ raise
160
+ finally:
161
+ self._self_finish()
162
+
163
+ async def __anext__(self):
164
+ try:
165
+ chunk = await self.__wrapped__.__anext__()
166
+ except StopAsyncIteration:
167
+ self._self_finish()
168
+ raise
169
+ except Exception as exc:
170
+ self._self_record_error(exc)
171
+ self._self_finish()
172
+ raise
173
+ self._self_add(chunk)
174
+ return chunk
175
+
176
+ async def __aenter__(self):
177
+ await self.__wrapped__.__aenter__()
178
+ return self
179
+
180
+ async def __aexit__(self, *exc):
181
+ try:
182
+ return await self.__wrapped__.__aexit__(*exc)
183
+ finally:
184
+ self._self_finish()
185
+
186
+ async def close(self):
187
+ try:
188
+ return await self.__wrapped__.close()
189
+ finally:
190
+ self._self_finish()
@@ -0,0 +1,221 @@
1
+ """anthropic SDK instrumentation (Messages API, incl. both streaming paths).
2
+
3
+ Shared design constraints live in _common.py. Two streaming surfaces exist:
4
+
5
+ - messages.create(stream=True) returns a raw Stream[RawMessageStreamEvent],
6
+ wrapped in the same recording proxy the openai patch uses.
7
+ - messages.stream() never goes through create(): it returns a manager that
8
+ fires the HTTP request in __enter__ and wraps the raw stream in a
9
+ MessageStream. The manager proxy below swaps that MessageStream's
10
+ _raw_stream for a recording proxy, so every consumption path (iteration,
11
+ text_stream, get_final_message) and MessageStream.close() flow through it
12
+ and both streaming paths share one raw-event assembler.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import threading
18
+ import time
19
+
20
+ import wrapt
21
+
22
+ from ctxlineage import _span, _state
23
+ from ctxlineage._instrument._common import (
24
+ AsyncStreamProxy,
25
+ StreamProxy,
26
+ base_payload,
27
+ finish_payload,
28
+ record_error,
29
+ record_response,
30
+ )
31
+
32
+ _PATCHED = False
33
+ # Guards check-then-act on _PATCHED so concurrent installs wrap each method once.
34
+ _install_lock = threading.Lock()
35
+
36
+
37
+ def install() -> bool:
38
+ global _PATCHED
39
+ if _PATCHED:
40
+ return True
41
+ with _install_lock:
42
+ if _PATCHED: # another thread won the race
43
+ return True
44
+ try:
45
+ import anthropic # noqa: F401
46
+ except ImportError:
47
+ return False
48
+ wrapt.wrap_function_wrapper("anthropic.resources.messages", "Messages.create", _sync_create)
49
+ wrapt.wrap_function_wrapper(
50
+ "anthropic.resources.messages", "AsyncMessages.create", _async_create
51
+ )
52
+ wrapt.wrap_function_wrapper("anthropic.resources.messages", "Messages.stream", _sync_stream)
53
+ wrapt.wrap_function_wrapper(
54
+ "anthropic.resources.messages", "AsyncMessages.stream", _async_stream
55
+ )
56
+ _PATCHED = True
57
+ return True
58
+
59
+
60
+ def _sync_create(wrapped, instance, args, kwargs):
61
+ if not _state.is_configured():
62
+ return wrapped(*args, **kwargs)
63
+ payload = base_payload("anthropic", "messages", kwargs)
64
+ start = time.monotonic()
65
+ try:
66
+ result = wrapped(*args, **kwargs)
67
+ except Exception as exc:
68
+ record_error(finish_payload(payload, start), exc)
69
+ raise
70
+ finish_payload(payload, start)
71
+ if payload["stream"]:
72
+ # streams may be consumed after the span exits: bind the span now
73
+ return StreamProxy(result, payload, _assemble_messages, _span.current_id())
74
+ record_response(payload, result)
75
+ return result
76
+
77
+
78
+ async def _async_create(wrapped, instance, args, kwargs):
79
+ if not _state.is_configured():
80
+ return await wrapped(*args, **kwargs)
81
+ payload = base_payload("anthropic", "messages", kwargs)
82
+ start = time.monotonic()
83
+ try:
84
+ result = await wrapped(*args, **kwargs)
85
+ except Exception as exc:
86
+ record_error(finish_payload(payload, start), exc)
87
+ raise
88
+ finish_payload(payload, start)
89
+ if payload["stream"]:
90
+ # streams may be consumed after the span exits: bind the span now
91
+ return AsyncStreamProxy(result, payload, _assemble_messages, _span.current_id())
92
+ record_response(payload, result)
93
+ return result
94
+
95
+
96
+ def _sync_stream(wrapped, instance, args, kwargs):
97
+ if not _state.is_configured():
98
+ return wrapped(*args, **kwargs)
99
+ payload = base_payload("anthropic", "messages", kwargs)
100
+ payload["stream"] = True # .stream() takes no stream kwarg
101
+ return _ManagerProxy(wrapped(*args, **kwargs), payload, _span.current_id())
102
+
103
+
104
+ def _async_stream(wrapped, instance, args, kwargs):
105
+ # AsyncMessages.stream is a sync method returning an async manager
106
+ if not _state.is_configured():
107
+ return wrapped(*args, **kwargs)
108
+ payload = base_payload("anthropic", "messages", kwargs)
109
+ payload["stream"] = True
110
+ return _AsyncManagerProxy(wrapped(*args, **kwargs), payload, _span.current_id())
111
+
112
+
113
+ class _ManagerProxy(wrapt.ObjectProxy):
114
+ """Records through a MessageStreamManager; the request only fires in __enter__."""
115
+
116
+ def __init__(self, wrapped, payload: dict, span_id=None):
117
+ super().__init__(wrapped)
118
+ self._self_payload = payload
119
+ self._self_span_id = span_id
120
+
121
+ def __enter__(self):
122
+ start = time.monotonic()
123
+ try:
124
+ stream = self.__wrapped__.__enter__()
125
+ except Exception as exc:
126
+ record_error(finish_payload(self._self_payload, start), exc)
127
+ raise
128
+ finish_payload(self._self_payload, start)
129
+ # MessageStream consumes lazily, so the fresh _raw_stream can be
130
+ # swapped for a recording proxy; MessageStream.close() closes it too.
131
+ # _raw_stream is a private SDK attribute: if the swap fails, hand back
132
+ # the untouched stream (unrecorded) rather than break the host app.
133
+ try:
134
+ stream._raw_stream = StreamProxy(
135
+ stream._raw_stream, self._self_payload, _assemble_messages, self._self_span_id
136
+ )
137
+ except Exception as exc:
138
+ _state.warn_once(
139
+ "anthropic_raw_stream_swap",
140
+ f"ctxlineage: could not instrument messages.stream() ({exc!r}); "
141
+ "this stream will not be recorded",
142
+ )
143
+ return stream
144
+
145
+ def __exit__(self, *exc):
146
+ return self.__wrapped__.__exit__(*exc)
147
+
148
+
149
+ class _AsyncManagerProxy(wrapt.ObjectProxy):
150
+ """Async twin of _ManagerProxy (AsyncMessageStreamManager)."""
151
+
152
+ def __init__(self, wrapped, payload: dict, span_id=None):
153
+ super().__init__(wrapped)
154
+ self._self_payload = payload
155
+ self._self_span_id = span_id
156
+
157
+ async def __aenter__(self):
158
+ start = time.monotonic()
159
+ try:
160
+ stream = await self.__wrapped__.__aenter__()
161
+ except Exception as exc:
162
+ record_error(finish_payload(self._self_payload, start), exc)
163
+ raise
164
+ finish_payload(self._self_payload, start)
165
+ # See _ManagerProxy.__enter__: a failed swap degrades to no recording.
166
+ try:
167
+ stream._raw_stream = AsyncStreamProxy(
168
+ stream._raw_stream, self._self_payload, _assemble_messages, self._self_span_id
169
+ )
170
+ except Exception as exc:
171
+ _state.warn_once(
172
+ "anthropic_raw_stream_swap",
173
+ f"ctxlineage: could not instrument messages.stream() ({exc!r}); "
174
+ "this stream will not be recorded",
175
+ )
176
+ return stream
177
+
178
+ async def __aexit__(self, *exc):
179
+ return await self.__wrapped__.__aexit__(*exc)
180
+
181
+
182
+ def _merge_usage(usage: dict, new) -> None:
183
+ # message_delta's usage model dumps unset fields as None (e.g. input_tokens);
184
+ # overlaying those verbatim would erase message_start's real counts.
185
+ if isinstance(new, dict):
186
+ usage.update({k: v for k, v in new.items() if v is not None})
187
+
188
+
189
+ def _assemble_messages(chunks: list) -> dict:
190
+ """Reduce raw Messages stream events into one response-like summary."""
191
+ content: dict[int, str] = {}
192
+ usage: dict = {}
193
+ stop_reason = None
194
+ message_id = None
195
+ model = None
196
+ for chunk in chunks:
197
+ if not isinstance(chunk, dict):
198
+ continue
199
+ kind = chunk.get("type", "")
200
+ if kind == "message_start":
201
+ message = chunk.get("message") or {}
202
+ message_id = message.get("id") or message_id
203
+ model = message.get("model") or model
204
+ _merge_usage(usage, message.get("usage"))
205
+ elif kind == "content_block_delta":
206
+ delta = chunk.get("delta") or {}
207
+ if delta.get("type") == "text_delta" and delta.get("text"):
208
+ index = chunk.get("index", 0)
209
+ content[index] = content.get(index, "") + delta["text"]
210
+ elif kind == "message_delta":
211
+ _merge_usage(usage, chunk.get("usage"))
212
+ stop_reason = (chunk.get("delta") or {}).get("stop_reason") or stop_reason
213
+ return {
214
+ "object": "message.assembled",
215
+ "id": message_id,
216
+ "model": model,
217
+ "content": {str(i): text for i, text in content.items()},
218
+ "stop_reason": stop_reason,
219
+ "usage": usage or None,
220
+ "chunk_count": len(chunks),
221
+ }