bitfrost 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.
- bitfrost/__init__.py +60 -0
- bitfrost/_brand.py +196 -0
- bitfrost/_readers.py +260 -0
- bitfrost/attribute_mapper.py +383 -0
- bitfrost/backends/__init__.py +24 -0
- bitfrost/backends/base.py +41 -0
- bitfrost/backends/console.py +131 -0
- bitfrost/backends/jsonl.py +114 -0
- bitfrost/backends/otlp.py +144 -0
- bitfrost/backends/sqlite.py +308 -0
- bitfrost/backends/tee.py +122 -0
- bitfrost/backends/voight.py +125 -0
- bitfrost/cli.py +366 -0
- bitfrost/console_renderer.py +321 -0
- bitfrost/conventions.py +171 -0
- bitfrost/exporter.py +191 -0
- bitfrost/identity.py +112 -0
- bitfrost/ingest.py +337 -0
- bitfrost/instrument.py +557 -0
- bitfrost/pricing.py +140 -0
- bitfrost/privacy.py +348 -0
- bitfrost/serve/__init__.py +52 -0
- bitfrost/serve/api.py +493 -0
- bitfrost/serve/app.py +58 -0
- bitfrost/serve/static/app.js +278 -0
- bitfrost/serve/static/chart.min.js +20 -0
- bitfrost/serve/static/favicon.svg +19 -0
- bitfrost/serve/static/index.html +136 -0
- bitfrost/serve/static/styles.css +167 -0
- bitfrost/tui.py +232 -0
- bitfrost/types.py +142 -0
- bitfrost-0.1.0.dist-info/METADATA +223 -0
- bitfrost-0.1.0.dist-info/RECORD +36 -0
- bitfrost-0.1.0.dist-info/WHEEL +4 -0
- bitfrost-0.1.0.dist-info/entry_points.txt +2 -0
- bitfrost-0.1.0.dist-info/licenses/LICENSE +21 -0
bitfrost/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Bitfrost — drop-in OpenTelemetry observability for Python LLM apps.
|
|
2
|
+
|
|
3
|
+
Made by `Voight <https://voight.xyz>`_. Licensed under MIT.
|
|
4
|
+
|
|
5
|
+
Public API
|
|
6
|
+
----------
|
|
7
|
+
|
|
8
|
+
.. code-block:: python
|
|
9
|
+
|
|
10
|
+
from bitfrost import BitfrostOptions, EventPayload, PrivacyLevel
|
|
11
|
+
|
|
12
|
+
Backends ship in :mod:`bitfrost.backends`:
|
|
13
|
+
|
|
14
|
+
.. code-block:: python
|
|
15
|
+
|
|
16
|
+
from bitfrost.backends.console import ConsoleBackend
|
|
17
|
+
from bitfrost.backends.voight import VoightBackend
|
|
18
|
+
from bitfrost.backends.otlp import OTLPBackend
|
|
19
|
+
from bitfrost.backends.jsonl import JSONLBackend
|
|
20
|
+
from bitfrost.backends.sqlite import SQLiteBackend
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from bitfrost.instrument import (
|
|
26
|
+
instrument_anthropic,
|
|
27
|
+
instrument_auto,
|
|
28
|
+
instrument_litellm,
|
|
29
|
+
instrument_openai,
|
|
30
|
+
instrument_smolagents,
|
|
31
|
+
quickstart,
|
|
32
|
+
)
|
|
33
|
+
from bitfrost.types import (
|
|
34
|
+
BitfrostOptions,
|
|
35
|
+
EventPayload,
|
|
36
|
+
EventType,
|
|
37
|
+
Outcome,
|
|
38
|
+
PrivacyLevel,
|
|
39
|
+
TokenBreakdown,
|
|
40
|
+
ToolCallRecord,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__version__ = "0.1.0"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"BitfrostOptions",
|
|
47
|
+
"EventPayload",
|
|
48
|
+
"EventType",
|
|
49
|
+
"Outcome",
|
|
50
|
+
"PrivacyLevel",
|
|
51
|
+
"TokenBreakdown",
|
|
52
|
+
"ToolCallRecord",
|
|
53
|
+
"__version__",
|
|
54
|
+
"instrument_anthropic",
|
|
55
|
+
"instrument_auto",
|
|
56
|
+
"instrument_litellm",
|
|
57
|
+
"instrument_openai",
|
|
58
|
+
"instrument_smolagents",
|
|
59
|
+
"quickstart",
|
|
60
|
+
]
|
bitfrost/_brand.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Shared brand identity for Bitfrost surfaces (CLI, TUI, web dashboard).
|
|
2
|
+
|
|
3
|
+
One source of truth for the wordmark, the colour palette, and the
|
|
4
|
+
welcome splash so the three surfaces read as the same product. The web
|
|
5
|
+
frontend mirrors :data:`PALETTE` in its CSS (CSS can't import Python);
|
|
6
|
+
keep them in sync.
|
|
7
|
+
|
|
8
|
+
The name nods to the Bifrost — the rainbow bridge of Norse myth that
|
|
9
|
+
links worlds — which is what an exporter does: bridge your LLM runtime
|
|
10
|
+
to wherever you watch it. The splash leads with that bridge: a small
|
|
11
|
+
iridescent arch (the only colourful element) beside a metallic wordmark.
|
|
12
|
+
|
|
13
|
+
The arch + wordmark art below were pre-rendered once (PIL + pyfiglet) and
|
|
14
|
+
embedded as plain strings — neither library is a runtime dependency. The
|
|
15
|
+
gradients are applied at draw time with pure arithmetic via ``rich``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
|
|
22
|
+
# Palette — dark, platinum-forward, with a teal→violet "bridge" accent.
|
|
23
|
+
# Mirrored in src/bitfrost/serve/static/styles.css (:root variables).
|
|
24
|
+
PALETTE = {
|
|
25
|
+
"bg": "#0c0e0d", # near-black canvas
|
|
26
|
+
"surface": "#14171a", # card
|
|
27
|
+
"surface2": "#1a1e22", # raised card / hover
|
|
28
|
+
"border": "#23282d",
|
|
29
|
+
"fg": "#e6e7ea", # platinum text
|
|
30
|
+
"fg_muted": "#8b9197",
|
|
31
|
+
"accent": "#5eead4", # teal — primary accent
|
|
32
|
+
"accent2": "#a78bfa", # violet — bridge gradient end
|
|
33
|
+
"success": "#4ade80",
|
|
34
|
+
"failed": "#f87171",
|
|
35
|
+
"pending": "#fbbf24",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Metallic platinum stops (logo's silver band) — for the wordmark, top→bottom.
|
|
39
|
+
_METAL: list[tuple[int, int, int]] = [
|
|
40
|
+
(0xF6, 0xF6, 0xFB),
|
|
41
|
+
(0xC7, 0xC8, 0xD2),
|
|
42
|
+
(0xB9, 0xBA, 0xC6),
|
|
43
|
+
(0x9A, 0x9B, 0xA7),
|
|
44
|
+
(0x8A, 0x8B, 0x97),
|
|
45
|
+
]
|
|
46
|
+
# Iridescent stops (logo's rainbow inner line) — for the arch, left→right.
|
|
47
|
+
_IRID: list[tuple[int, int, int]] = [
|
|
48
|
+
(0x8B, 0x7B, 0xF0),
|
|
49
|
+
(0x46, 0xC5, 0x85),
|
|
50
|
+
(0xEA, 0xBF, 0x4E),
|
|
51
|
+
(0xEF, 0x5A, 0x6A),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# The logo arch, rasterised to braille (pre-rendered). The ONLY colourful
|
|
55
|
+
# element in the splash — coloured left→right with the iridescent stops.
|
|
56
|
+
ARCH_BRAILLE: list[str] = [
|
|
57
|
+
" ⢀⣠⣤⣶⠶⠾⠿⠿⠷⠶⣶⣦⣄⡀ ",
|
|
58
|
+
" ⣴⡿⠋⠁ ⠙⢿⣦ ",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
# Wordmark (pyfiglet 'ansi_shadow', pre-rendered) — coloured top→bottom
|
|
62
|
+
# with the metallic stops.
|
|
63
|
+
WORDMARK: list[str] = [
|
|
64
|
+
"██████╗ ██╗████████╗███████╗██████╗ ██████╗ ███████╗████████╗",
|
|
65
|
+
"██╔══██╗██║╚══██╔══╝██╔════╝██╔══██╗██╔═══██╗██╔════╝╚══██╔══╝",
|
|
66
|
+
"██████╔╝██║ ██║ █████╗ ██████╔╝██║ ██║███████╗ ██║ ",
|
|
67
|
+
"██╔══██╗██║ ██║ ██╔══╝ ██╔══██╗██║ ██║╚════██║ ██║ ",
|
|
68
|
+
"██████╔╝██║ ██║ ██║ ██║ ██║╚██████╔╝███████║ ██║ ",
|
|
69
|
+
"╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# Plain-text fallback wordmark for when ``rich`` isn't installed.
|
|
73
|
+
BANNER = r"""
|
|
74
|
+
┌┐ ┬┌┬┐┌─┐┬─┐┌─┐┌─┐┌┬┐
|
|
75
|
+
├┴┐│ │ ├┤ ├┬┘│ │└─┐ │
|
|
76
|
+
└─┘┴ ┴ └ ┴└─└─┘└─┘ ┴ by voight.xyz
|
|
77
|
+
═══════════════════════ bridge your LLM telemetry
|
|
78
|
+
""".strip("\n")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def banner(tagline: str | None = None) -> str:
|
|
82
|
+
"""Return the plain-text wordmark, optionally replacing the tagline."""
|
|
83
|
+
|
|
84
|
+
if tagline is None:
|
|
85
|
+
return BANNER
|
|
86
|
+
lines = BANNER.splitlines()
|
|
87
|
+
lines[-1] = "═" * 23 + " " + tagline
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _hex_at(fraction: float, stops: Sequence[tuple[int, int, int]]) -> str:
|
|
92
|
+
"""Interpolate a multi-stop gradient and return a ``#rrggbb`` string."""
|
|
93
|
+
|
|
94
|
+
f = max(0.0, min(1.0, fraction))
|
|
95
|
+
seg = f * (len(stops) - 1)
|
|
96
|
+
i = int(seg)
|
|
97
|
+
if i >= len(stops) - 1:
|
|
98
|
+
r, g, b = stops[-1]
|
|
99
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
100
|
+
t = seg - i
|
|
101
|
+
a, c = stops[i], stops[i + 1]
|
|
102
|
+
r, g, b = (round(a[k] + (c[k] - a[k]) * t) for k in range(3))
|
|
103
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def render_splash(*, full: bool = True, console: object | None = None) -> None:
|
|
107
|
+
"""Print the Bitfrost welcome splash to the terminal.
|
|
108
|
+
|
|
109
|
+
Shown only at startup — bare ``bitfrost`` (full), and ``watch`` /
|
|
110
|
+
``serve`` (compact: lockup only, ``full=False``). Uses ``rich`` for
|
|
111
|
+
the gradient lockup + info panel; if ``rich`` isn't installed it falls
|
|
112
|
+
back to the plain :func:`banner`.
|
|
113
|
+
|
|
114
|
+
The arch is the only multi-coloured element; the wordmark is metallic
|
|
115
|
+
platinum and everything else uses the Voight palette.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
from rich.box import ROUNDED
|
|
120
|
+
from rich.console import Console
|
|
121
|
+
from rich.panel import Panel
|
|
122
|
+
from rich.table import Table
|
|
123
|
+
from rich.text import Text
|
|
124
|
+
except ImportError: # pragma: no cover - exercised only without [rich]
|
|
125
|
+
print(banner())
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
con = console if isinstance(console, Console) else Console()
|
|
129
|
+
|
|
130
|
+
# Arch — iridescent, the only colourful element.
|
|
131
|
+
arch = Text()
|
|
132
|
+
for line in ARCH_BRAILLE:
|
|
133
|
+
width = max(1, len(line) - 1)
|
|
134
|
+
for i, ch in enumerate(line):
|
|
135
|
+
if ch == " ":
|
|
136
|
+
arch.append(" ")
|
|
137
|
+
else:
|
|
138
|
+
arch.append(ch, style=_hex_at(i / width, _IRID))
|
|
139
|
+
arch.append("\n")
|
|
140
|
+
|
|
141
|
+
# Wordmark — metallic platinum, top→bottom.
|
|
142
|
+
wm = Text()
|
|
143
|
+
rows = max(1, len(WORDMARK) - 1)
|
|
144
|
+
for i, line in enumerate(WORDMARK):
|
|
145
|
+
wm.append(line + "\n", style=_hex_at(i / rows, _METAL))
|
|
146
|
+
|
|
147
|
+
# Side-by-side lockup when the terminal is wide enough; otherwise stack
|
|
148
|
+
# the arch above the wordmark so neither wraps on an 80-column terminal.
|
|
149
|
+
needed = len(WORDMARK[0]) + len(ARCH_BRAILLE[0]) + 2
|
|
150
|
+
if con.size.width >= needed:
|
|
151
|
+
lockup = Table.grid(padding=(0, 2))
|
|
152
|
+
lockup.add_column()
|
|
153
|
+
lockup.add_column()
|
|
154
|
+
lockup.add_row(Text("\n") + arch, wm)
|
|
155
|
+
con.print(lockup)
|
|
156
|
+
else:
|
|
157
|
+
con.print(arch)
|
|
158
|
+
con.print(wm)
|
|
159
|
+
|
|
160
|
+
if not full:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
from bitfrost import __version__
|
|
165
|
+
except Exception: # pragma: no cover - defensive
|
|
166
|
+
__version__ = "0.1.0"
|
|
167
|
+
|
|
168
|
+
con.print()
|
|
169
|
+
info = Text()
|
|
170
|
+
info.append("Drop-in OpenTelemetry observability for Python LLM apps.\n", style="#b4b4bd")
|
|
171
|
+
info.append(f"v{__version__} · MIT · Python 3.10-3.13\n\n", style="#5c5c66")
|
|
172
|
+
|
|
173
|
+
def _section(title: str, items: list[str]) -> None:
|
|
174
|
+
info.append(f"{title}\n", style=f"bold {PALETTE['accent']}")
|
|
175
|
+
info.append(" " + " · ".join(items) + "\n\n", style=PALETTE["fg"])
|
|
176
|
+
|
|
177
|
+
_section("Backends", ["console", "sqlite", "jsonl", "otlp", "voight", "tee"])
|
|
178
|
+
_section("Commands", ["watch", "replay", "query", "vacuum", "tui", "serve"])
|
|
179
|
+
_section("Instruments", ["openai", "anthropic", "litellm", "smolagents"])
|
|
180
|
+
info.append("run ", style="#5c5c66")
|
|
181
|
+
info.append("bitfrost --help", style=PALETTE["accent2"])
|
|
182
|
+
|
|
183
|
+
con.print(
|
|
184
|
+
Panel(
|
|
185
|
+
info,
|
|
186
|
+
title=f"[bold {PALETTE['fg']}]bitfrost[/] [{PALETTE['fg_muted']}]by voight.xyz[/]",
|
|
187
|
+
title_align="left",
|
|
188
|
+
border_style=PALETTE["accent"],
|
|
189
|
+
box=ROUNDED,
|
|
190
|
+
padding=(1, 3),
|
|
191
|
+
width=84,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
__all__ = ["ARCH_BRAILLE", "BANNER", "PALETTE", "WORDMARK", "banner", "render_splash"]
|
bitfrost/_readers.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Readers that turn a captured log (JSONL file or SQLite DB) back into events.
|
|
2
|
+
|
|
3
|
+
The CLI commands (``watch``, ``replay``, ``query``) all need to pull events
|
|
4
|
+
out of a file the user captured earlier. Two storage formats ship in v0.1:
|
|
5
|
+
|
|
6
|
+
- **JSONL** (:class:`~bitfrost.backends.jsonl.JSONLBackend`) — one JSON
|
|
7
|
+
object per line, each line a full :class:`~bitfrost.types.EventPayload`.
|
|
8
|
+
- **SQLite** (:class:`~bitfrost.backends.sqlite.SQLiteBackend`) — flat
|
|
9
|
+
columns plus a ``metadata`` JSON column.
|
|
10
|
+
|
|
11
|
+
Both readers expose the same surface:
|
|
12
|
+
|
|
13
|
+
- ``read_all()`` → every event as a payload dict, in capture order.
|
|
14
|
+
- ``tail(marker)`` → ``(new_events, new_marker)`` since the last poll, so
|
|
15
|
+
``bitfrost watch`` can incrementally stream new events without re-reading
|
|
16
|
+
the whole file. The marker is opaque to callers: a byte offset for
|
|
17
|
+
JSONL, a rowid for SQLite. Start a watch loop with ``marker=0``.
|
|
18
|
+
|
|
19
|
+
:class:`SQLiteReader` additionally exposes ``query(sql)`` — a **read-only**
|
|
20
|
+
SQL passthrough for ``bitfrost query`` — opened with SQLite's ``mode=ro``
|
|
21
|
+
URI so a fat-fingered ``DROP TABLE`` can't damage the user's capture.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import json
|
|
28
|
+
import sqlite3
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class JSONLReader:
|
|
34
|
+
"""Read events from a ``.jsonl`` capture file."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, path: str | Path) -> None:
|
|
37
|
+
self._path = Path(path)
|
|
38
|
+
|
|
39
|
+
def read_all(self) -> list[dict[str, Any]]:
|
|
40
|
+
"""Return every well-formed event in the file, in order.
|
|
41
|
+
|
|
42
|
+
Blank lines and malformed JSON are skipped silently — a partially
|
|
43
|
+
written final line (the writer crashed mid-append) shouldn't make
|
|
44
|
+
the whole replay unreadable.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
events: list[dict[str, Any]] = []
|
|
48
|
+
if not self._path.exists():
|
|
49
|
+
return events
|
|
50
|
+
with self._path.open("r", encoding="utf-8") as fh:
|
|
51
|
+
for line in fh:
|
|
52
|
+
event = _parse_line(line)
|
|
53
|
+
if event is not None:
|
|
54
|
+
events.append(event)
|
|
55
|
+
return events
|
|
56
|
+
|
|
57
|
+
def tail(self, marker: int = 0) -> tuple[list[dict[str, Any]], int]:
|
|
58
|
+
"""Return events appended since byte offset ``marker``.
|
|
59
|
+
|
|
60
|
+
Reads only complete lines: if the file currently ends mid-line
|
|
61
|
+
(a write in progress), the partial trailing line is left for the
|
|
62
|
+
next poll by rewinding the returned marker to the start of it.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
if not self._path.exists():
|
|
66
|
+
return [], marker
|
|
67
|
+
events: list[dict[str, Any]] = []
|
|
68
|
+
with self._path.open("rb") as fh:
|
|
69
|
+
fh.seek(marker)
|
|
70
|
+
data = fh.read()
|
|
71
|
+
new_marker = fh.tell()
|
|
72
|
+
if not data:
|
|
73
|
+
return [], marker
|
|
74
|
+
text = data.decode("utf-8", "replace")
|
|
75
|
+
# If the chunk doesn't end on a newline, hold back the partial
|
|
76
|
+
# last line so we re-read it whole next time.
|
|
77
|
+
if not text.endswith("\n"):
|
|
78
|
+
last_nl = text.rfind("\n")
|
|
79
|
+
if last_nl == -1:
|
|
80
|
+
# No complete line yet — wait for more.
|
|
81
|
+
return [], marker
|
|
82
|
+
consumed = last_nl + 1
|
|
83
|
+
new_marker = marker + len(text[:consumed].encode("utf-8"))
|
|
84
|
+
text = text[:consumed]
|
|
85
|
+
for line in text.splitlines():
|
|
86
|
+
event = _parse_line(line)
|
|
87
|
+
if event is not None:
|
|
88
|
+
events.append(event)
|
|
89
|
+
return events, new_marker
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SQLiteReader:
|
|
93
|
+
"""Read events from a SQLite capture DB written by ``SQLiteBackend``."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, path: str | Path) -> None:
|
|
96
|
+
self._path = Path(path)
|
|
97
|
+
|
|
98
|
+
def read_all(self) -> list[dict[str, Any]]:
|
|
99
|
+
"""Return every event reconstructed into payload-dict shape, in order."""
|
|
100
|
+
|
|
101
|
+
if not self._path.exists():
|
|
102
|
+
return []
|
|
103
|
+
conn = self._connect_ro()
|
|
104
|
+
try:
|
|
105
|
+
cursor = conn.execute(
|
|
106
|
+
"SELECT rowid, agent_id, session_id, timestamp, event_type, "
|
|
107
|
+
"model, duration_ms, outcome, tool_executed, input, metadata "
|
|
108
|
+
"FROM events ORDER BY rowid"
|
|
109
|
+
)
|
|
110
|
+
return [_row_to_event(row) for row in cursor.fetchall()]
|
|
111
|
+
finally:
|
|
112
|
+
conn.close()
|
|
113
|
+
|
|
114
|
+
def tail(self, marker: int = 0) -> tuple[list[dict[str, Any]], int]:
|
|
115
|
+
"""Return events with ``rowid > marker``; new marker is the max rowid.
|
|
116
|
+
|
|
117
|
+
rowid is SQLite's monotonic insertion counter, so it gives a stable
|
|
118
|
+
"everything since last poll" cursor even when two events share a
|
|
119
|
+
millisecond timestamp.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
if not self._path.exists():
|
|
123
|
+
return [], marker
|
|
124
|
+
conn = self._connect_ro()
|
|
125
|
+
try:
|
|
126
|
+
cursor = conn.execute(
|
|
127
|
+
"SELECT rowid, agent_id, session_id, timestamp, event_type, "
|
|
128
|
+
"model, duration_ms, outcome, tool_executed, input, metadata "
|
|
129
|
+
"FROM events WHERE rowid > ? ORDER BY rowid",
|
|
130
|
+
(marker,),
|
|
131
|
+
)
|
|
132
|
+
rows = cursor.fetchall()
|
|
133
|
+
finally:
|
|
134
|
+
conn.close()
|
|
135
|
+
if not rows:
|
|
136
|
+
return [], marker
|
|
137
|
+
events = [_row_to_event(row) for row in rows]
|
|
138
|
+
new_marker = max(int(row[0]) for row in rows)
|
|
139
|
+
return events, new_marker
|
|
140
|
+
|
|
141
|
+
def query(self, sql: str) -> tuple[list[str], list[tuple[Any, ...]]]:
|
|
142
|
+
"""Run a read-only SQL query, returning ``(column_names, rows)``.
|
|
143
|
+
|
|
144
|
+
The connection is opened ``mode=ro`` so any mutation (INSERT,
|
|
145
|
+
UPDATE, DELETE, DROP, …) raises ``sqlite3.OperationalError`` rather
|
|
146
|
+
than touching the user's capture. ``bitfrost query`` surfaces that
|
|
147
|
+
as a friendly error.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
conn = self._connect_ro()
|
|
151
|
+
try:
|
|
152
|
+
cursor = conn.execute(sql)
|
|
153
|
+
columns = [d[0] for d in cursor.description] if cursor.description else []
|
|
154
|
+
rows = cursor.fetchall()
|
|
155
|
+
return columns, rows
|
|
156
|
+
finally:
|
|
157
|
+
conn.close()
|
|
158
|
+
|
|
159
|
+
def _connect_ro(self) -> sqlite3.Connection:
|
|
160
|
+
"""Open a read-only connection via the ``file:…?mode=ro`` URI."""
|
|
161
|
+
|
|
162
|
+
uri = f"file:{self._path}?mode=ro"
|
|
163
|
+
return sqlite3.connect(uri, uri=True)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Helpers
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parse_line(line: str) -> dict[str, Any] | None:
|
|
172
|
+
"""Parse one JSONL line into an event dict, or ``None`` if not usable."""
|
|
173
|
+
|
|
174
|
+
stripped = line.strip()
|
|
175
|
+
if not stripped:
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
parsed = json.loads(stripped)
|
|
179
|
+
except (json.JSONDecodeError, ValueError):
|
|
180
|
+
return None
|
|
181
|
+
return parsed if isinstance(parsed, dict) else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _row_to_event(row: tuple[Any, ...]) -> dict[str, Any]:
|
|
185
|
+
"""Reconstruct a render-ready event dict from a SQLite row.
|
|
186
|
+
|
|
187
|
+
Column order matches the SELECT in :meth:`SQLiteReader.read_all`:
|
|
188
|
+
``(rowid, agent_id, session_id, timestamp, event_type, model,
|
|
189
|
+
duration_ms, outcome, tool_executed, input, metadata)``.
|
|
190
|
+
|
|
191
|
+
``metadata`` already carries ``tokens`` / ``provider`` / ``sessionId``
|
|
192
|
+
and ``input`` carries the (privacy-filtered) prompt, so the
|
|
193
|
+
reconstructed dict renders identically to a live event.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
(
|
|
197
|
+
_rowid,
|
|
198
|
+
agent_id,
|
|
199
|
+
_session_id,
|
|
200
|
+
timestamp,
|
|
201
|
+
event_type,
|
|
202
|
+
model,
|
|
203
|
+
duration_ms,
|
|
204
|
+
outcome,
|
|
205
|
+
tool_executed,
|
|
206
|
+
input_raw,
|
|
207
|
+
metadata_raw,
|
|
208
|
+
) = row
|
|
209
|
+
try:
|
|
210
|
+
metadata = json.loads(metadata_raw) if metadata_raw else {}
|
|
211
|
+
except (json.JSONDecodeError, ValueError, TypeError):
|
|
212
|
+
metadata = {}
|
|
213
|
+
if not isinstance(metadata, dict):
|
|
214
|
+
metadata = {}
|
|
215
|
+
|
|
216
|
+
event: dict[str, Any] = {
|
|
217
|
+
"agentId": agent_id,
|
|
218
|
+
"type": event_type,
|
|
219
|
+
"model": model,
|
|
220
|
+
"durationMs": duration_ms,
|
|
221
|
+
"outcome": outcome,
|
|
222
|
+
"timestamp": timestamp,
|
|
223
|
+
"metadata": metadata,
|
|
224
|
+
}
|
|
225
|
+
if tool_executed:
|
|
226
|
+
event["toolExecuted"] = tool_executed
|
|
227
|
+
if input_raw:
|
|
228
|
+
with contextlib.suppress(json.JSONDecodeError, ValueError, TypeError):
|
|
229
|
+
event["input"] = json.loads(input_raw)
|
|
230
|
+
return event
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def make_reader(path: str | Path, fmt: str | None = None) -> JSONLReader | SQLiteReader:
|
|
234
|
+
"""Return the right reader for ``path``, auto-detecting by extension.
|
|
235
|
+
|
|
236
|
+
``fmt`` overrides detection: ``"jsonl"`` or ``"sqlite"`` / ``"db"``.
|
|
237
|
+
Detection falls back to SQLite for ``.db`` / ``.sqlite`` / ``.sqlite3``
|
|
238
|
+
and JSONL for ``.jsonl`` / ``.ndjson``; anything else raises so the CLI
|
|
239
|
+
can tell the user to pass an explicit flag.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
if fmt is not None:
|
|
243
|
+
fmt_l = fmt.lower()
|
|
244
|
+
if fmt_l == "jsonl":
|
|
245
|
+
return JSONLReader(path)
|
|
246
|
+
if fmt_l in ("sqlite", "db"):
|
|
247
|
+
return SQLiteReader(path)
|
|
248
|
+
msg = f"unknown reader format: {fmt!r} (expected 'jsonl' or 'sqlite')"
|
|
249
|
+
raise ValueError(msg)
|
|
250
|
+
|
|
251
|
+
suffix = Path(path).suffix.lower()
|
|
252
|
+
if suffix in (".db", ".sqlite", ".sqlite3"):
|
|
253
|
+
return SQLiteReader(path)
|
|
254
|
+
if suffix in (".jsonl", ".ndjson"):
|
|
255
|
+
return JSONLReader(path)
|
|
256
|
+
msg = f"cannot infer format from extension {suffix!r}; pass --db or --jsonl explicitly"
|
|
257
|
+
raise ValueError(msg)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
__all__ = ["JSONLReader", "SQLiteReader", "make_reader"]
|