From f3be208733dd785f86b5b6c57e65ca6ceef36c62 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Tue, 2 Jun 2026 01:59:46 +0800 Subject: [PATCH 1/5] feat(tapestore): add otel tape store projection --- README.md | 1 + packages/bub-tapestore-otel/README.md | 44 +++ packages/bub-tapestore-otel/pyproject.toml | 21 ++ .../src/bub_tapestore_otel/__init__.py | 5 + .../src/bub_tapestore_otel/exporter.py | 217 +++++++++++++++ .../src/bub_tapestore_otel/plugin.py | 78 ++++++ .../src/bub_tapestore_otel/py.typed | 1 + .../src/bub_tapestore_otel/store.py | 53 ++++ .../bub-tapestore-otel/tests/test_exporter.py | 68 +++++ .../bub-tapestore-otel/tests/test_plugin.py | 57 ++++ .../bub-tapestore-otel/tests/test_store.py | 80 ++++++ pyproject.toml | 2 + uv.lock | 250 +++++++++++++++++- 13 files changed, 874 insertions(+), 3 deletions(-) create mode 100644 packages/bub-tapestore-otel/README.md create mode 100644 packages/bub-tapestore-otel/pyproject.toml create mode 100644 packages/bub-tapestore-otel/src/bub_tapestore_otel/__init__.py create mode 100644 packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py create mode 100644 packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py create mode 100644 packages/bub-tapestore-otel/src/bub_tapestore_otel/py.typed create mode 100644 packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py create mode 100644 packages/bub-tapestore-otel/tests/test_exporter.py create mode 100644 packages/bub-tapestore-otel/tests/test_plugin.py create mode 100644 packages/bub-tapestore-otel/tests/test_store.py diff --git a/README.md b/README.md index 73d644b..c0ea61c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Below is the list of packages currently included in this repository. | [`packages/bub-acp-server`](./packages/bub-acp-server/README.md) | `acp-server` | Exposes Bub as an Agent Client Protocol agent with `bub acp serve` for ACP-compatible editors. | | [`packages/bub-tg-feed`](./packages/bub-tg-feed/README.md) | `tg-feed` | Provides an AMQP-based channel adapter for Telegram feed messages. | | [`packages/bub-schedule`](./packages/bub-schedule/README.md) | `schedule` | Provides scheduling channel/tools backed by APScheduler with a JSON job store. | +| [`packages/bub-tapestore-otel`](./packages/bub-tapestore-otel/README.md) | `tapestore-otel` | Wraps the active tape store and projects committed tape writes to OpenTelemetry through Logfire. | | [`packages/bub-tapestore-sqlalchemy`](./packages/bub-tapestore-sqlalchemy/README.md) | `tapestore-sqlalchemy` | Provides a SQLAlchemy-backed tape store for Bub conversation history. | | [`packages/bub-tapestore-sqlite`](./packages/bub-tapestore-sqlite/README.md) | `tapestore-sqlite` | Provides a SQLite-backed tape store for Bub conversation history. | | [`packages/bub-discord`](./packages/bub-discord/README.md) | `discord` | Provides a Discord channel adapter for Bub message IO. | diff --git a/packages/bub-tapestore-otel/README.md b/packages/bub-tapestore-otel/README.md new file mode 100644 index 0000000..e16dfa8 --- /dev/null +++ b/packages/bub-tapestore-otel/README.md @@ -0,0 +1,44 @@ +# bub-tapestore-otel + +`bub-tapestore-otel` wraps the active Bub tape store and projects committed tape +writes to OpenTelemetry through Logfire. + +It is a transparent tape-store decorator: + +```text +Bub -> OTelTapeStore -> active TapeStore + -> Logfire / OTLP +``` + +The real tape backend can still be the builtin file store or another contrib +store such as SQLite, SQLAlchemy, or Redis. This package observes `append` and +`reset` calls after the real store succeeds, then emits best-effort spans. Export +failures are swallowed so telemetry cannot break tape persistence. + +## Configuration + +For local Jaeger: + +```bash +LOGFIRE_SEND_TO_LOGFIRE=false \ +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces \ +uv run bub run ",tape.info" +``` + +Plugin settings: + +| Variable | Default | Description | +| --- | --- | --- | +| `BUB_TAPESTORE_OTEL_ENABLED` | `true` | Wrap the active tape store. | +| `BUB_TAPESTORE_OTEL_SERVICE_NAME` | `bub` | Service name used by Logfire. | +| `BUB_TAPESTORE_OTEL_SEND_TO_LOGFIRE` | `false` | Send to hosted Logfire in addition to OTLP env exporters. | +| `BUB_TAPESTORE_OTEL_FORCE_FLUSH` | `true` | Flush after each completed tape batch for streamable local observation. | +| `BUB_TAPESTORE_OTEL_SHUTDOWN_AFTER_FLUSH` | `true` | Shut down Logfire after each flush so short-lived `bub run` processes exit cleanly. | + +The projection is tape-first: spans carry `bub.tape.name`, +`bub.tape.entry.kind`, and `bub.tape.entry.name`. Prompt and message content are +not exported by default. + +For long-running `bub chat` or `bub gateway` processes, set +`BUB_TAPESTORE_OTEL_SHUTDOWN_AFTER_FLUSH=false` so later tape batches can keep +using the same Logfire runtime. diff --git a/packages/bub-tapestore-otel/pyproject.toml b/packages/bub-tapestore-otel/pyproject.toml new file mode 100644 index 0000000..0a8f527 --- /dev/null +++ b/packages/bub-tapestore-otel/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "bub-tapestore-otel" +version = "0.1.0" +description = "OpenTelemetry projection layer for Bub tape stores" +readme = "README.md" +authors = [ + { name = "Chojan Shang", email = "psiace@apache.org" } +] +requires-python = ">=3.12" +dependencies = [ + "bub", + "logfire>=4.31.0", + "republic>=0.5.7", +] + +[project.entry-points.bub] +tapestore-otel = "bub_tapestore_otel.plugin:OTelTapeStorePlugin" + +[build-system] +requires = ["uv_build>=0.9.7,<0.10.0"] +build-backend = "uv_build" diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/__init__.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/__init__.py new file mode 100644 index 0000000..5be8726 --- /dev/null +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +__all__ = ["OTelTapeStorePlugin"] + +from bub_tapestore_otel.plugin import OTelTapeStorePlugin diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py new file mode 100644 index 0000000..1d491e9 --- /dev/null +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import re +import threading +from dataclasses import dataclass +from typing import Any + +from loguru import logger +from republic import TapeEntry + +SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_.-]+") + + +@dataclass(frozen=True) +class LogfireTapeExporterSettings: + service_name: str = "bub" + send_to_logfire: bool = False + force_flush: bool = True + shutdown_after_flush: bool = True + + +class LogfireTapeExporter: + def __init__(self, settings: LogfireTapeExporterSettings | None = None) -> None: + self._settings = settings or LogfireTapeExporterSettings() + self._configured = False + self._lock = threading.Lock() + self._pending: dict[str, list[TapeEntry]] = {} + + def append(self, tape: str, entry: TapeEntry) -> None: + try: + self._append(tape, entry) + except Exception: + logger.opt(exception=True).warning("tapestore.otel.export_failed action=append tape={}", tape) + + def reset(self, tape: str) -> None: + try: + self._reset(tape) + except Exception: + logger.opt(exception=True).warning("tapestore.otel.export_failed action=reset tape={}", tape) + + def _configure(self) -> None: + if self._configured: + return + import logfire + + logfire.configure( + send_to_logfire=self._settings.send_to_logfire, + service_name=self._settings.service_name, + console=False, + scrubbing=False, + ) + self._configured = True + + def _flush(self) -> None: + if not self._settings.force_flush: + return + import logfire + + logfire.force_flush() + if self._settings.shutdown_after_flush: + logfire.shutdown() + self._configured = False + + def _append(self, tape: str, entry: TapeEntry) -> None: + self._configure() + batch = self._record_entry(tape, entry) + if batch is None: + return + _instrument_batch(tape, batch) + self._flush() + + def _reset(self, tape: str) -> None: + self._configure() + batch = self._pop_pending(tape) + if batch: + _instrument_batch(tape, batch) + _instrument_reset(tape) + self._flush() + + def _record_entry(self, tape: str, entry: TapeEntry) -> list[TapeEntry] | None: + with self._lock: + entries = self._pending.setdefault(tape, []) + entries.append(entry) + if not _should_flush_batch(entry): + return None + return self._pending.pop(tape) + + def _pop_pending(self, tape: str) -> list[TapeEntry]: + with self._lock: + return self._pending.pop(tape, []) + + +def _entry_name(entry: TapeEntry) -> str: + value = entry.payload.get("name") + return str(value) if value else entry.kind + + +def _span_name(entry: TapeEntry) -> str: + name = _entry_name(entry) + if entry.kind == "event" and name == "run": + return "bub.model.run" + if entry.kind == "event" and name.startswith("loop.step"): + return "bub.loop.step" + if entry.kind == "event" and name == "command": + return "bub.command" + if entry.kind == "anchor" and name != "session/start": + return "bub.tape.handoff" + safe_name = SAFE_NAME_RE.sub(".", name).strip(".") or entry.kind + return f"bub.tape.{entry.kind}.{safe_name}" + + +def _payload_data(entry: TapeEntry) -> dict[str, Any]: + data = entry.payload.get("data") + return data if isinstance(data, dict) else {} + + +def _entry_attributes(tape: str, entry: TapeEntry) -> dict[str, Any]: + data = _payload_data(entry) + attributes: dict[str, Any] = { + "bub.tape.name": tape, + "bub.tape.entry.id": entry.id, + "bub.tape.entry.kind": entry.kind, + "bub.tape.entry.name": _entry_name(entry), + "bub.tape.entry.date": entry.date, + } + for source_key, attr_key in ( + ("status", "bub.tape.entry.status"), + ("step", "bub.loop.step"), + ("elapsed_ms", "bub.duration_ms"), + ("model", "bub.model"), + ("provider", "bub.provider"), + ): + if source_key in data: + attributes[attr_key] = data[source_key] + + prompt = data.get("prompt") + if isinstance(prompt, str): + attributes["bub.prompt.chars"] = len(prompt) + elif isinstance(prompt, list): + attributes["bub.prompt.parts"] = len(prompt) + + content = entry.payload.get("content") + if isinstance(content, str): + attributes["bub.content.chars"] = len(content) + + usage = data.get("usage") + if isinstance(usage, dict): + for key in ("prompt_tokens", "completion_tokens", "total_tokens"): + if key in usage: + attributes[f"bub.usage.{key}"] = usage[key] + + return attributes + + +def _batch_attributes(tape: str, entries: list[TapeEntry]) -> dict[str, Any]: + attributes: dict[str, Any] = { + "bub.tape.name": tape, + "bub.tape.batch.entries": len(entries), + } + if entries: + attributes["bub.tape.batch.first_entry_id"] = entries[0].id + attributes["bub.tape.batch.last_entry_id"] = entries[-1].id + attributes["bub.tape.batch.first_entry_date"] = entries[0].date + attributes["bub.tape.batch.last_entry_date"] = entries[-1].date + return attributes + + +def _should_flush_batch(entry: TapeEntry) -> bool: + if entry.kind == "event" and _entry_name(entry) in {"command", "loop.step"}: + return True + return False + + +def _instrument_batch(tape: str, entries: list[TapeEntry]) -> None: + import logfire + + @logfire.instrument( + "bub.tape.export", + span_name="bub.tape.export", + extract_args=False, + ) + def emit() -> None: + with logfire.span( + "bub.tape.batch {tape}", + _span_name="bub.tape.batch", + tape=tape, + **_batch_attributes(tape, entries), + ): + for entry in entries: + with logfire.span( + "bub.tape.entry {entry_name}", + _span_name=_span_name(entry), + entry_name=_entry_name(entry), + **_entry_attributes(tape, entry), + ): + pass + + emit() + + +def _instrument_reset(tape: str) -> None: + import logfire + + @logfire.instrument( + "bub.tape.reset", + span_name="bub.tape.reset", + extract_args=False, + ) + def emit() -> None: + with logfire.span( + "bub.tape.reset {tape}", + _span_name="bub.tape.reset", + **{"bub.tape.name": tape}, + ): + pass + + emit() diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py new file mode 100644 index 0000000..41bad2b --- /dev/null +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import contextlib +from collections.abc import AsyncIterator, Iterator +from typing import Any + +from pydantic import Field +from pydantic_settings import SettingsConfigDict + +import bub +from bub import BubFramework, hookimpl +from bub_tapestore_otel.exporter import LogfireTapeExporter, LogfireTapeExporterSettings +from bub_tapestore_otel.store import OTelTapeStore + +CONFIG_NAME = "tapestore-otel" + + +@bub.config(name=CONFIG_NAME) +class OTelTapeStoreSettings(bub.Settings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + populate_by_name=True, + ) + + enabled: bool = Field(default=True, validation_alias="BUB_TAPESTORE_OTEL_ENABLED") + service_name: str = Field(default="bub", validation_alias="BUB_TAPESTORE_OTEL_SERVICE_NAME") + send_to_logfire: bool = Field(default=False, validation_alias="BUB_TAPESTORE_OTEL_SEND_TO_LOGFIRE") + force_flush: bool = Field(default=True, validation_alias="BUB_TAPESTORE_OTEL_FORCE_FLUSH") + shutdown_after_flush: bool = Field(default=True, validation_alias="BUB_TAPESTORE_OTEL_SHUTDOWN_AFTER_FLUSH") + + +class OTelTapeStorePlugin: + def __init__(self, framework: BubFramework) -> None: + self.framework = framework + + @hookimpl(tryfirst=True) + def provide_tape_store(self) -> Any: + parent = self.framework._plugin_manager.subset_hook_caller( + "provide_tape_store", + remove_plugins=[self], + ) + store = parent() + settings = bub.ensure_config(OTelTapeStoreSettings) + if not settings.enabled: + return store + exporter = LogfireTapeExporter( + LogfireTapeExporterSettings( + service_name=settings.service_name, + send_to_logfire=settings.send_to_logfire, + force_flush=settings.force_flush, + shutdown_after_flush=settings.shutdown_after_flush, + ) + ) + return _wrap_store_result(store, exporter) + + +def _wrap_store_result(store: Any, exporter: LogfireTapeExporter) -> Any: + if isinstance(store, AsyncIterator): + + @contextlib.asynccontextmanager + async def manager() -> AsyncIterator[OTelTapeStore]: + async with contextlib.asynccontextmanager(lambda: store)() as inner: + yield OTelTapeStore(inner, exporter) + + return manager() + + if isinstance(store, Iterator): + + @contextlib.contextmanager + def manager() -> Iterator[OTelTapeStore]: + with contextlib.contextmanager(lambda: store)() as inner: + yield OTelTapeStore(inner, exporter) + + return manager() + + return OTelTapeStore(store, exporter) diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/py.typed b/packages/bub-tapestore-otel/src/bub_tapestore_otel/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/py.typed @@ -0,0 +1 @@ + diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py new file mode 100644 index 0000000..5c8be4f --- /dev/null +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/store.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import Protocol + +from loguru import logger +from republic import AsyncTapeStore, TapeEntry, TapeQuery +from republic.tape import TapeStore +from republic.tape.store import is_async_tape_store + + +class TapeExporter(Protocol): + def append(self, tape: str, entry: TapeEntry) -> None: ... + + def reset(self, tape: str) -> None: ... + + +class OTelTapeStore: + """Transparent async tape-store decorator that observes committed writes.""" + + def __init__(self, inner: TapeStore | AsyncTapeStore, exporter: TapeExporter) -> None: + self._inner = inner + self._exporter = exporter + + async def list_tapes(self) -> list[str]: + if is_async_tape_store(self._inner): + return await self._inner.list_tapes() + return self._inner.list_tapes() + + async def fetch_all(self, query: TapeQuery[AsyncTapeStore]) -> Iterable[TapeEntry]: + if is_async_tape_store(self._inner): + return await self._inner.fetch_all(query) + return self._inner.fetch_all(query) + + async def append(self, tape: str, entry: TapeEntry) -> None: + if is_async_tape_store(self._inner): + await self._inner.append(tape, entry) + else: + self._inner.append(tape, entry) + try: + self._exporter.append(tape, entry) + except Exception: + logger.opt(exception=True).warning("tapestore.otel.export_failed action=append tape={}", tape) + + async def reset(self, tape: str) -> None: + if is_async_tape_store(self._inner): + await self._inner.reset(tape) + else: + self._inner.reset(tape) + try: + self._exporter.reset(tape) + except Exception: + logger.opt(exception=True).warning("tapestore.otel.export_failed action=reset tape={}", tape) diff --git a/packages/bub-tapestore-otel/tests/test_exporter.py b/packages/bub-tapestore-otel/tests/test_exporter.py new file mode 100644 index 0000000..3ef1001 --- /dev/null +++ b/packages/bub-tapestore-otel/tests/test_exporter.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from republic import TapeEntry + +from bub_tapestore_otel.exporter import _batch_attributes, _entry_attributes, _should_flush_batch, _span_name + + +def test_span_name_maps_known_tape_events() -> None: + assert _span_name(TapeEntry.event("run", data={})) == "bub.model.run" + assert _span_name(TapeEntry.event("loop.step", data={})) == "bub.loop.step" + assert _span_name(TapeEntry.event("command", data={})) == "bub.command" + + +def test_entry_attributes_do_not_include_content() -> None: + entry = TapeEntry.event( + "run", + data={ + "status": "ok", + "elapsed_ms": 12, + "usage": {"total_tokens": 42}, + "prompt": "do not export", + }, + ) + + attributes = _entry_attributes("tape-1", entry) + + assert attributes["bub.tape.name"] == "tape-1" + assert attributes["bub.tape.entry.kind"] == "event" + assert attributes["bub.tape.entry.name"] == "run" + assert attributes["bub.duration_ms"] == 12 + assert attributes["bub.usage.total_tokens"] == 42 + assert "prompt" not in attributes + + +def test_entry_attributes_include_safe_shape_metadata() -> None: + entry = TapeEntry.event( + "loop.start", + data={ + "model": "openai:gpt-5", + "prompt": "hello", + }, + ) + + attributes = _entry_attributes("tape-1", entry) + + assert attributes["bub.model"] == "openai:gpt-5" + assert attributes["bub.prompt.chars"] == 5 + assert "hello" not in attributes.values() + + +def test_batch_flushes_on_completed_tape_turn_markers() -> None: + assert _should_flush_batch(TapeEntry.event("loop.step", data={"status": "ok"})) + assert _should_flush_batch(TapeEntry.event("command", data={})) + assert not _should_flush_batch(TapeEntry.event("loop.step.start", data={})) + + +def test_batch_attributes_summarize_entry_range() -> None: + entries = [ + TapeEntry.event("loop.start", data={}), + TapeEntry.event("loop.step", data={"status": "ok"}), + ] + + attributes = _batch_attributes("tape-1", entries) + + assert attributes["bub.tape.name"] == "tape-1" + assert attributes["bub.tape.batch.entries"] == 2 + assert attributes["bub.tape.batch.first_entry_id"] == entries[0].id + assert attributes["bub.tape.batch.last_entry_id"] == entries[-1].id diff --git a/packages/bub-tapestore-otel/tests/test_plugin.py b/packages/bub-tapestore-otel/tests/test_plugin.py new file mode 100644 index 0000000..6271fd6 --- /dev/null +++ b/packages/bub-tapestore-otel/tests/test_plugin.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import pluggy + +import bub_tapestore_otel.plugin as plugin +from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl +from bub_tapestore_otel.plugin import OTelTapeStorePlugin, OTelTapeStoreSettings +from bub_tapestore_otel.store import OTelTapeStore + + +class ParentStore: + pass + + +class ParentPlugin: + @hookimpl + def provide_tape_store(self) -> ParentStore: + return ParentStore() + + +class Framework: + def __init__(self) -> None: + self._plugin_manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE) + self._plugin_manager.add_hookspecs(BubHookSpecs) + + +def test_plugin_wraps_parent_tape_store(monkeypatch) -> None: + framework = Framework() + otel_plugin = OTelTapeStorePlugin(framework) # type: ignore[arg-type] + framework._plugin_manager.register(ParentPlugin(), "parent") + framework._plugin_manager.register(otel_plugin, "otel") + monkeypatch.setattr( + plugin.bub, + "ensure_config", + lambda _: OTelTapeStoreSettings(enabled=True, force_flush=False), + ) + + store = framework._plugin_manager.hook.provide_tape_store() + + assert isinstance(store, OTelTapeStore) + assert isinstance(store._inner, ParentStore) + + +def test_plugin_can_be_disabled(monkeypatch) -> None: + framework = Framework() + otel_plugin = OTelTapeStorePlugin(framework) # type: ignore[arg-type] + framework._plugin_manager.register(ParentPlugin(), "parent") + framework._plugin_manager.register(otel_plugin, "otel") + monkeypatch.setattr( + plugin.bub, + "ensure_config", + lambda _: OTelTapeStoreSettings(enabled=False), + ) + + store = framework._plugin_manager.hook.provide_tape_store() + + assert isinstance(store, ParentStore) diff --git a/packages/bub-tapestore-otel/tests/test_store.py b/packages/bub-tapestore-otel/tests/test_store.py new file mode 100644 index 0000000..9540b93 --- /dev/null +++ b/packages/bub-tapestore-otel/tests/test_store.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from collections.abc import Iterable + +import pytest +from republic import TapeEntry, TapeQuery + +from bub_tapestore_otel.store import OTelTapeStore + + +class MemoryStore: + def __init__(self) -> None: + self.entries: dict[str, list[TapeEntry]] = {} + self.resets: list[str] = [] + + def list_tapes(self) -> list[str]: + return sorted(self.entries) + + def reset(self, tape: str) -> None: + self.resets.append(tape) + self.entries[tape] = [] + + def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: + return list(self.entries.get(query.tape, [])) + + def append(self, tape: str, entry: TapeEntry) -> None: + self.entries.setdefault(tape, []).append(entry) + + +class Exporter: + def __init__(self) -> None: + self.appended: list[tuple[str, TapeEntry]] = [] + self.reset_tapes: list[str] = [] + + def append(self, tape: str, entry: TapeEntry) -> None: + self.appended.append((tape, entry)) + + def reset(self, tape: str) -> None: + self.reset_tapes.append(tape) + + +class FailingExporter(Exporter): + def append(self, tape: str, entry: TapeEntry) -> None: + raise RuntimeError("export failed") + + +@pytest.mark.asyncio +async def test_append_writes_inner_store_before_exporting() -> None: + inner = MemoryStore() + exporter = Exporter() + store = OTelTapeStore(inner, exporter) + entry = TapeEntry.event("loop.step", data={"status": "ok"}) + + await store.append("tape-1", entry) + + assert inner.entries == {"tape-1": [entry]} + assert exporter.appended == [("tape-1", entry)] + + +@pytest.mark.asyncio +async def test_reset_writes_inner_store_before_exporting() -> None: + inner = MemoryStore() + exporter = Exporter() + store = OTelTapeStore(inner, exporter) + + await store.reset("tape-1") + + assert inner.resets == ["tape-1"] + assert exporter.reset_tapes == ["tape-1"] + + +@pytest.mark.asyncio +async def test_export_errors_do_not_roll_back_inner_write() -> None: + inner = MemoryStore() + store = OTelTapeStore(inner, FailingExporter()) + entry = TapeEntry.event("command", data={}) + + await store.append("tape-1", entry) + + assert inner.entries == {"tape-1": [entry]} diff --git a/pyproject.toml b/pyproject.toml index 950197f..f3f0f59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "bub-schedule", "bub-searxng-search", "bub-tapestore-sqlalchemy", + "bub-tapestore-otel", "bub-tapestore-redis", "bub-tapestore-sqlite", "bub-tg-feed", @@ -51,6 +52,7 @@ bub-qq = { workspace = true } bub-schedule = { workspace = true } bub-searxng-search = { workspace = true } bub-tapestore-sqlalchemy = { workspace = true } +bub-tapestore-otel = { workspace = true } bub-tapestore-redis = { workspace = true } bub-tapestore-sqlite = { workspace = true } bub-web-search = { workspace = true } diff --git a/uv.lock b/uv.lock index 93a30a8..d223d56 100644 --- a/uv.lock +++ b/uv.lock @@ -25,6 +25,7 @@ members = [ "bub-schedule", "bub-searxng-search", "bub-session-prompt", + "bub-tapestore-otel", "bub-tapestore-redis", "bub-tapestore-sqlalchemy", "bub-tapestore-sqlite", @@ -443,6 +444,7 @@ dependencies = [ { name = "bub-schedule" }, { name = "bub-searxng-search" }, { name = "bub-session-prompt" }, + { name = "bub-tapestore-otel" }, { name = "bub-tapestore-redis" }, { name = "bub-tapestore-sqlalchemy" }, { name = "bub-tapestore-sqlite" }, @@ -478,6 +480,7 @@ requires-dist = [ { name = "bub-schedule", editable = "packages/bub-schedule" }, { name = "bub-searxng-search", editable = "packages/bub-searxng-search" }, { name = "bub-session-prompt", editable = "packages/bub-session-prompt" }, + { name = "bub-tapestore-otel", editable = "packages/bub-tapestore-otel" }, { name = "bub-tapestore-redis", editable = "packages/bub-tapestore-redis" }, { name = "bub-tapestore-sqlalchemy", editable = "packages/bub-tapestore-sqlalchemy" }, { name = "bub-tapestore-sqlite", editable = "packages/bub-tapestore-sqlite" }, @@ -722,6 +725,23 @@ name = "bub-session-prompt" version = "0.1.0" source = { editable = "packages/bub-session-prompt" } +[[package]] +name = "bub-tapestore-otel" +version = "0.1.0" +source = { editable = "packages/bub-tapestore-otel" } +dependencies = [ + { name = "bub" }, + { name = "logfire" }, + { name = "republic" }, +] + +[package.metadata] +requires-dist = [ + { name = "bub", git = "https://github.com/bubbuild/bub.git" }, + { name = "logfire", specifier = ">=4.31.0" }, + { name = "republic", specifier = ">=0.5.7" }, +] + [[package]] name = "bub-tapestore-redis" version = "0.1.0" @@ -1163,6 +1183,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + [[package]] name = "extism" version = "1.1.1" @@ -1412,6 +1441,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/d1/4adcfcb9c95e3d064c9f7aaf6cb3a4fc842d86115014b9d4094db4d465b5/google_re2-1.1.20251105-1-cp314-cp314-win_arm64.whl", hash = "sha256:1d27f3a2a947ec1f721d0f14f661108acfd4f4d34f357ce28db951cc036656e5", size = 643093, upload-time = "2025-11-05T14:58:05.761Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + [[package]] name = "greenlet" version = "3.5.1" @@ -1536,6 +1577,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -1797,6 +1850,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, ] +[[package]] +name = "logfire" +version = "4.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "executing" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "protobuf" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/a6/6cbd7064d7ab120fbdd72ca1a218b3fa3343a5f1f0733fcfa34d43333aaf/logfire-4.34.0.tar.gz", hash = "sha256:d88bb04f26a5ae0064d36eeb0fca5413599f0e2d068d629bcab25993ff405e25", size = 1144711, upload-time = "2026-05-26T18:09:51.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/04/a386debccf1e91575dda55fa07b96f8309fc544833d7eec24b69869278f9/logfire-4.34.0-py3-none-any.whl", hash = "sha256:b9d9fc71aec184b28c29a9a9e280c954a3ea52c399f1d7b95ce82bff6b177364", size = 344385, upload-time = "2026-05-26T18:09:48.436Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -2061,14 +2132,99 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.42.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/ca/25288069c399be6769159d9fb7b1190b603537d82aad2fa2746a0cc2c8c6/opentelemetry_api-1.42.0.tar.gz", hash = "sha256:ea84c893ad177791d138e0349d6ceebd8d3bf006440900400ce220008dafc372", size = 72300, upload-time = "2026-05-19T09:46:29.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/0b/be5daf659b82b525338fde371dfcfab09b606a19bb5620c37076964710ec/opentelemetry_api-1.42.0-py3-none-any.whl", hash = "sha256:558d88f88192a973579910ef6f2c13db47a268d5ec2e53e83e50e74a39a02922", size = 61310, upload-time = "2026-05-19T09:46:06.561Z" }, + { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/cb/0523b92c112a6cc70be43724343dc45225d3af134419844d7879a07755d4/opentelemetry_instrumentation-0.62b1.tar.gz", hash = "sha256:90e92a905ba4f84db06ac3aec96701df6c079b2d66e9379f8739f0a1bdcc7f45", size = 34043, upload-time = "2026-04-24T13:22:31.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/0f/45adbaea1f81b847cffdcee4f4b5f89297e42facf7fac78c7aaac4c38e75/opentelemetry_instrumentation-0.62b1-py3-none-any.whl", hash = "sha256:976fc6e640f2006599e97429c949e622c108d0c17c2059347d1e6c93c707f257", size = 34163, upload-time = "2026-04-24T13:21:31.722Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d0/54ee30dab82fb0acda23d144502771ff76ef8728459c83c3e89ef9fb1825/opentelemetry_sdk-1.41.1.tar.gz", hash = "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", size = 230180, upload-time = "2026-04-24T13:15:50.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/e7/a1420b698aad018e1cf60fdbaaccbe49021fb415e2a0d81c242f4c518f54/opentelemetry_sdk-1.41.1-py3-none-any.whl", hash = "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d", size = 180213, upload-time = "2026-04-24T13:15:33.767Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/911ac9e309052aca1b20b2d5549d3db45d1011e1a610e552c6ccdd1b64f8/opentelemetry_semantic_conventions-0.62b1.tar.gz", hash = "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", size = 145750, upload-time = "2026-04-24T13:15:52.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, ] [[package]] @@ -2265,6 +2421,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "py-key-value-aio" version = "0.4.4" @@ -3318,6 +3489,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + [[package]] name = "yarl" version = "1.24.2" @@ -3399,3 +3634,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] From 12f4713bc26cd60fbe3862395ade6ed54e61e1bf Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Tue, 2 Jun 2026 02:45:11 +0800 Subject: [PATCH 2/5] feat(tapestore): export genai traces --- packages/bub-tapestore-otel/README.md | 29 +- .../src/bub_tapestore_otel/exporter.py | 584 ++++++++++++++---- .../src/bub_tapestore_otel/plugin.py | 6 - .../bub-tapestore-otel/tests/test_exporter.py | 133 ++-- .../bub-tapestore-otel/tests/test_plugin.py | 2 +- 5 files changed, 576 insertions(+), 178 deletions(-) diff --git a/packages/bub-tapestore-otel/README.md b/packages/bub-tapestore-otel/README.md index e16dfa8..edb9df2 100644 --- a/packages/bub-tapestore-otel/README.md +++ b/packages/bub-tapestore-otel/README.md @@ -17,12 +17,12 @@ failures are swallowed so telemetry cannot break tape persistence. ## Configuration -For local Jaeger: +For local Phoenix: ```bash -LOGFIRE_SEND_TO_LOGFIRE=false \ -OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces \ -uv run bub run ",tape.info" +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://127.0.0.1:6006/v1/traces \ +OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf \ +uv run bub run "hello" ``` Plugin settings: @@ -31,14 +31,17 @@ Plugin settings: | --- | --- | --- | | `BUB_TAPESTORE_OTEL_ENABLED` | `true` | Wrap the active tape store. | | `BUB_TAPESTORE_OTEL_SERVICE_NAME` | `bub` | Service name used by Logfire. | -| `BUB_TAPESTORE_OTEL_SEND_TO_LOGFIRE` | `false` | Send to hosted Logfire in addition to OTLP env exporters. | -| `BUB_TAPESTORE_OTEL_FORCE_FLUSH` | `true` | Flush after each completed tape batch for streamable local observation. | -| `BUB_TAPESTORE_OTEL_SHUTDOWN_AFTER_FLUSH` | `true` | Shut down Logfire after each flush so short-lived `bub run` processes exit cleanly. | -The projection is tape-first: spans carry `bub.tape.name`, -`bub.tape.entry.kind`, and `bub.tape.entry.name`. Prompt and message content are -not exported by default. +OTLP exporter configuration stays on the standard OpenTelemetry environment +variables such as `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` and +`OTEL_EXPORTER_OTLP_TRACES_PROTOCOL`. -For long-running `bub chat` or `bub gateway` processes, set -`BUB_TAPESTORE_OTEL_SHUTDOWN_AFTER_FLUSH=false` so later tape batches can keep -using the same Logfire runtime. +The projection is tape-first but emits Phoenix-friendly GenAI spans: + +- `bub.invoke_agent` root span with `openinference.span.kind=AGENT` +- `bub.llm.chat` child span with `openinference.span.kind=LLM` +- `bub.tool.` child spans with `openinference.span.kind=TOOL` + +Message content, system prompt, token usage, model/provider metadata, and tool +calls are derived from committed tape entries and exported using OTel GenAI and +OpenInference attribute names. diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py index 1d491e9..3099577 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py @@ -1,22 +1,62 @@ from __future__ import annotations +import hashlib +import json import re import threading -from dataclasses import dataclass +from dataclasses import dataclass, field, replace from typing import Any from loguru import logger from republic import TapeEntry SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_.-]+") +SEND_TO_LOGFIRE = False +FORCE_FLUSH_TIMEOUT_MS = 3_000 +SHUTDOWN_TIMEOUT_MS = 1_000 @dataclass(frozen=True) class LogfireTapeExporterSettings: service_name: str = "bub" - send_to_logfire: bool = False - force_flush: bool = True - shutdown_after_flush: bool = True + + +@dataclass(frozen=True) +class TraceMessage: + role: str + content: str = "" + name: str | None = None + tool_call_id: str | None = None + tool_calls: tuple["ToolCall", ...] = () + + +@dataclass(frozen=True) +class ToolCall: + id: str + name: str + arguments: str + result: str | None = None + + +@dataclass(frozen=True) +class TapeTrace: + tape: str + entries: list[TapeEntry] + input_messages: list[TraceMessage] + output_messages: list[TraceMessage] + tool_calls: list[ToolCall] + system_prompt: str | None = None + prompt: str | None = None + output: str | None = None + provider: str | None = None + model: str | None = None + status: str | None = None + usage_input_tokens: int | None = None + usage_output_tokens: int | None = None + usage_total_tokens: int | None = None + duration_ms: int | float | None = None + agent_attributes: dict[str, Any] = field(default_factory=dict) + llm_attributes: dict[str, Any] = field(default_factory=dict) class LogfireTapeExporter: @@ -44,7 +84,7 @@ def _configure(self) -> None: import logfire logfire.configure( - send_to_logfire=self._settings.send_to_logfire, + send_to_logfire=SEND_TO_LOGFIRE, service_name=self._settings.service_name, console=False, scrubbing=False, @@ -52,28 +92,25 @@ def _configure(self) -> None: self._configured = True def _flush(self) -> None: - if not self._settings.force_flush: - return import logfire - logfire.force_flush() - if self._settings.shutdown_after_flush: - logfire.shutdown() - self._configured = False + logfire.force_flush(timeout_millis=FORCE_FLUSH_TIMEOUT_MS) + logfire.shutdown(timeout_millis=SHUTDOWN_TIMEOUT_MS, flush=False) + self._configured = False def _append(self, tape: str, entry: TapeEntry) -> None: self._configure() batch = self._record_entry(tape, entry) if batch is None: return - _instrument_batch(tape, batch) + _instrument_trace(build_tape_trace(tape, batch)) self._flush() def _reset(self, tape: str) -> None: self._configure() batch = self._pop_pending(tape) if batch: - _instrument_batch(tape, batch) + _instrument_trace(build_tape_trace(tape, batch)) _instrument_reset(tape) self._flush() @@ -90,128 +127,459 @@ def _pop_pending(self, tape: str) -> list[TapeEntry]: return self._pending.pop(tape, []) -def _entry_name(entry: TapeEntry) -> str: - value = entry.payload.get("name") - return str(value) if value else entry.kind - - -def _span_name(entry: TapeEntry) -> str: - name = _entry_name(entry) - if entry.kind == "event" and name == "run": - return "bub.model.run" - if entry.kind == "event" and name.startswith("loop.step"): - return "bub.loop.step" - if entry.kind == "event" and name == "command": - return "bub.command" - if entry.kind == "anchor" and name != "session/start": - return "bub.tape.handoff" - safe_name = SAFE_NAME_RE.sub(".", name).strip(".") or entry.kind - return f"bub.tape.{entry.kind}.{safe_name}" +def build_tape_trace(tape: str, entries: list[TapeEntry]) -> TapeTrace: + run_data = _last_event_data(entries, "run") + step_data = _last_event_data(entries, "loop.step") + prompt = _first_prompt(entries) + messages, tool_calls = _extract_messages_and_tools(entries) + input_messages, output_messages = _split_input_output(messages, prompt, tool_calls) + system_prompt = _first_message_content(input_messages, "system") + output = _output_value(output_messages, tool_calls) + provider = _as_text(run_data.get("provider")) + model = _as_text(run_data.get("model")) + prompt_tokens, completion_tokens, total_tokens = _usage(run_data) + status = _as_text(step_data.get("status") or run_data.get("status")) + duration_ms = step_data.get("elapsed_ms") or run_data.get("elapsed_ms") + + trace = TapeTrace( + tape=tape, + entries=entries, + input_messages=input_messages, + output_messages=output_messages, + tool_calls=tool_calls, + system_prompt=system_prompt, + prompt=prompt, + output=output, + provider=provider, + model=model, + status=status, + usage_input_tokens=prompt_tokens, + usage_output_tokens=completion_tokens, + usage_total_tokens=total_tokens, + duration_ms=duration_ms if isinstance(duration_ms, (int, float)) and not isinstance(duration_ms, bool) else None, + ) + return _with_trace_attributes(trace) + + +def _with_trace_attributes(trace: TapeTrace) -> TapeTrace: + agent_attributes = _common_attributes(trace) | { + "openinference.span.kind": "AGENT", + "gen_ai.operation.name": "invoke_agent", + "input.mime_type": "application/json", + "input.value": _json_dumps(_message_payloads(trace.input_messages)), + "output.mime_type": "application/json", + "output.value": trace.output or "", + "bub.tape.batch.entries": len(trace.entries), + } + if trace.status: + agent_attributes["bub.tape.status"] = trace.status + + llm_attributes = _common_attributes(trace) | { + "openinference.span.kind": "LLM", + "gen_ai.operation.name": "chat", + "gen_ai.input.messages": _json_dumps(_otel_messages(trace.input_messages)), + "gen_ai.output.messages": _json_dumps(_otel_messages(trace.output_messages)), + "input.mime_type": "application/json", + "input.value": _json_dumps(_message_payloads(trace.input_messages)), + "output.mime_type": "application/json", + "output.value": trace.output or "", + "gen_ai.output": trace.output or "", + } + if trace.model: + llm_attributes["gen_ai.request.model"] = trace.model + llm_attributes["gen_ai.response.model"] = trace.model + llm_attributes["llm.model_name"] = trace.model + if trace.provider: + llm_attributes["gen_ai.provider.name"] = trace.provider + llm_attributes["llm.provider"] = trace.provider + if trace.usage_input_tokens is not None: + llm_attributes["gen_ai.usage.input_tokens"] = trace.usage_input_tokens + llm_attributes["llm.token_count.prompt"] = trace.usage_input_tokens + if trace.usage_output_tokens is not None: + llm_attributes["gen_ai.usage.output_tokens"] = trace.usage_output_tokens + llm_attributes["llm.token_count.completion"] = trace.usage_output_tokens + if trace.usage_total_tokens is not None: + llm_attributes["llm.token_count.total"] = trace.usage_total_tokens + if trace.duration_ms is not None: + llm_attributes["gen_ai.server.time_to_last_token"] = trace.duration_ms / 1000 + llm_attributes.update(_openinference_messages("llm.input_messages", trace.input_messages)) + llm_attributes.update(_openinference_messages("llm.output_messages", trace.output_messages)) + llm_attributes.update(_openinference_tool_definitions(trace.tool_calls)) + + return replace(trace, agent_attributes=agent_attributes, llm_attributes=llm_attributes) + + +def _extract_messages_and_tools(entries: list[TapeEntry]) -> tuple[list[TraceMessage], list[ToolCall]]: + messages: list[TraceMessage] = [] + pending_calls: list[ToolCall] = [] + + for entry in entries: + if entry.kind == "system": + content = _as_text(entry.payload.get("content")) + if content: + messages.append(TraceMessage(role="system", content=content)) + elif entry.kind == "message": + message = _message_entry(entry) + if message is not None: + messages.append(message) + elif entry.kind == "tool_call": + calls = [_tool_call(call, index) for index, call in enumerate(_payload_list(entry, "calls"))] + pending_calls.extend(calls) + if calls: + messages.append(TraceMessage(role="assistant", tool_calls=tuple(calls))) + elif entry.kind == "tool_result": + results = _payload_list(entry, "results") + pending_calls = _attach_tool_results(pending_calls, results) + for index, result in enumerate(results): + tool_call = pending_calls[index] if index < len(pending_calls) else None + messages.append( + TraceMessage( + role="tool", + content=_stringify(result), + tool_call_id=tool_call.id if tool_call else None, + ) + ) + + return messages, pending_calls + + +def _message_entry(entry: TapeEntry) -> TraceMessage | None: + role = _as_text(entry.payload.get("role")) or "assistant" + content = _as_text(entry.payload.get("content")) or _stringify(entry.payload) + name = _as_text(entry.payload.get("name")) + tool_call_id = _as_text(entry.payload.get("tool_call_id")) + raw_tool_calls = entry.payload.get("tool_calls") + tool_calls = () + if isinstance(raw_tool_calls, list): + tool_calls = tuple(_tool_call(call, index) for index, call in enumerate(raw_tool_calls)) + if not content and not tool_calls: + return None + return TraceMessage(role=role, content=content, name=name, tool_call_id=tool_call_id, tool_calls=tool_calls) + + +def _split_input_output( + messages: list[TraceMessage], prompt: str | None, tool_calls: list[ToolCall] +) -> tuple[list[TraceMessage], list[TraceMessage]]: + last_assistant_index = _last_assistant_content_index(messages) + if last_assistant_index is not None: + return messages[:last_assistant_index], [messages[last_assistant_index]] + + last_tool_call_index = _last_tool_call_index(messages) + if last_tool_call_index is not None: + return messages[:last_tool_call_index], [messages[last_tool_call_index]] + + if messages: + return messages, [] + + if prompt: + return [TraceMessage(role="user", content=prompt)], [] + if tool_calls: + return [], [TraceMessage(role="assistant", tool_calls=tuple(tool_calls))] + return [], [] + + +def _last_assistant_content_index(messages: list[TraceMessage]) -> int | None: + for index in range(len(messages) - 1, -1, -1): + message = messages[index] + if message.role == "assistant" and message.content: + return index + return None + + +def _last_tool_call_index(messages: list[TraceMessage]) -> int | None: + for index in range(len(messages) - 1, -1, -1): + message = messages[index] + if message.role == "assistant" and message.tool_calls: + return index + return None + + +def _tool_call(raw: Any, index: int) -> ToolCall: + call = raw if isinstance(raw, dict) else {"value": raw} + function = call.get("function") + if isinstance(function, dict): + name = function.get("name") or call.get("name") or f"tool_{index}" + arguments = function.get("arguments") or call.get("arguments") or call.get("args") or {} + else: + name = call.get("name") or call.get("tool_name") or f"tool_{index}" + arguments = call.get("arguments") or call.get("args") or call.get("input") or {} + return ToolCall( + id=str(call.get("id") or call.get("tool_call_id") or f"tool-{index}"), + name=str(name), + arguments=_stringify(arguments), + ) -def _payload_data(entry: TapeEntry) -> dict[str, Any]: - data = entry.payload.get("data") - return data if isinstance(data, dict) else {} +def _attach_tool_results(tool_calls: list[ToolCall], results: list[Any]) -> list[ToolCall]: + if not tool_calls: + return [] + updated: list[ToolCall] = [] + for index, call in enumerate(tool_calls): + result = _stringify(results[index]) if index < len(results) else call.result + updated.append(ToolCall(id=call.id, name=call.name, arguments=call.arguments, result=result)) + return updated -def _entry_attributes(tape: str, entry: TapeEntry) -> dict[str, Any]: - data = _payload_data(entry) +def _common_attributes(trace: TapeTrace) -> dict[str, Any]: attributes: dict[str, Any] = { - "bub.tape.name": tape, - "bub.tape.entry.id": entry.id, - "bub.tape.entry.kind": entry.kind, - "bub.tape.entry.name": _entry_name(entry), - "bub.tape.entry.date": entry.date, + "bub.tape.name": trace.tape, + "bub.session.hash": _session_hash(trace.tape), } - for source_key, attr_key in ( - ("status", "bub.tape.entry.status"), - ("step", "bub.loop.step"), - ("elapsed_ms", "bub.duration_ms"), - ("model", "bub.model"), - ("provider", "bub.provider"), - ): - if source_key in data: - attributes[attr_key] = data[source_key] + if trace.entries: + attributes.update( + { + "bub.tape.entry.first_id": trace.entries[0].id, + "bub.tape.entry.last_id": trace.entries[-1].id, + "bub.tape.entry.first_date": trace.entries[0].date, + "bub.tape.entry.last_date": trace.entries[-1].date, + } + ) + return attributes - prompt = data.get("prompt") - if isinstance(prompt, str): - attributes["bub.prompt.chars"] = len(prompt) - elif isinstance(prompt, list): - attributes["bub.prompt.parts"] = len(prompt) - content = entry.payload.get("content") - if isinstance(content, str): - attributes["bub.content.chars"] = len(content) +def _openinference_messages(prefix: str, messages: list[TraceMessage]) -> dict[str, Any]: + attributes: dict[str, Any] = {} + for index, message in enumerate(messages): + base = f"{prefix}.{index}.message" + attributes[f"{base}.role"] = message.role + if message.content: + attributes[f"{base}.content"] = message.content + if message.name: + attributes[f"{base}.name"] = message.name + if message.tool_call_id: + attributes[f"{base}.tool_call_id"] = message.tool_call_id + for call_index, call in enumerate(message.tool_calls): + call_base = f"{base}.tool_calls.{call_index}.tool_call" + attributes[f"{call_base}.id"] = call.id + attributes[f"{call_base}.function.name"] = call.name + attributes[f"{call_base}.function.arguments"] = call.arguments + return attributes - usage = data.get("usage") - if isinstance(usage, dict): - for key in ("prompt_tokens", "completion_tokens", "total_tokens"): - if key in usage: - attributes[f"bub.usage.{key}"] = usage[key] +def _openinference_tool_definitions(tool_calls: list[ToolCall]) -> dict[str, Any]: + attributes: dict[str, Any] = {} + seen: set[str] = set() + for index, call in enumerate(tool_calls): + if call.name in seen: + continue + seen.add(call.name) + attributes[f"llm.tools.{index}.tool.json_schema"] = _json_dumps( + { + "type": "function", + "function": { + "name": call.name, + "parameters": {"type": "object"}, + }, + } + ) return attributes -def _batch_attributes(tape: str, entries: list[TapeEntry]) -> dict[str, Any]: - attributes: dict[str, Any] = { - "bub.tape.name": tape, - "bub.tape.batch.entries": len(entries), +def _tool_span_attributes(trace: TapeTrace, call: ToolCall) -> dict[str, Any]: + attributes = _common_attributes(trace) | { + "openinference.span.kind": "TOOL", + "gen_ai.operation.name": "execute_tool", + "gen_ai.tool.name": call.name, + "gen_ai.tool.args": call.arguments, + "tool.name": call.name, + "tool.call.id": call.id, + "input.mime_type": "application/json", + "input.value": call.arguments, + "output.value": call.result or "", } - if entries: - attributes["bub.tape.batch.first_entry_id"] = entries[0].id - attributes["bub.tape.batch.last_entry_id"] = entries[-1].id - attributes["bub.tape.batch.first_entry_date"] = entries[0].date - attributes["bub.tape.batch.last_entry_date"] = entries[-1].date + if call.result is not None: + attributes["gen_ai.output"] = call.result + attributes["output.mime_type"] = "application/json" return attributes +def _otel_messages(messages: list[TraceMessage]) -> list[dict[str, Any]]: + payloads: list[dict[str, Any]] = [] + for message in messages: + payload: dict[str, Any] = {"role": message.role} + if message.content: + payload["parts"] = [{"type": "text", "content": message.content}] + payload["content"] = message.content + if message.tool_call_id: + payload["tool_call_id"] = message.tool_call_id + if message.tool_calls: + payload["tool_calls"] = [ + { + "id": call.id, + "type": "function", + "function": {"name": call.name, "arguments": call.arguments}, + } + for call in message.tool_calls + ] + payloads.append(payload) + return payloads + + +def _message_payloads(messages: list[TraceMessage]) -> list[dict[str, Any]]: + payloads: list[dict[str, Any]] = [] + for message in messages: + payload: dict[str, Any] = {"role": message.role} + if message.content: + payload["content"] = message.content + if message.name: + payload["name"] = message.name + if message.tool_call_id: + payload["tool_call_id"] = message.tool_call_id + if message.tool_calls: + payload["tool_calls"] = [ + { + "id": call.id, + "name": call.name, + "arguments": call.arguments, + **({"result": call.result} if call.result is not None else {}), + } + for call in message.tool_calls + ] + payloads.append(payload) + return payloads + + +def _payload_list(entry: TapeEntry, key: str) -> list[Any]: + value = entry.payload.get(key) + return value if isinstance(value, list) else [] + + +def _first_message_content(messages: list[TraceMessage], role: str) -> str | None: + for message in messages: + if message.role == role and message.content: + return message.content + return None + + +def _output_value(messages: list[TraceMessage], tool_calls: list[ToolCall]) -> str | None: + content = "\n".join(message.content for message in messages if message.content) + if content: + return content + calls = [call for message in messages for call in message.tool_calls] or tool_calls + if calls: + return _json_dumps([{"id": call.id, "name": call.name, "arguments": call.arguments} for call in calls]) + return None + + +def _first_prompt(entries: list[TapeEntry]) -> str | None: + for entry in entries: + data = _payload_data(entry) + prompt = data.get("prompt") + if isinstance(prompt, str): + return prompt + if isinstance(prompt, list): + return _stringify(prompt) + return None + + +def _last_event_data(entries: list[TapeEntry], name: str) -> dict[str, Any]: + for entry in reversed(entries): + if entry.kind == "event" and _entry_name(entry) == name: + return _payload_data(entry) + return {} + + +def _usage(data: dict[str, Any]) -> tuple[int | None, int | None, int | None]: + usage = data.get("usage") + if not isinstance(usage, dict): + return None, None, None + return ( + _int_or_none(usage.get("prompt_tokens") or usage.get("input_tokens")), + _int_or_none(usage.get("completion_tokens") or usage.get("output_tokens")), + _int_or_none(usage.get("total_tokens")), + ) + + +def _int_or_none(value: Any) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return None + + +def _entry_name(entry: TapeEntry) -> str: + value = entry.payload.get("name") + return str(value) if value else entry.kind + + +def _payload_data(entry: TapeEntry) -> dict[str, Any]: + data = entry.payload.get("data") + return data if isinstance(data, dict) else {} + + def _should_flush_batch(entry: TapeEntry) -> bool: if entry.kind == "event" and _entry_name(entry) in {"command", "loop.step"}: return True return False -def _instrument_batch(tape: str, entries: list[TapeEntry]) -> None: - import logfire +def _session_hash(tape: str) -> str: + return hashlib.sha256(tape.encode("utf-8")).hexdigest()[:16] - @logfire.instrument( - "bub.tape.export", - span_name="bub.tape.export", - extract_args=False, - ) - def emit() -> None: + +def _as_text(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + return _stringify(value) + + +def _stringify(value: Any) -> str: + if isinstance(value, str): + return value + return _json_dumps(value) + + +def _json_dumps(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str) + + +def _instrument_trace(trace: TapeTrace) -> None: + import logfire + from opentelemetry.trace import SpanKind + + with logfire.span( + "bub invoke_agent {tape}", + _span_name="bub.invoke_agent", + _span_kind=SpanKind.INTERNAL, + tape=trace.tape, + **trace.agent_attributes, + ): + llm_context = None with logfire.span( - "bub.tape.batch {tape}", - _span_name="bub.tape.batch", - tape=tape, - **_batch_attributes(tape, entries), - ): - for entry in entries: - with logfire.span( - "bub.tape.entry {entry_name}", - _span_name=_span_name(entry), - entry_name=_entry_name(entry), - **_entry_attributes(tape, entry), - ): - pass - - emit() + "bub chat {model}", + _span_name="bub.llm.chat", + _span_kind=SpanKind.CLIENT, + model=trace.model or "unknown", + **trace.llm_attributes, + ) as llm_span: + llm_context = llm_span.get_span_context() + + links = [] + if llm_context is not None: + links.append((llm_context, {"bub.link.type": "llm_tool_call"})) + for call in trace.tool_calls: + with logfire.span( + "bub tool {tool}", + _span_name=f"bub.tool.{SAFE_NAME_RE.sub('.', call.name).strip('.') or 'call'}", + _span_kind=SpanKind.CLIENT, + _links=links, + tool=call.name, + **_tool_span_attributes(trace, call), + ): + pass def _instrument_reset(tape: str) -> None: import logfire - @logfire.instrument( - "bub.tape.reset", - span_name="bub.tape.reset", - extract_args=False, - ) - def emit() -> None: - with logfire.span( - "bub.tape.reset {tape}", - _span_name="bub.tape.reset", - **{"bub.tape.name": tape}, - ): - pass - - emit() + with logfire.span( + "bub.tape.reset {tape}", + _span_name="bub.tape.reset", + **{"bub.tape.name": tape, "bub.session.hash": _session_hash(tape)}, + ): + pass diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py index 41bad2b..06131be 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py @@ -26,9 +26,6 @@ class OTelTapeStoreSettings(bub.Settings): enabled: bool = Field(default=True, validation_alias="BUB_TAPESTORE_OTEL_ENABLED") service_name: str = Field(default="bub", validation_alias="BUB_TAPESTORE_OTEL_SERVICE_NAME") - send_to_logfire: bool = Field(default=False, validation_alias="BUB_TAPESTORE_OTEL_SEND_TO_LOGFIRE") - force_flush: bool = Field(default=True, validation_alias="BUB_TAPESTORE_OTEL_FORCE_FLUSH") - shutdown_after_flush: bool = Field(default=True, validation_alias="BUB_TAPESTORE_OTEL_SHUTDOWN_AFTER_FLUSH") class OTelTapeStorePlugin: @@ -48,9 +45,6 @@ def provide_tape_store(self) -> Any: exporter = LogfireTapeExporter( LogfireTapeExporterSettings( service_name=settings.service_name, - send_to_logfire=settings.send_to_logfire, - force_flush=settings.force_flush, - shutdown_after_flush=settings.shutdown_after_flush, ) ) return _wrap_store_result(store, exporter) diff --git a/packages/bub-tapestore-otel/tests/test_exporter.py b/packages/bub-tapestore-otel/tests/test_exporter.py index 3ef1001..eb544d9 100644 --- a/packages/bub-tapestore-otel/tests/test_exporter.py +++ b/packages/bub-tapestore-otel/tests/test_exporter.py @@ -1,68 +1,101 @@ from __future__ import annotations -from republic import TapeEntry - -from bub_tapestore_otel.exporter import _batch_attributes, _entry_attributes, _should_flush_batch, _span_name +import json +from republic import TapeEntry -def test_span_name_maps_known_tape_events() -> None: - assert _span_name(TapeEntry.event("run", data={})) == "bub.model.run" - assert _span_name(TapeEntry.event("loop.step", data={})) == "bub.loop.step" - assert _span_name(TapeEntry.event("command", data={})) == "bub.command" +from bub_tapestore_otel.exporter import _should_flush_batch, build_tape_trace -def test_entry_attributes_do_not_include_content() -> None: - entry = TapeEntry.event( - "run", - data={ - "status": "ok", - "elapsed_ms": 12, - "usage": {"total_tokens": 42}, - "prompt": "do not export", - }, - ) +def test_build_tape_trace_exports_genai_and_openinference_llm_attributes() -> None: + entries = [ + TapeEntry.system("system rules"), + TapeEntry.message({"role": "user", "content": "say hello"}), + TapeEntry.message({"role": "assistant", "content": "hello"}), + TapeEntry.event( + "run", + data={ + "provider": "openai", + "model": "gpt-5-mini", + "usage": {"prompt_tokens": 11, "completion_tokens": 3, "total_tokens": 14}, + }, + ), + TapeEntry.event("loop.step", data={"status": "ok", "elapsed_ms": 125}), + ] - attributes = _entry_attributes("tape-1", entry) + trace = build_tape_trace("chat__1", entries) + + assert trace.agent_attributes["openinference.span.kind"] == "AGENT" + assert trace.agent_attributes["gen_ai.operation.name"] == "invoke_agent" + assert trace.agent_attributes["output.value"] == "hello" + + assert trace.llm_attributes["openinference.span.kind"] == "LLM" + assert trace.llm_attributes["gen_ai.operation.name"] == "chat" + assert trace.llm_attributes["gen_ai.provider.name"] == "openai" + assert trace.llm_attributes["gen_ai.request.model"] == "gpt-5-mini" + assert trace.llm_attributes["gen_ai.output"] == "hello" + assert trace.llm_attributes["gen_ai.usage.input_tokens"] == 11 + assert trace.llm_attributes["gen_ai.usage.output_tokens"] == 3 + assert trace.llm_attributes["llm.token_count.total"] == 14 + assert trace.llm_attributes["llm.input_messages.0.message.role"] == "system" + assert trace.llm_attributes["llm.input_messages.0.message.content"] == "system rules" + assert trace.llm_attributes["llm.input_messages.1.message.role"] == "user" + assert trace.llm_attributes["llm.input_messages.1.message.content"] == "say hello" + assert trace.llm_attributes["llm.output_messages.0.message.role"] == "assistant" + assert trace.llm_attributes["llm.output_messages.0.message.content"] == "hello" + + input_messages = json.loads(trace.llm_attributes["gen_ai.input.messages"]) + output_messages = json.loads(trace.llm_attributes["gen_ai.output.messages"]) + assert input_messages == [ + {"role": "system", "parts": [{"type": "text", "content": "system rules"}], "content": "system rules"}, + {"role": "user", "parts": [{"type": "text", "content": "say hello"}], "content": "say hello"}, + ] + assert output_messages == [ + {"role": "assistant", "parts": [{"type": "text", "content": "hello"}], "content": "hello"} + ] - assert attributes["bub.tape.name"] == "tape-1" - assert attributes["bub.tape.entry.kind"] == "event" - assert attributes["bub.tape.entry.name"] == "run" - assert attributes["bub.duration_ms"] == 12 - assert attributes["bub.usage.total_tokens"] == 42 - assert "prompt" not in attributes +def test_build_tape_trace_exports_tool_calls_and_results() -> None: + entries = [ + TapeEntry.message({"role": "user", "content": "search docs"}), + TapeEntry.tool_call([{"id": "call_1", "name": "search", "arguments": {"query": "otel genai"}}]), + TapeEntry.tool_result([{"title": "OpenTelemetry GenAI"}]), + TapeEntry.event("loop.step", data={"status": "ok"}), + ] -def test_entry_attributes_include_safe_shape_metadata() -> None: - entry = TapeEntry.event( - "loop.start", - data={ - "model": "openai:gpt-5", - "prompt": "hello", - }, + trace = build_tape_trace("agent__tools", entries) + + assert trace.tool_calls[0].id == "call_1" + assert trace.tool_calls[0].name == "search" + assert trace.tool_calls[0].arguments == '{"query":"otel genai"}' + assert trace.tool_calls[0].result == '{"title":"OpenTelemetry GenAI"}' + assert trace.llm_attributes["llm.output_messages.0.message.tool_calls.0.tool_call.id"] == "call_1" + assert trace.llm_attributes["llm.output_messages.0.message.tool_calls.0.tool_call.function.name"] == "search" + assert ( + trace.llm_attributes["llm.output_messages.0.message.tool_calls.0.tool_call.function.arguments"] + == '{"query":"otel genai"}' + ) + assert json.loads(trace.llm_attributes["llm.tools.0.tool.json_schema"]) == { + "type": "function", + "function": {"name": "search", "parameters": {"type": "object"}}, + } + + +def test_build_tape_trace_falls_back_to_prompt_when_messages_are_missing() -> None: + trace = build_tape_trace( + "prompt__1", + [ + TapeEntry.event("loop.step.start", data={"prompt": "plain prompt"}), + TapeEntry.event("loop.step", data={"status": "ok"}), + ], ) - attributes = _entry_attributes("tape-1", entry) - - assert attributes["bub.model"] == "openai:gpt-5" - assert attributes["bub.prompt.chars"] == 5 - assert "hello" not in attributes.values() + assert trace.input_messages[0].role == "user" + assert trace.input_messages[0].content == "plain prompt" + assert trace.llm_attributes["llm.input_messages.0.message.content"] == "plain prompt" def test_batch_flushes_on_completed_tape_turn_markers() -> None: assert _should_flush_batch(TapeEntry.event("loop.step", data={"status": "ok"})) assert _should_flush_batch(TapeEntry.event("command", data={})) assert not _should_flush_batch(TapeEntry.event("loop.step.start", data={})) - - -def test_batch_attributes_summarize_entry_range() -> None: - entries = [ - TapeEntry.event("loop.start", data={}), - TapeEntry.event("loop.step", data={"status": "ok"}), - ] - - attributes = _batch_attributes("tape-1", entries) - - assert attributes["bub.tape.name"] == "tape-1" - assert attributes["bub.tape.batch.entries"] == 2 - assert attributes["bub.tape.batch.first_entry_id"] == entries[0].id - assert attributes["bub.tape.batch.last_entry_id"] == entries[-1].id diff --git a/packages/bub-tapestore-otel/tests/test_plugin.py b/packages/bub-tapestore-otel/tests/test_plugin.py index 6271fd6..bdbbb1a 100644 --- a/packages/bub-tapestore-otel/tests/test_plugin.py +++ b/packages/bub-tapestore-otel/tests/test_plugin.py @@ -32,7 +32,7 @@ def test_plugin_wraps_parent_tape_store(monkeypatch) -> None: monkeypatch.setattr( plugin.bub, "ensure_config", - lambda _: OTelTapeStoreSettings(enabled=True, force_flush=False), + lambda _: OTelTapeStoreSettings(enabled=True), ) store = framework._plugin_manager.hook.provide_tape_store() From 008b21810dfbeb54e3764a89dda8b1d0dc5b94db Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Tue, 2 Jun 2026 22:23:43 +0800 Subject: [PATCH 3/5] refactor: refine code and improve otel --- .../src/bub_tapestore_otel/exporter.py | 455 +++++++++++------- .../src/bub_tapestore_otel/plugin.py | 4 +- .../bub-tapestore-otel/tests/test_exporter.py | 113 ++++- .../bub-tapestore-otel/tests/test_plugin.py | 3 +- .../bub-tapestore-otel/tests/test_store.py | 3 +- 5 files changed, 409 insertions(+), 169 deletions(-) diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py index 3099577..bb8c440 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py @@ -4,49 +4,56 @@ import json import re import threading -from dataclasses import dataclass, field, replace +from collections.abc import Iterator, Mapping +from contextlib import contextmanager from typing import Any from loguru import logger +from pydantic import BaseModel, ConfigDict, Field from republic import TapeEntry SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_.-]+") -SEND_TO_LOGFIRE = False FORCE_FLUSH_TIMEOUT_MS = 3_000 -SHUTDOWN_TIMEOUT_MS = 1_000 +TERMINAL_STEP_STATUSES = frozenset({"ok", "error", "failed", "cancelled"}) +TRACER_NAME = "bub_tapestore_otel" +_SPAN_PROCESSOR_LOCK = threading.Lock() +_EXPORTER_RUNTIMES: dict[str, OTelExporterRuntime] = {} -@dataclass(frozen=True) -class LogfireTapeExporterSettings: +class TapeProjectionModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + + +class LogfireTapeExporterSettings(TapeProjectionModel): service_name: str = "bub" -@dataclass(frozen=True) -class TraceMessage: - role: str - content: str = "" - name: str | None = None - tool_call_id: str | None = None - tool_calls: tuple["ToolCall", ...] = () +class OTelExporterRuntime(TapeProjectionModel): + provider: Any + tracer: Any -@dataclass(frozen=True) -class ToolCall: +class ToolCall(TapeProjectionModel): id: str name: str arguments: str result: str | None = None -@dataclass(frozen=True) -class TapeTrace: +class TraceMessage(TapeProjectionModel): + role: str + content: str = "" + name: str | None = None + tool_call_id: str | None = None + tool_calls: tuple[ToolCall, ...] = () + + +class TraceProjection(TapeProjectionModel): tape: str entries: list[TapeEntry] input_messages: list[TraceMessage] output_messages: list[TraceMessage] tool_calls: list[ToolCall] - system_prompt: str | None = None - prompt: str | None = None output: str | None = None provider: str | None = None model: str | None = None @@ -55,14 +62,25 @@ class TapeTrace: usage_output_tokens: int | None = None usage_total_tokens: int | None = None duration_ms: int | float | None = None - agent_attributes: dict[str, Any] = field(default_factory=dict) - llm_attributes: dict[str, Any] = field(default_factory=dict) + + +class StepTrace(TraceProjection): + step: int + step_attributes: dict[str, Any] = Field(default_factory=dict) + llm_attributes: dict[str, Any] = Field(default_factory=dict) + + +class TapeTrace(TraceProjection): + system_prompt: str | None = None + prompt: str | None = None + steps: list[StepTrace] = Field(default_factory=list) + agent_attributes: dict[str, Any] = Field(default_factory=dict) + llm_attributes: dict[str, Any] = Field(default_factory=dict) class LogfireTapeExporter: def __init__(self, settings: LogfireTapeExporterSettings | None = None) -> None: self._settings = settings or LogfireTapeExporterSettings() - self._configured = False self._lock = threading.Lock() self._pending: dict[str, list[TapeEntry]] = {} @@ -78,41 +96,27 @@ def reset(self, tape: str) -> None: except Exception: logger.opt(exception=True).warning("tapestore.otel.export_failed action=reset tape={}", tape) - def _configure(self) -> None: - if self._configured: - return - import logfire - - logfire.configure( - send_to_logfire=SEND_TO_LOGFIRE, - service_name=self._settings.service_name, - console=False, - scrubbing=False, - ) - self._configured = True - - def _flush(self) -> None: - import logfire + def _ensure_exporter(self) -> OTelExporterRuntime: + return _ensure_otel_exporter_runtime(self._settings.service_name) - logfire.force_flush(timeout_millis=FORCE_FLUSH_TIMEOUT_MS) - logfire.shutdown(timeout_millis=SHUTDOWN_TIMEOUT_MS, flush=False) - self._configured = False + def _flush(self, runtime: OTelExporterRuntime) -> None: + runtime.provider.force_flush(timeout_millis=FORCE_FLUSH_TIMEOUT_MS) def _append(self, tape: str, entry: TapeEntry) -> None: - self._configure() + runtime = self._ensure_exporter() batch = self._record_entry(tape, entry) if batch is None: return - _instrument_trace(build_tape_trace(tape, batch)) - self._flush() + _instrument_trace(build_tape_trace(tape, batch), tracer=runtime.tracer) + self._flush(runtime) def _reset(self, tape: str) -> None: - self._configure() + runtime = self._ensure_exporter() batch = self._pop_pending(tape) if batch: - _instrument_trace(build_tape_trace(tape, batch)) - _instrument_reset(tape) - self._flush() + _instrument_trace(build_tape_trace(tape, batch), tracer=runtime.tracer) + _instrument_reset(tape, tracer=runtime.tracer) + self._flush(runtime) def _record_entry(self, tape: str, entry: TapeEntry) -> list[TapeEntry] | None: with self._lock: @@ -127,42 +131,86 @@ def _pop_pending(self, tape: str) -> list[TapeEntry]: return self._pending.pop(tape, []) +def _ensure_otel_exporter_runtime(service_name: str) -> OTelExporterRuntime: + with _SPAN_PROCESSOR_LOCK: + runtime = _EXPORTER_RUNTIMES.get(service_name) + if runtime is not None: + return runtime + + runtime = _build_otel_exporter_runtime(service_name) + _EXPORTER_RUNTIMES[service_name] = runtime + return runtime + + +def _build_otel_exporter_runtime(service_name: str) -> OTelExporterRuntime: + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + + provider = TracerProvider(resource=Resource.create({"service.name": service_name})) + provider.add_span_processor(_build_otel_span_processor()) + return OTelExporterRuntime(provider=provider, tracer=provider.get_tracer(TRACER_NAME)) + + +def _build_otel_span_processor() -> object: + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + return BatchSpanProcessor(OTLPSpanExporter()) + + def build_tape_trace(tape: str, entries: list[TapeEntry]) -> TapeTrace: + steps = [_build_step_trace(tape, step, index) for index, step in enumerate(_split_step_entries(entries), start=1)] + prompt_tokens, completion_tokens, total_tokens = _combined_usage(entries) + fields = _trace_projection_fields(tape, entries) + fields.update({ + "system_prompt": _first_message_content(fields["input_messages"], "system"), + "prompt": _first_prompt(entries), + "usage_input_tokens": prompt_tokens, + "usage_output_tokens": completion_tokens, + "usage_total_tokens": total_tokens, + "duration_ms": _valid_duration_ms(_combined_duration_ms(entries)), + "steps": steps, + }) + trace = TapeTrace(**fields) + return _with_trace_attributes(trace) + + +def _build_step_trace(tape: str, entries: list[TapeEntry], index: int) -> StepTrace: + step_data = _last_event_data(entries, "loop.step") + step = StepTrace( + **_trace_projection_fields(tape, entries), + step=_step_number(step_data, index), + ) + return _with_step_attributes(step) + + +def _trace_projection_fields(tape: str, entries: list[TapeEntry]) -> dict[str, Any]: run_data = _last_event_data(entries, "run") step_data = _last_event_data(entries, "loop.step") - prompt = _first_prompt(entries) messages, tool_calls = _extract_messages_and_tools(entries) - input_messages, output_messages = _split_input_output(messages, prompt, tool_calls) - system_prompt = _first_message_content(input_messages, "system") + input_messages, output_messages = _split_input_output(messages, _first_prompt(entries), tool_calls) output = _output_value(output_messages, tool_calls) - provider = _as_text(run_data.get("provider")) - model = _as_text(run_data.get("model")) prompt_tokens, completion_tokens, total_tokens = _usage(run_data) - status = _as_text(step_data.get("status") or run_data.get("status")) duration_ms = step_data.get("elapsed_ms") or run_data.get("elapsed_ms") - - trace = TapeTrace( - tape=tape, - entries=entries, - input_messages=input_messages, - output_messages=output_messages, - tool_calls=tool_calls, - system_prompt=system_prompt, - prompt=prompt, - output=output, - provider=provider, - model=model, - status=status, - usage_input_tokens=prompt_tokens, - usage_output_tokens=completion_tokens, - usage_total_tokens=total_tokens, - duration_ms=duration_ms if isinstance(duration_ms, (int, float)) and not isinstance(duration_ms, bool) else None, - ) - return _with_trace_attributes(trace) + return { + "tape": tape, + "entries": entries, + "input_messages": input_messages, + "output_messages": output_messages, + "tool_calls": tool_calls, + "output": output, + "provider": _as_text(run_data.get("provider")), + "model": _as_text(run_data.get("model")), + "status": _as_text(step_data.get("status") or run_data.get("status")), + "usage_input_tokens": prompt_tokens, + "usage_output_tokens": completion_tokens, + "usage_total_tokens": total_tokens, + "duration_ms": _valid_duration_ms(duration_ms), + } def _with_trace_attributes(trace: TapeTrace) -> TapeTrace: - agent_attributes = _common_attributes(trace) | { + agent_attributes = _common_attributes(trace.tape, trace.entries) | { "openinference.span.kind": "AGENT", "gen_ai.operation.name": "invoke_agent", "input.mime_type": "application/json", @@ -174,39 +222,78 @@ def _with_trace_attributes(trace: TapeTrace) -> TapeTrace: if trace.status: agent_attributes["bub.tape.status"] = trace.status - llm_attributes = _common_attributes(trace) | { + return trace.model_copy(update={"agent_attributes": agent_attributes, "llm_attributes": _llm_attributes(trace)}) + + +def _with_step_attributes(step: StepTrace) -> StepTrace: + step_attributes = _common_attributes(step.tape, step.entries) | { + "bub.agent.step": step.step, + "bub.tape.batch.entries": len(step.entries), + } + if step.status: + step_attributes["bub.tape.status"] = step.status + if step.duration_ms is not None: + step_attributes["bub.agent.step.duration_ms"] = step.duration_ms + + return step.model_copy(update={"step_attributes": step_attributes, "llm_attributes": _llm_attributes(step)}) + + +def _llm_attributes(projection: TraceProjection) -> dict[str, Any]: + attributes = _common_attributes(projection.tape, projection.entries) | { "openinference.span.kind": "LLM", "gen_ai.operation.name": "chat", - "gen_ai.input.messages": _json_dumps(_otel_messages(trace.input_messages)), - "gen_ai.output.messages": _json_dumps(_otel_messages(trace.output_messages)), + "gen_ai.input.messages": _json_dumps(_otel_messages(projection.input_messages)), + "gen_ai.output.messages": _json_dumps(_otel_messages(projection.output_messages)), "input.mime_type": "application/json", - "input.value": _json_dumps(_message_payloads(trace.input_messages)), + "input.value": _json_dumps(_message_payloads(projection.input_messages)), "output.mime_type": "application/json", - "output.value": trace.output or "", - "gen_ai.output": trace.output or "", + "output.value": projection.output or "", + "gen_ai.output": projection.output or "", + } + _add_model_attributes(attributes, projection) + _add_usage_attributes(attributes, projection) + if projection.duration_ms is not None: + attributes["gen_ai.server.time_to_last_token"] = projection.duration_ms / 1000 + attributes.update(_openinference_messages("llm.input_messages", projection.input_messages)) + attributes.update(_openinference_messages("llm.output_messages", projection.output_messages)) + attributes.update(_openinference_tool_definitions(projection.tool_calls)) + return attributes + + +def _add_model_attributes(attributes: dict[str, Any], projection: TraceProjection) -> None: + if projection.model: + attributes["gen_ai.request.model"] = projection.model + attributes["gen_ai.response.model"] = projection.model + attributes["llm.model_name"] = projection.model + if projection.provider: + attributes["gen_ai.provider.name"] = projection.provider + attributes["llm.provider"] = projection.provider + + +def _add_usage_attributes(attributes: dict[str, Any], projection: TraceProjection) -> None: + usage_attributes = { + "gen_ai.usage.input_tokens": projection.usage_input_tokens, + "llm.token_count.prompt": projection.usage_input_tokens, + "gen_ai.usage.output_tokens": projection.usage_output_tokens, + "llm.token_count.completion": projection.usage_output_tokens, + "llm.token_count.total": projection.usage_total_tokens, } - if trace.model: - llm_attributes["gen_ai.request.model"] = trace.model - llm_attributes["gen_ai.response.model"] = trace.model - llm_attributes["llm.model_name"] = trace.model - if trace.provider: - llm_attributes["gen_ai.provider.name"] = trace.provider - llm_attributes["llm.provider"] = trace.provider - if trace.usage_input_tokens is not None: - llm_attributes["gen_ai.usage.input_tokens"] = trace.usage_input_tokens - llm_attributes["llm.token_count.prompt"] = trace.usage_input_tokens - if trace.usage_output_tokens is not None: - llm_attributes["gen_ai.usage.output_tokens"] = trace.usage_output_tokens - llm_attributes["llm.token_count.completion"] = trace.usage_output_tokens - if trace.usage_total_tokens is not None: - llm_attributes["llm.token_count.total"] = trace.usage_total_tokens - if trace.duration_ms is not None: - llm_attributes["gen_ai.server.time_to_last_token"] = trace.duration_ms / 1000 - llm_attributes.update(_openinference_messages("llm.input_messages", trace.input_messages)) - llm_attributes.update(_openinference_messages("llm.output_messages", trace.output_messages)) - llm_attributes.update(_openinference_tool_definitions(trace.tool_calls)) - - return replace(trace, agent_attributes=agent_attributes, llm_attributes=llm_attributes) + attributes.update({name: value for name, value in usage_attributes.items() if value is not None}) + + +def _split_step_entries(entries: list[TapeEntry]) -> list[list[TapeEntry]]: + steps: list[list[TapeEntry]] = [] + current: list[TapeEntry] = [] + + for entry in entries: + current.append(entry) + if entry.kind == "event" and _entry_name(entry) == "loop.step": + steps.append(current) + current = [] + + if current and not steps: + steps.append(current) + return steps def _extract_messages_and_tools(entries: list[TapeEntry]) -> tuple[list[TraceMessage], list[ToolCall]]: @@ -320,20 +407,18 @@ def _attach_tool_results(tool_calls: list[ToolCall], results: list[Any]) -> list return updated -def _common_attributes(trace: TapeTrace) -> dict[str, Any]: +def _common_attributes(tape: str, entries: list[TapeEntry]) -> dict[str, Any]: attributes: dict[str, Any] = { - "bub.tape.name": trace.tape, - "bub.session.hash": _session_hash(trace.tape), + "bub.tape.name": tape, + "bub.session.hash": _session_hash(tape), } - if trace.entries: - attributes.update( - { - "bub.tape.entry.first_id": trace.entries[0].id, - "bub.tape.entry.last_id": trace.entries[-1].id, - "bub.tape.entry.first_date": trace.entries[0].date, - "bub.tape.entry.last_date": trace.entries[-1].date, - } - ) + if entries: + attributes.update({ + "bub.tape.entry.first_id": entries[0].id, + "bub.tape.entry.last_id": entries[-1].id, + "bub.tape.entry.first_date": entries[0].date, + "bub.tape.entry.last_date": entries[-1].date, + }) return attributes @@ -363,24 +448,23 @@ def _openinference_tool_definitions(tool_calls: list[ToolCall]) -> dict[str, Any if call.name in seen: continue seen.add(call.name) - attributes[f"llm.tools.{index}.tool.json_schema"] = _json_dumps( - { - "type": "function", - "function": { - "name": call.name, - "parameters": {"type": "object"}, - }, - } - ) + attributes[f"llm.tools.{index}.tool.json_schema"] = _json_dumps({ + "type": "function", + "function": { + "name": call.name, + "parameters": {"type": "object"}, + }, + }) return attributes -def _tool_span_attributes(trace: TapeTrace, call: ToolCall) -> dict[str, Any]: - attributes = _common_attributes(trace) | { +def _tool_span_attributes(step: StepTrace, call: ToolCall) -> dict[str, Any]: + attributes = _common_attributes(step.tape, step.entries) | { "openinference.span.kind": "TOOL", "gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": call.name, "gen_ai.tool.args": call.arguments, + "bub.agent.step": step.step, "tool.name": call.name, "tool.call.id": call.id, "input.mime_type": "application/json", @@ -490,6 +574,46 @@ def _usage(data: dict[str, Any]) -> tuple[int | None, int | None, int | None]: ) +def _combined_usage(entries: list[TapeEntry]) -> tuple[int | None, int | None, int | None]: + totals = [0, 0, 0] + saw_usage = False + for entry in entries: + if entry.kind != "event" or _entry_name(entry) != "run": + continue + usage = _usage(_payload_data(entry)) + if all(value is None for value in usage): + continue + saw_usage = True + for index, value in enumerate(usage): + if value is not None: + totals[index] += value + if not saw_usage: + return None, None, None + return totals[0], totals[1], totals[2] + + +def _combined_duration_ms(entries: list[TapeEntry]) -> int | float | None: + total = 0 + saw_duration = False + for entry in entries: + if entry.kind != "event" or _entry_name(entry) != "loop.step": + continue + elapsed_ms = _payload_data(entry).get("elapsed_ms") + if isinstance(elapsed_ms, (int, float)) and not isinstance(elapsed_ms, bool): + total += elapsed_ms + saw_duration = True + return total if saw_duration else None + + +def _valid_duration_ms(value: object) -> int | float | None: + return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None + + +def _step_number(data: dict[str, Any], fallback: int) -> int: + value = data.get("step") + return value if isinstance(value, int) and not isinstance(value, bool) else fallback + + def _int_or_none(value: Any) -> int | None: if isinstance(value, bool): return None @@ -511,9 +635,18 @@ def _payload_data(entry: TapeEntry) -> dict[str, Any]: def _should_flush_batch(entry: TapeEntry) -> bool: - if entry.kind == "event" and _entry_name(entry) in {"command", "loop.step"}: + if entry.kind != "event": + return False + if _entry_name(entry) == "command": return True - return False + if _entry_name(entry) != "loop.step": + return False + return _is_terminal_step(entry) + + +def _is_terminal_step(entry: TapeEntry) -> bool: + status = _as_text(_payload_data(entry).get("status")) + return status in TERMINAL_STEP_STATUSES def _session_hash(tape: str) -> str: @@ -538,48 +671,48 @@ def _json_dumps(value: Any) -> str: return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str) -def _instrument_trace(trace: TapeTrace) -> None: - import logfire +def _instrument_trace(trace: TapeTrace, *, tracer: Any) -> None: from opentelemetry.trace import SpanKind - with logfire.span( - "bub invoke_agent {tape}", - _span_name="bub.invoke_agent", - _span_kind=SpanKind.INTERNAL, - tape=trace.tape, - **trace.agent_attributes, - ): - llm_context = None - with logfire.span( - "bub chat {model}", - _span_name="bub.llm.chat", - _span_kind=SpanKind.CLIENT, - model=trace.model or "unknown", - **trace.llm_attributes, - ) as llm_span: - llm_context = llm_span.get_span_context() - - links = [] - if llm_context is not None: - links.append((llm_context, {"bub.link.type": "llm_tool_call"})) - for call in trace.tool_calls: - with logfire.span( - "bub tool {tool}", - _span_name=f"bub.tool.{SAFE_NAME_RE.sub('.', call.name).strip('.') or 'call'}", - _span_kind=SpanKind.CLIENT, - _links=links, - tool=call.name, - **_tool_span_attributes(trace, call), + with _otel_span(tracer, "bub.invoke_agent", kind=SpanKind.INTERNAL, attributes=trace.agent_attributes): + for step in trace.steps: + _instrument_step(step, tracer=tracer) + + +def _instrument_step(step: StepTrace, *, tracer: Any) -> None: + from opentelemetry.trace import SpanKind + + with _otel_span(tracer, "bub.agent.step", kind=SpanKind.INTERNAL, attributes=step.step_attributes): + with _otel_span(tracer, "bub.llm.chat", kind=SpanKind.CLIENT, attributes=step.llm_attributes): + pass + + for call in step.tool_calls: + with _otel_span( + tracer, + f"bub.tool.{SAFE_NAME_RE.sub('.', call.name).strip('.') or 'call'}", + kind=SpanKind.CLIENT, + attributes=_tool_span_attributes(step, call), ): pass -def _instrument_reset(tape: str) -> None: - import logfire +def _instrument_reset(tape: str, *, tracer: Any) -> None: + from opentelemetry.trace import SpanKind - with logfire.span( - "bub.tape.reset {tape}", - _span_name="bub.tape.reset", - **{"bub.tape.name": tape, "bub.session.hash": _session_hash(tape)}, + with _otel_span( + tracer, + "bub.tape.reset", + kind=SpanKind.INTERNAL, + attributes={"bub.tape.name": tape, "bub.session.hash": _session_hash(tape)}, ): pass + + +@contextmanager +def _otel_span(tracer: Any, name: str, *, kind: object, attributes: Mapping[str, Any]) -> Iterator[None]: + with tracer.start_as_current_span(name, kind=kind, attributes=_otel_attributes(attributes)): + yield + + +def _otel_attributes(attributes: Mapping[str, Any]) -> dict[str, str | bool | int | float]: + return {name: value for name, value in attributes.items() if isinstance(value, (str, bool, int, float))} diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py index 06131be..9b8efad 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py @@ -4,11 +4,11 @@ from collections.abc import AsyncIterator, Iterator from typing import Any +import bub +from bub import BubFramework, hookimpl from pydantic import Field from pydantic_settings import SettingsConfigDict -import bub -from bub import BubFramework, hookimpl from bub_tapestore_otel.exporter import LogfireTapeExporter, LogfireTapeExporterSettings from bub_tapestore_otel.store import OTelTapeStore diff --git a/packages/bub-tapestore-otel/tests/test_exporter.py b/packages/bub-tapestore-otel/tests/test_exporter.py index eb544d9..220732f 100644 --- a/packages/bub-tapestore-otel/tests/test_exporter.py +++ b/packages/bub-tapestore-otel/tests/test_exporter.py @@ -1,11 +1,13 @@ from __future__ import annotations import json +from contextlib import contextmanager +from types import SimpleNamespace +import bub_tapestore_otel.exporter as exporter +from bub_tapestore_otel.exporter import LogfireTapeExporter, _instrument_trace, _should_flush_batch, build_tape_trace from republic import TapeEntry -from bub_tapestore_otel.exporter import _should_flush_batch, build_tape_trace - def test_build_tape_trace_exports_genai_and_openinference_llm_attributes() -> None: entries = [ @@ -79,6 +81,48 @@ def test_build_tape_trace_exports_tool_calls_and_results() -> None: "type": "function", "function": {"name": "search", "parameters": {"type": "object"}}, } + assert trace.steps[0].tool_calls[0].name == "search" + assert trace.steps[0].llm_attributes["llm.tools.0.tool.json_schema"] + + +def test_build_tape_trace_groups_a_turn_into_steps() -> None: + entries = [ + TapeEntry.event("loop.step.start", data={"step": 1, "prompt": "first"}), + TapeEntry.message({"role": "user", "content": "first"}), + TapeEntry.tool_call([{"id": "call_1", "name": "search", "arguments": {"query": "otel"}}]), + TapeEntry.tool_result(["result"]), + TapeEntry.event( + "run", + data={ + "provider": "openai", + "model": "gpt-5-mini", + "usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}, + }, + ), + TapeEntry.event("loop.step", data={"step": 1, "status": "continue", "elapsed_ms": 100}), + TapeEntry.event("loop.step.start", data={"step": 2, "prompt": "second"}), + TapeEntry.message({"role": "assistant", "content": "done"}), + TapeEntry.event( + "run", + data={ + "provider": "openai", + "model": "gpt-5-mini", + "usage": {"prompt_tokens": 20, "completion_tokens": 4, "total_tokens": 24}, + }, + ), + TapeEntry.event("loop.step", data={"step": 2, "status": "ok", "elapsed_ms": 200}), + ] + + trace = build_tape_trace("agent__steps", entries) + + assert trace.usage_input_tokens == 30 + assert trace.usage_output_tokens == 6 + assert trace.usage_total_tokens == 36 + assert trace.duration_ms == 300 + assert [step.step for step in trace.steps] == [1, 2] + assert [step.status for step in trace.steps] == ["continue", "ok"] + assert trace.steps[0].tool_calls[0].name == "search" + assert trace.steps[1].output == "done" def test_build_tape_trace_falls_back_to_prompt_when_messages_are_missing() -> None: @@ -97,5 +141,70 @@ def test_build_tape_trace_falls_back_to_prompt_when_messages_are_missing() -> No def test_batch_flushes_on_completed_tape_turn_markers() -> None: assert _should_flush_batch(TapeEntry.event("loop.step", data={"status": "ok"})) + assert _should_flush_batch(TapeEntry.event("loop.step", data={"status": "error"})) assert _should_flush_batch(TapeEntry.event("command", data={})) + assert not _should_flush_batch(TapeEntry.event("loop.step", data={"status": "continue"})) assert not _should_flush_batch(TapeEntry.event("loop.step.start", data={})) + + +def test_instrument_trace_nests_steps_and_tools_under_agent(monkeypatch) -> None: + spans: list[tuple[str, str | None]] = [] + stack: list[str] = [] + + class FakeTracer: + @contextmanager + def start_as_current_span(self, name, **_kwargs): + spans.append((name, stack[-1] if stack else None)) + stack.append(name) + try: + yield SimpleNamespace(get_span_context=lambda: object()) + finally: + stack.pop() + + trace = build_tape_trace( + "agent__nested", + [ + TapeEntry.message({"role": "user", "content": "search docs"}), + TapeEntry.tool_call([{"id": "call_1", "name": "search", "arguments": {"query": "otel"}}]), + TapeEntry.tool_result(["result"]), + TapeEntry.event("run", data={"provider": "openai", "model": "gpt-5-mini"}), + TapeEntry.event("loop.step", data={"step": 1, "status": "ok"}), + ], + ) + + _instrument_trace(trace, tracer=FakeTracer()) + + assert spans == [ + ("bub.invoke_agent", None), + ("bub.agent.step", "bub.invoke_agent"), + ("bub.llm.chat", "bub.agent.step"), + ("bub.tool.search", "bub.agent.step"), + ] + + +def test_exporter_uses_span_processor_without_shutdown(monkeypatch) -> None: + calls: list[str] = [] + + class FakeProvider: + def force_flush(self, *, timeout_millis: int) -> None: + calls.append(f"force_flush:{timeout_millis}") + + fake_runtime = exporter.OTelExporterRuntime(provider=FakeProvider(), tracer=object()) + + monkeypatch.setattr(exporter, "_EXPORTER_RUNTIMES", {}) + monkeypatch.setattr(exporter, "_build_otel_exporter_runtime", lambda _service_name: calls.append("build_runtime") or fake_runtime) + monkeypatch.setattr( + exporter, + "_instrument_trace", + lambda _trace, *, tracer: calls.append(f"instrument_trace:{tracer is fake_runtime.tracer}"), + ) + + tape_exporter = LogfireTapeExporter() + tape_exporter.append("tape-1", TapeEntry.message({"role": "user", "content": "hello"})) + tape_exporter.append("tape-1", TapeEntry.event("loop.step", data={"status": "ok"})) + + assert calls == [ + "build_runtime", + "instrument_trace:True", + "force_flush:3000", + ] diff --git a/packages/bub-tapestore-otel/tests/test_plugin.py b/packages/bub-tapestore-otel/tests/test_plugin.py index bdbbb1a..700ff88 100644 --- a/packages/bub-tapestore-otel/tests/test_plugin.py +++ b/packages/bub-tapestore-otel/tests/test_plugin.py @@ -1,8 +1,7 @@ from __future__ import annotations -import pluggy - import bub_tapestore_otel.plugin as plugin +import pluggy from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl from bub_tapestore_otel.plugin import OTelTapeStorePlugin, OTelTapeStoreSettings from bub_tapestore_otel.store import OTelTapeStore diff --git a/packages/bub-tapestore-otel/tests/test_store.py b/packages/bub-tapestore-otel/tests/test_store.py index 9540b93..ba43563 100644 --- a/packages/bub-tapestore-otel/tests/test_store.py +++ b/packages/bub-tapestore-otel/tests/test_store.py @@ -3,9 +3,8 @@ from collections.abc import Iterable import pytest -from republic import TapeEntry, TapeQuery - from bub_tapestore_otel.store import OTelTapeStore +from republic import TapeEntry, TapeQuery class MemoryStore: From 086ef3c481d9e576fd5c56f69e457d3956205ce1 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Tue, 2 Jun 2026 23:14:33 +0800 Subject: [PATCH 4/5] refactor: align otel genai exporter semantics --- packages/bub-tapestore-otel/README.md | 49 ++- packages/bub-tapestore-otel/pyproject.toml | 3 +- .../src/bub_tapestore_otel/exporter.py | 326 ++++++++---------- .../src/bub_tapestore_otel/plugin.py | 8 +- .../bub-tapestore-otel/tests/test_exporter.py | 57 +-- uv.lock | 112 +----- 6 files changed, 216 insertions(+), 339 deletions(-) diff --git a/packages/bub-tapestore-otel/README.md b/packages/bub-tapestore-otel/README.md index edb9df2..7107d88 100644 --- a/packages/bub-tapestore-otel/README.md +++ b/packages/bub-tapestore-otel/README.md @@ -1,13 +1,13 @@ # bub-tapestore-otel `bub-tapestore-otel` wraps the active Bub tape store and projects committed tape -writes to OpenTelemetry through Logfire. +writes to OpenTelemetry through the OTLP HTTP exporter. It is a transparent tape-store decorator: ```text Bub -> OTelTapeStore -> active TapeStore - -> Logfire / OTLP + -> OpenTelemetry / OTLP ``` The real tape backend can still be the builtin file store or another contrib @@ -25,23 +25,52 @@ OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf \ uv run bub run "hello" ``` +For local Jaeger: + +```bash +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://127.0.0.1:4318/v1/traces \ +OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf \ +uv run bub run "hello" +``` + Plugin settings: | Variable | Default | Description | | --- | --- | --- | | `BUB_TAPESTORE_OTEL_ENABLED` | `true` | Wrap the active tape store. | -| `BUB_TAPESTORE_OTEL_SERVICE_NAME` | `bub` | Service name used by Logfire. | +| `BUB_TAPESTORE_OTEL_SERVICE_NAME` | `bub` | OpenTelemetry `service.name` resource value. | OTLP exporter configuration stays on the standard OpenTelemetry environment variables such as `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` and `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL`. -The projection is tape-first but emits Phoenix-friendly GenAI spans: +The projection is tape-first and uses separate namespaces for separate +semantic sources: + +- `gen_ai.*` attributes and span names follow the current OpenTelemetry GenAI + semantic conventions. +- `bub.*` attributes describe Bub-specific runtime facts such as tape identity, + tape entry boundaries, loop step number, step duration, and the runtime tool + name Bub actually executed. +- `openinference.*`, `llm.*`, `input.*`, and `output.*` attributes are emitted + as Phoenix/OpenInference compatibility attributes so Phoenix can classify and + render Bub spans usefully. They are not OpenTelemetry semantic-convention + attributes. + +It emits these spans: -- `bub.invoke_agent` root span with `openinference.span.kind=AGENT` -- `bub.llm.chat` child span with `openinference.span.kind=LLM` -- `bub.tool.` child spans with `openinference.span.kind=TOOL` +- `invoke_agent` root span with `gen_ai.operation.name=invoke_agent` and + `openinference.span.kind=AGENT` +- `bub.agent.step` framework span for each Bub loop turn, carrying custom + `bub.agent.step` and `bub.agent.step.duration_ms` attributes +- `chat ` child span with `gen_ai.operation.name=chat` and + `openinference.span.kind=LLM` +- `execute_tool ` child spans with `gen_ai.operation.name=execute_tool`, + `gen_ai.tool.call.*`, `bub.tool.*`, and `openinference.span.kind=TOOL` -Message content, system prompt, token usage, model/provider metadata, and tool -calls are derived from committed tape entries and exported using OTel GenAI and -OpenInference attribute names. +All spans include `gen_ai.conversation.id` for trace correlation. Message +content, system prompt, token usage, model/provider metadata, and tool calls are +derived from committed tape entries and exported using OTel GenAI and +OpenInference attribute names. Bub loop turns do not currently have a dedicated +OTel GenAI semantic-convention attribute, so step numbering stays in the +`bub.*` namespace. diff --git a/packages/bub-tapestore-otel/pyproject.toml b/packages/bub-tapestore-otel/pyproject.toml index 0a8f527..282c7d3 100644 --- a/packages/bub-tapestore-otel/pyproject.toml +++ b/packages/bub-tapestore-otel/pyproject.toml @@ -9,7 +9,8 @@ authors = [ requires-python = ">=3.12" dependencies = [ "bub", - "logfire>=4.31.0", + "opentelemetry-exporter-otlp-proto-http>=1.39.0", + "opentelemetry-sdk>=1.39.0", "republic>=0.5.7", ] diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py index bb8c440..9788b60 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py @@ -2,9 +2,8 @@ import hashlib import json -import re import threading -from collections.abc import Iterator, Mapping +from collections.abc import Callable, Iterator, Mapping from contextlib import contextmanager from typing import Any @@ -12,19 +11,16 @@ from pydantic import BaseModel, ConfigDict, Field from republic import TapeEntry -SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_.-]+") FORCE_FLUSH_TIMEOUT_MS = 3_000 TERMINAL_STEP_STATUSES = frozenset({"ok", "error", "failed", "cancelled"}) TRACER_NAME = "bub_tapestore_otel" -_SPAN_PROCESSOR_LOCK = threading.Lock() -_EXPORTER_RUNTIMES: dict[str, OTelExporterRuntime] = {} class TapeProjectionModel(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) -class LogfireTapeExporterSettings(TapeProjectionModel): +class OTelTapeExporterSettings(TapeProjectionModel): service_name: str = "bub" @@ -66,7 +62,6 @@ class TraceProjection(TapeProjectionModel): class StepTrace(TraceProjection): step: int - step_attributes: dict[str, Any] = Field(default_factory=dict) llm_attributes: dict[str, Any] = Field(default_factory=dict) @@ -78,11 +73,12 @@ class TapeTrace(TraceProjection): llm_attributes: dict[str, Any] = Field(default_factory=dict) -class LogfireTapeExporter: - def __init__(self, settings: LogfireTapeExporterSettings | None = None) -> None: - self._settings = settings or LogfireTapeExporterSettings() +class OTelTapeExporter: + def __init__(self, settings: OTelTapeExporterSettings | None = None) -> None: + self._settings = settings or OTelTapeExporterSettings() self._lock = threading.Lock() self._pending: dict[str, list[TapeEntry]] = {} + self._runtime: OTelExporterRuntime | None = None def append(self, tape: str, entry: TapeEntry) -> None: try: @@ -97,7 +93,10 @@ def reset(self, tape: str) -> None: logger.opt(exception=True).warning("tapestore.otel.export_failed action=reset tape={}", tape) def _ensure_exporter(self) -> OTelExporterRuntime: - return _ensure_otel_exporter_runtime(self._settings.service_name) + with self._lock: + if self._runtime is None: + self._runtime = _build_otel_exporter_runtime(self._settings.service_name) + return self._runtime def _flush(self, runtime: OTelExporterRuntime) -> None: runtime.provider.force_flush(timeout_millis=FORCE_FLUSH_TIMEOUT_MS) @@ -131,17 +130,6 @@ def _pop_pending(self, tape: str) -> list[TapeEntry]: return self._pending.pop(tape, []) -def _ensure_otel_exporter_runtime(service_name: str) -> OTelExporterRuntime: - with _SPAN_PROCESSOR_LOCK: - runtime = _EXPORTER_RUNTIMES.get(service_name) - if runtime is not None: - return runtime - - runtime = _build_otel_exporter_runtime(service_name) - _EXPORTER_RUNTIMES[service_name] = runtime - return runtime - - def _build_otel_exporter_runtime(service_name: str) -> OTelExporterRuntime: from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider @@ -162,15 +150,15 @@ def build_tape_trace(tape: str, entries: list[TapeEntry]) -> TapeTrace: steps = [_build_step_trace(tape, step, index) for index, step in enumerate(_split_step_entries(entries), start=1)] prompt_tokens, completion_tokens, total_tokens = _combined_usage(entries) fields = _trace_projection_fields(tape, entries) - fields.update({ - "system_prompt": _first_message_content(fields["input_messages"], "system"), - "prompt": _first_prompt(entries), - "usage_input_tokens": prompt_tokens, - "usage_output_tokens": completion_tokens, - "usage_total_tokens": total_tokens, - "duration_ms": _valid_duration_ms(_combined_duration_ms(entries)), - "steps": steps, - }) + fields.update( + system_prompt=_first_message_content(fields["input_messages"], "system"), + prompt=_first_prompt(entries), + usage_input_tokens=prompt_tokens, + usage_output_tokens=completion_tokens, + usage_total_tokens=total_tokens, + duration_ms=_valid_duration_ms(_combined_duration_ms(entries)), + steps=steps, + ) trace = TapeTrace(**fields) return _with_trace_attributes(trace) @@ -181,14 +169,15 @@ def _build_step_trace(tape: str, entries: list[TapeEntry], index: int) -> StepTr **_trace_projection_fields(tape, entries), step=_step_number(step_data, index), ) - return _with_step_attributes(step) + return step.model_copy(update={"llm_attributes": _llm_attributes(step) | _step_attributes(step)}) def _trace_projection_fields(tape: str, entries: list[TapeEntry]) -> dict[str, Any]: run_data = _last_event_data(entries, "run") step_data = _last_event_data(entries, "loop.step") + prompt = _first_prompt(entries) messages, tool_calls = _extract_messages_and_tools(entries) - input_messages, output_messages = _split_input_output(messages, _first_prompt(entries), tool_calls) + input_messages, output_messages = _split_input_output(messages, prompt, tool_calls) output = _output_value(output_messages, tool_calls) prompt_tokens, completion_tokens, total_tokens = _usage(run_data) duration_ms = step_data.get("elapsed_ms") or run_data.get("elapsed_ms") @@ -210,75 +199,85 @@ def _trace_projection_fields(tape: str, entries: list[TapeEntry]) -> dict[str, A def _with_trace_attributes(trace: TapeTrace) -> TapeTrace: - agent_attributes = _common_attributes(trace.tape, trace.entries) | { - "openinference.span.kind": "AGENT", - "gen_ai.operation.name": "invoke_agent", - "input.mime_type": "application/json", - "input.value": _json_dumps(_message_payloads(trace.input_messages)), - "output.mime_type": "application/json", - "output.value": trace.output or "", - "bub.tape.batch.entries": len(trace.entries), - } + agent_attributes = _genai_span_attributes(trace, operation_name="invoke_agent") + agent_attributes.update(_bub_batch_attributes(trace)) + agent_attributes.update(_openinference_span_attributes(trace, span_kind="AGENT")) if trace.status: agent_attributes["bub.tape.status"] = trace.status return trace.model_copy(update={"agent_attributes": agent_attributes, "llm_attributes": _llm_attributes(trace)}) -def _with_step_attributes(step: StepTrace) -> StepTrace: - step_attributes = _common_attributes(step.tape, step.entries) | { - "bub.agent.step": step.step, - "bub.tape.batch.entries": len(step.entries), - } - if step.status: - step_attributes["bub.tape.status"] = step.status - if step.duration_ms is not None: - step_attributes["bub.agent.step.duration_ms"] = step.duration_ms - - return step.model_copy(update={"step_attributes": step_attributes, "llm_attributes": _llm_attributes(step)}) - - def _llm_attributes(projection: TraceProjection) -> dict[str, Any]: - attributes = _common_attributes(projection.tape, projection.entries) | { - "openinference.span.kind": "LLM", - "gen_ai.operation.name": "chat", - "gen_ai.input.messages": _json_dumps(_otel_messages(projection.input_messages)), - "gen_ai.output.messages": _json_dumps(_otel_messages(projection.output_messages)), - "input.mime_type": "application/json", - "input.value": _json_dumps(_message_payloads(projection.input_messages)), - "output.mime_type": "application/json", - "output.value": projection.output or "", - "gen_ai.output": projection.output or "", - } - _add_model_attributes(attributes, projection) - _add_usage_attributes(attributes, projection) - if projection.duration_ms is not None: - attributes["gen_ai.server.time_to_last_token"] = projection.duration_ms / 1000 + attributes = _genai_span_attributes(projection, operation_name="chat") + attributes.update(_openinference_span_attributes(projection, span_kind="LLM")) attributes.update(_openinference_messages("llm.input_messages", projection.input_messages)) attributes.update(_openinference_messages("llm.output_messages", projection.output_messages)) - attributes.update(_openinference_tool_definitions(projection.tool_calls)) return attributes -def _add_model_attributes(attributes: dict[str, Any], projection: TraceProjection) -> None: +def _genai_span_attributes(projection: TraceProjection, *, operation_name: str) -> dict[str, Any]: + attributes = _genai_conversation_attributes(projection.tape) | { + "gen_ai.operation.name": operation_name, + } if projection.model: attributes["gen_ai.request.model"] = projection.model attributes["gen_ai.response.model"] = projection.model - attributes["llm.model_name"] = projection.model if projection.provider: attributes["gen_ai.provider.name"] = projection.provider - attributes["llm.provider"] = projection.provider + attributes.update(_genai_usage_attributes(projection)) + return attributes + + +def _genai_conversation_attributes(tape: str) -> dict[str, str]: + return {"gen_ai.conversation.id": tape} -def _add_usage_attributes(attributes: dict[str, Any], projection: TraceProjection) -> None: - usage_attributes = { +def _genai_usage_attributes(projection: TraceProjection) -> dict[str, int]: + attributes = { "gen_ai.usage.input_tokens": projection.usage_input_tokens, - "llm.token_count.prompt": projection.usage_input_tokens, "gen_ai.usage.output_tokens": projection.usage_output_tokens, + } + return {name: value for name, value in attributes.items() if value is not None} + + +def _openinference_span_attributes(projection: TraceProjection, *, span_kind: str) -> dict[str, Any]: + attributes = { + "openinference.span.kind": span_kind, + "input.value": _messages_text(projection.input_messages), + "output.value": projection.output or "", + } + if projection.model: + attributes["llm.model_name"] = projection.model + if projection.provider: + attributes["llm.provider"] = projection.provider + attributes.update(_openinference_usage_attributes(projection)) + return attributes + + +def _openinference_usage_attributes(projection: TraceProjection) -> dict[str, int]: + attributes = { + "llm.token_count.prompt": projection.usage_input_tokens, "llm.token_count.completion": projection.usage_output_tokens, "llm.token_count.total": projection.usage_total_tokens, } - attributes.update({name: value for name, value in usage_attributes.items() if value is not None}) + return {name: value for name, value in attributes.items() if value is not None} + + +def _step_attributes(step: StepTrace) -> dict[str, Any]: + attributes: dict[str, Any] = { + "bub.agent.step": step.step, + "bub.tape.batch.entries": len(step.entries), + } + if step.status: + attributes["bub.tape.status"] = step.status + if step.duration_ms is not None: + attributes["bub.agent.step.duration_ms"] = step.duration_ms + return attributes + + +def _bub_batch_attributes(projection: TraceProjection) -> dict[str, int]: + return {"bub.tape.batch.entries": len(projection.entries)} def _split_step_entries(entries: list[TapeEntry]) -> list[list[TapeEntry]]: @@ -322,7 +321,7 @@ def _extract_messages_and_tools(entries: list[TapeEntry]) -> tuple[list[TraceMes messages.append( TraceMessage( role="tool", - content=_stringify(result), + content=_attribute_text(result), tool_call_id=tool_call.id if tool_call else None, ) ) @@ -332,7 +331,7 @@ def _extract_messages_and_tools(entries: list[TapeEntry]) -> tuple[list[TraceMes def _message_entry(entry: TapeEntry) -> TraceMessage | None: role = _as_text(entry.payload.get("role")) or "assistant" - content = _as_text(entry.payload.get("content")) or _stringify(entry.payload) + content = _as_text(entry.payload.get("content")) or "" name = _as_text(entry.payload.get("name")) tool_call_id = _as_text(entry.payload.get("tool_call_id")) raw_tool_calls = entry.payload.get("tool_calls") @@ -347,12 +346,10 @@ def _message_entry(entry: TapeEntry) -> TraceMessage | None: def _split_input_output( messages: list[TraceMessage], prompt: str | None, tool_calls: list[ToolCall] ) -> tuple[list[TraceMessage], list[TraceMessage]]: - last_assistant_index = _last_assistant_content_index(messages) - if last_assistant_index is not None: + if (last_assistant_index := _last_message_index(messages, _has_assistant_content)) is not None: return messages[:last_assistant_index], [messages[last_assistant_index]] - last_tool_call_index = _last_tool_call_index(messages) - if last_tool_call_index is not None: + if (last_tool_call_index := _last_message_index(messages, _has_assistant_tool_call)) is not None: return messages[:last_tool_call_index], [messages[last_tool_call_index]] if messages: @@ -365,20 +362,19 @@ def _split_input_output( return [], [] -def _last_assistant_content_index(messages: list[TraceMessage]) -> int | None: +def _last_message_index(messages: list[TraceMessage], predicate: Callable[[TraceMessage], bool]) -> int | None: for index in range(len(messages) - 1, -1, -1): - message = messages[index] - if message.role == "assistant" and message.content: + if predicate(messages[index]): return index return None -def _last_tool_call_index(messages: list[TraceMessage]) -> int | None: - for index in range(len(messages) - 1, -1, -1): - message = messages[index] - if message.role == "assistant" and message.tool_calls: - return index - return None +def _has_assistant_content(message: TraceMessage) -> bool: + return message.role == "assistant" and bool(message.content) + + +def _has_assistant_tool_call(message: TraceMessage) -> bool: + return message.role == "assistant" and bool(message.tool_calls) def _tool_call(raw: Any, index: int) -> ToolCall: @@ -393,7 +389,7 @@ def _tool_call(raw: Any, index: int) -> ToolCall: return ToolCall( id=str(call.get("id") or call.get("tool_call_id") or f"tool-{index}"), name=str(name), - arguments=_stringify(arguments), + arguments=_attribute_text(arguments), ) @@ -402,12 +398,12 @@ def _attach_tool_results(tool_calls: list[ToolCall], results: list[Any]) -> list return [] updated: list[ToolCall] = [] for index, call in enumerate(tool_calls): - result = _stringify(results[index]) if index < len(results) else call.result + result = _attribute_text(results[index]) if index < len(results) else call.result updated.append(ToolCall(id=call.id, name=call.name, arguments=call.arguments, result=result)) return updated -def _common_attributes(tape: str, entries: list[TapeEntry]) -> dict[str, Any]: +def _bub_tape_attributes(tape: str, entries: list[TapeEntry]) -> dict[str, Any]: attributes: dict[str, Any] = { "bub.tape.name": tape, "bub.session.hash": _session_hash(tape), @@ -422,6 +418,10 @@ def _common_attributes(tape: str, entries: list[TapeEntry]) -> dict[str, Any]: return attributes +def _common_attributes(tape: str, entries: list[TapeEntry]) -> dict[str, Any]: + return _genai_conversation_attributes(tape) | _bub_tape_attributes(tape, entries) + + def _openinference_messages(prefix: str, messages: list[TraceMessage]) -> dict[str, Any]: attributes: dict[str, Any] = {} for index, message in enumerate(messages): @@ -441,93 +441,37 @@ def _openinference_messages(prefix: str, messages: list[TraceMessage]) -> dict[s return attributes -def _openinference_tool_definitions(tool_calls: list[ToolCall]) -> dict[str, Any]: - attributes: dict[str, Any] = {} - seen: set[str] = set() - for index, call in enumerate(tool_calls): - if call.name in seen: - continue - seen.add(call.name) - attributes[f"llm.tools.{index}.tool.json_schema"] = _json_dumps({ - "type": "function", - "function": { - "name": call.name, - "parameters": {"type": "object"}, - }, - }) - return attributes - - def _tool_span_attributes(step: StepTrace, call: ToolCall) -> dict[str, Any]: - attributes = _common_attributes(step.tape, step.entries) | { + attributes = _genai_conversation_attributes(step.tape) | _bub_tape_attributes(step.tape, step.entries) + attributes.update(_step_attributes(step)) + attributes.update({ "openinference.span.kind": "TOOL", "gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": call.name, - "gen_ai.tool.args": call.arguments, - "bub.agent.step": step.step, - "tool.name": call.name, - "tool.call.id": call.id, + "gen_ai.tool.call.id": call.id, + "gen_ai.tool.type": "function", + "gen_ai.tool.call.arguments": call.arguments, + "bub.tool.name": call.name, + "bub.tool.call.id": call.id, "input.mime_type": "application/json", "input.value": call.arguments, "output.value": call.result or "", - } + }) if call.result is not None: - attributes["gen_ai.output"] = call.result + attributes["gen_ai.tool.call.result"] = call.result attributes["output.mime_type"] = "application/json" return attributes -def _otel_messages(messages: list[TraceMessage]) -> list[dict[str, Any]]: - payloads: list[dict[str, Any]] = [] - for message in messages: - payload: dict[str, Any] = {"role": message.role} - if message.content: - payload["parts"] = [{"type": "text", "content": message.content}] - payload["content"] = message.content - if message.tool_call_id: - payload["tool_call_id"] = message.tool_call_id - if message.tool_calls: - payload["tool_calls"] = [ - { - "id": call.id, - "type": "function", - "function": {"name": call.name, "arguments": call.arguments}, - } - for call in message.tool_calls - ] - payloads.append(payload) - return payloads - - -def _message_payloads(messages: list[TraceMessage]) -> list[dict[str, Any]]: - payloads: list[dict[str, Any]] = [] - for message in messages: - payload: dict[str, Any] = {"role": message.role} - if message.content: - payload["content"] = message.content - if message.name: - payload["name"] = message.name - if message.tool_call_id: - payload["tool_call_id"] = message.tool_call_id - if message.tool_calls: - payload["tool_calls"] = [ - { - "id": call.id, - "name": call.name, - "arguments": call.arguments, - **({"result": call.result} if call.result is not None else {}), - } - for call in message.tool_calls - ] - payloads.append(payload) - return payloads - - def _payload_list(entry: TapeEntry, key: str) -> list[Any]: value = entry.payload.get(key) return value if isinstance(value, list) else [] +def _messages_text(messages: list[TraceMessage]) -> str: + return "\n".join(f"{message.role}: {message.content}" for message in messages if message.content) + + def _first_message_content(messages: list[TraceMessage], role: str) -> str | None: for message in messages: if message.role == role and message.content: @@ -541,7 +485,7 @@ def _output_value(messages: list[TraceMessage], tool_calls: list[ToolCall]) -> s return content calls = [call for message in messages for call in message.tool_calls] or tool_calls if calls: - return _json_dumps([{"id": call.id, "name": call.name, "arguments": call.arguments} for call in calls]) + return _compact_json([{"id": call.id, "name": call.name, "arguments": call.arguments} for call in calls]) return None @@ -552,7 +496,7 @@ def _first_prompt(entries: list[TapeEntry]) -> str | None: if isinstance(prompt, str): return prompt if isinstance(prompt, list): - return _stringify(prompt) + return _attribute_text(prompt) return None @@ -658,42 +602,48 @@ def _as_text(value: Any) -> str | None: return None if isinstance(value, str): return value - return _stringify(value) + return _attribute_text(value) -def _stringify(value: Any) -> str: +def _attribute_text(value: Any) -> str: if isinstance(value, str): return value - return _json_dumps(value) + return _compact_json(value) -def _json_dumps(value: Any) -> str: +def _compact_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str) def _instrument_trace(trace: TapeTrace, *, tracer: Any) -> None: from opentelemetry.trace import SpanKind - with _otel_span(tracer, "bub.invoke_agent", kind=SpanKind.INTERNAL, attributes=trace.agent_attributes): + with _otel_span(tracer, "invoke_agent", kind=SpanKind.INTERNAL, attributes=trace.agent_attributes): for step in trace.steps: - _instrument_step(step, tracer=tracer) + with _otel_span(tracer, "bub.agent.step", kind=SpanKind.INTERNAL, attributes=_step_span_attributes(step)): + with _otel_span(tracer, _llm_span_name(step), kind=SpanKind.CLIENT, attributes=step.llm_attributes): + pass + for call in step.tool_calls: + with _otel_span( + tracer, + _tool_span_name(call), + kind=SpanKind.INTERNAL, + attributes=_tool_span_attributes(step, call), + ): + pass + + +def _llm_span_name(step: StepTrace) -> str: + return f"chat {step.model}" if step.model else "chat" + + +def _tool_span_name(call: ToolCall) -> str: + return f"execute_tool {call.name or 'tool'}" -def _instrument_step(step: StepTrace, *, tracer: Any) -> None: - from opentelemetry.trace import SpanKind - with _otel_span(tracer, "bub.agent.step", kind=SpanKind.INTERNAL, attributes=step.step_attributes): - with _otel_span(tracer, "bub.llm.chat", kind=SpanKind.CLIENT, attributes=step.llm_attributes): - pass - - for call in step.tool_calls: - with _otel_span( - tracer, - f"bub.tool.{SAFE_NAME_RE.sub('.', call.name).strip('.') or 'call'}", - kind=SpanKind.CLIENT, - attributes=_tool_span_attributes(step, call), - ): - pass +def _step_span_attributes(step: StepTrace) -> dict[str, Any]: + return _common_attributes(step.tape, step.entries) | _step_attributes(step) def _instrument_reset(tape: str, *, tracer: Any) -> None: diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py index 9b8efad..1c4d419 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py @@ -9,7 +9,7 @@ from pydantic import Field from pydantic_settings import SettingsConfigDict -from bub_tapestore_otel.exporter import LogfireTapeExporter, LogfireTapeExporterSettings +from bub_tapestore_otel.exporter import OTelTapeExporter, OTelTapeExporterSettings from bub_tapestore_otel.store import OTelTapeStore CONFIG_NAME = "tapestore-otel" @@ -42,15 +42,15 @@ def provide_tape_store(self) -> Any: settings = bub.ensure_config(OTelTapeStoreSettings) if not settings.enabled: return store - exporter = LogfireTapeExporter( - LogfireTapeExporterSettings( + exporter = OTelTapeExporter( + OTelTapeExporterSettings( service_name=settings.service_name, ) ) return _wrap_store_result(store, exporter) -def _wrap_store_result(store: Any, exporter: LogfireTapeExporter) -> Any: +def _wrap_store_result(store: Any, exporter: OTelTapeExporter) -> Any: if isinstance(store, AsyncIterator): @contextlib.asynccontextmanager diff --git a/packages/bub-tapestore-otel/tests/test_exporter.py b/packages/bub-tapestore-otel/tests/test_exporter.py index 220732f..9712ae4 100644 --- a/packages/bub-tapestore-otel/tests/test_exporter.py +++ b/packages/bub-tapestore-otel/tests/test_exporter.py @@ -1,11 +1,9 @@ from __future__ import annotations - -import json from contextlib import contextmanager from types import SimpleNamespace import bub_tapestore_otel.exporter as exporter -from bub_tapestore_otel.exporter import LogfireTapeExporter, _instrument_trace, _should_flush_batch, build_tape_trace +from bub_tapestore_otel.exporter import OTelTapeExporter, _instrument_trace, _should_flush_batch, build_tape_trace from republic import TapeEntry @@ -29,13 +27,16 @@ def test_build_tape_trace_exports_genai_and_openinference_llm_attributes() -> No assert trace.agent_attributes["openinference.span.kind"] == "AGENT" assert trace.agent_attributes["gen_ai.operation.name"] == "invoke_agent" + assert trace.agent_attributes["gen_ai.provider.name"] == "openai" + assert trace.agent_attributes["gen_ai.request.model"] == "gpt-5-mini" + assert trace.agent_attributes["gen_ai.conversation.id"] == "chat__1" + assert trace.agent_attributes["input.value"] == "system: system rules\nuser: say hello" assert trace.agent_attributes["output.value"] == "hello" assert trace.llm_attributes["openinference.span.kind"] == "LLM" assert trace.llm_attributes["gen_ai.operation.name"] == "chat" assert trace.llm_attributes["gen_ai.provider.name"] == "openai" assert trace.llm_attributes["gen_ai.request.model"] == "gpt-5-mini" - assert trace.llm_attributes["gen_ai.output"] == "hello" assert trace.llm_attributes["gen_ai.usage.input_tokens"] == 11 assert trace.llm_attributes["gen_ai.usage.output_tokens"] == 3 assert trace.llm_attributes["llm.token_count.total"] == 14 @@ -45,16 +46,8 @@ def test_build_tape_trace_exports_genai_and_openinference_llm_attributes() -> No assert trace.llm_attributes["llm.input_messages.1.message.content"] == "say hello" assert trace.llm_attributes["llm.output_messages.0.message.role"] == "assistant" assert trace.llm_attributes["llm.output_messages.0.message.content"] == "hello" - - input_messages = json.loads(trace.llm_attributes["gen_ai.input.messages"]) - output_messages = json.loads(trace.llm_attributes["gen_ai.output.messages"]) - assert input_messages == [ - {"role": "system", "parts": [{"type": "text", "content": "system rules"}], "content": "system rules"}, - {"role": "user", "parts": [{"type": "text", "content": "say hello"}], "content": "say hello"}, - ] - assert output_messages == [ - {"role": "assistant", "parts": [{"type": "text", "content": "hello"}], "content": "hello"} - ] + assert "gen_ai.input.messages" not in trace.llm_attributes + assert "gen_ai.output.messages" not in trace.llm_attributes def test_build_tape_trace_exports_tool_calls_and_results() -> None: @@ -77,12 +70,8 @@ def test_build_tape_trace_exports_tool_calls_and_results() -> None: trace.llm_attributes["llm.output_messages.0.message.tool_calls.0.tool_call.function.arguments"] == '{"query":"otel genai"}' ) - assert json.loads(trace.llm_attributes["llm.tools.0.tool.json_schema"]) == { - "type": "function", - "function": {"name": "search", "parameters": {"type": "object"}}, - } assert trace.steps[0].tool_calls[0].name == "search" - assert trace.steps[0].llm_attributes["llm.tools.0.tool.json_schema"] + assert "llm.tools.0.tool.json_schema" not in trace.steps[0].llm_attributes def test_build_tape_trace_groups_a_turn_into_steps() -> None: @@ -148,13 +137,13 @@ def test_batch_flushes_on_completed_tape_turn_markers() -> None: def test_instrument_trace_nests_steps_and_tools_under_agent(monkeypatch) -> None: - spans: list[tuple[str, str | None]] = [] + spans: list[tuple[str, str | None, dict]] = [] stack: list[str] = [] class FakeTracer: @contextmanager - def start_as_current_span(self, name, **_kwargs): - spans.append((name, stack[-1] if stack else None)) + def start_as_current_span(self, name, **kwargs): + spans.append((name, stack[-1] if stack else None, kwargs["attributes"])) stack.append(name) try: yield SimpleNamespace(get_span_context=lambda: object()) @@ -175,11 +164,21 @@ def start_as_current_span(self, name, **_kwargs): _instrument_trace(trace, tracer=FakeTracer()) assert spans == [ - ("bub.invoke_agent", None), - ("bub.agent.step", "bub.invoke_agent"), - ("bub.llm.chat", "bub.agent.step"), - ("bub.tool.search", "bub.agent.step"), + ("invoke_agent", None, trace.agent_attributes), + ("bub.agent.step", "invoke_agent", exporter._step_span_attributes(trace.steps[0])), + ("chat gpt-5-mini", "bub.agent.step", trace.steps[0].llm_attributes), + ( + "execute_tool search", + "bub.agent.step", + exporter._tool_span_attributes(trace.steps[0], trace.steps[0].tool_calls[0]), + ), ] + assert spans[1][2]["bub.agent.step"] == 1 + assert spans[1][2]["gen_ai.conversation.id"] == "agent__nested" + assert spans[2][2]["bub.agent.step"] == 1 + assert spans[3][2]["gen_ai.tool.call.arguments"] == '{"query":"otel"}' + assert spans[3][2]["gen_ai.tool.call.result"] == "result" + assert spans[3][2]["bub.tool.name"] == "search" def test_exporter_uses_span_processor_without_shutdown(monkeypatch) -> None: @@ -191,7 +190,6 @@ def force_flush(self, *, timeout_millis: int) -> None: fake_runtime = exporter.OTelExporterRuntime(provider=FakeProvider(), tracer=object()) - monkeypatch.setattr(exporter, "_EXPORTER_RUNTIMES", {}) monkeypatch.setattr(exporter, "_build_otel_exporter_runtime", lambda _service_name: calls.append("build_runtime") or fake_runtime) monkeypatch.setattr( exporter, @@ -199,12 +197,15 @@ def force_flush(self, *, timeout_millis: int) -> None: lambda _trace, *, tracer: calls.append(f"instrument_trace:{tracer is fake_runtime.tracer}"), ) - tape_exporter = LogfireTapeExporter() + tape_exporter = OTelTapeExporter() tape_exporter.append("tape-1", TapeEntry.message({"role": "user", "content": "hello"})) tape_exporter.append("tape-1", TapeEntry.event("loop.step", data={"status": "ok"})) + tape_exporter.append("tape-2", TapeEntry.event("command", data={})) assert calls == [ "build_runtime", "instrument_trace:True", "force_flush:3000", + "instrument_trace:True", + "force_flush:3000", ] diff --git a/uv.lock b/uv.lock index d223d56..4f19576 100644 --- a/uv.lock +++ b/uv.lock @@ -731,14 +731,16 @@ version = "0.1.0" source = { editable = "packages/bub-tapestore-otel" } dependencies = [ { name = "bub" }, - { name = "logfire" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, { name = "republic" }, ] [package.metadata] requires-dist = [ { name = "bub", git = "https://github.com/bubbuild/bub.git" }, - { name = "logfire", specifier = ">=4.31.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.39.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.0" }, { name = "republic", specifier = ">=0.5.7" }, ] @@ -1183,15 +1185,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] -[[package]] -name = "executing" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, -] - [[package]] name = "extism" version = "1.1.1" @@ -1850,24 +1843,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, ] -[[package]] -name = "logfire" -version = "4.34.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "executing" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, - { name = "protobuf" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/a6/6cbd7064d7ab120fbdd72ca1a218b3fa3343a5f1f0733fcfa34d43333aaf/logfire-4.34.0.tar.gz", hash = "sha256:d88bb04f26a5ae0064d36eeb0fca5413599f0e2d068d629bcab25993ff405e25", size = 1144711, upload-time = "2026-05-26T18:09:51.81Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/04/a386debccf1e91575dda55fa07b96f8309fc544833d7eec24b69869278f9/logfire-4.34.0-py3-none-any.whl", hash = "sha256:b9d9fc71aec184b28c29a9a9e280c954a3ea52c399f1d7b95ce82bff6b177364", size = 344385, upload-time = "2026-05-26T18:09:48.436Z" }, -] - [[package]] name = "loguru" version = "0.7.3" @@ -2173,21 +2148,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, ] -[[package]] -name = "opentelemetry-instrumentation" -version = "0.62b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "packaging" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/cb/0523b92c112a6cc70be43724343dc45225d3af134419844d7879a07755d4/opentelemetry_instrumentation-0.62b1.tar.gz", hash = "sha256:90e92a905ba4f84db06ac3aec96701df6c079b2d66e9379f8739f0a1bdcc7f45", size = 34043, upload-time = "2026-04-24T13:22:31.997Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/0f/45adbaea1f81b847cffdcee4f4b5f89297e42facf7fac78c7aaac4c38e75/opentelemetry_instrumentation-0.62b1-py3-none-any.whl", hash = "sha256:976fc6e640f2006599e97429c949e622c108d0c17c2059347d1e6c93c707f257", size = 34163, upload-time = "2026-04-24T13:21:31.722Z" }, -] - [[package]] name = "opentelemetry-proto" version = "1.41.1" @@ -3489,70 +3449,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] -[[package]] -name = "wrapt" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, - { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, - { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, - { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, - { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, - { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, - { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, - { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, - { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, - { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, - { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, - { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, - { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, - { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, - { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, - { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, - { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, - { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, - { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, - { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, - { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, - { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, - { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, - { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, - { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, - { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, - { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, - { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, - { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, - { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, - { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, - { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, - { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, - { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, - { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, - { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, - { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, - { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, - { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, - { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, - { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, - { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, - { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, -] - [[package]] name = "yarl" version = "1.24.2" From 99dfac57273db4fab8ccf9bc9d76e0529ec2167f Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Tue, 2 Jun 2026 23:18:03 +0800 Subject: [PATCH 5/5] fix: set bub as otel agent name --- packages/bub-tapestore-otel/README.md | 5 +++-- .../src/bub_tapestore_otel/exporter.py | 18 ++++++++++++++---- .../src/bub_tapestore_otel/plugin.py | 2 ++ .../bub-tapestore-otel/tests/test_exporter.py | 5 +++-- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/bub-tapestore-otel/README.md b/packages/bub-tapestore-otel/README.md index 7107d88..4844bff 100644 --- a/packages/bub-tapestore-otel/README.md +++ b/packages/bub-tapestore-otel/README.md @@ -39,6 +39,7 @@ Plugin settings: | --- | --- | --- | | `BUB_TAPESTORE_OTEL_ENABLED` | `true` | Wrap the active tape store. | | `BUB_TAPESTORE_OTEL_SERVICE_NAME` | `bub` | OpenTelemetry `service.name` resource value. | +| `BUB_TAPESTORE_OTEL_AGENT_NAME` | `bub` | OpenTelemetry `gen_ai.agent.name` value. | OTLP exporter configuration stays on the standard OpenTelemetry environment variables such as `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` and @@ -59,8 +60,8 @@ semantic sources: It emits these spans: -- `invoke_agent` root span with `gen_ai.operation.name=invoke_agent` and - `openinference.span.kind=AGENT` +- `invoke_agent bub` root span with `gen_ai.operation.name=invoke_agent`, + `gen_ai.agent.name=bub`, and `openinference.span.kind=AGENT` - `bub.agent.step` framework span for each Bub loop turn, carrying custom `bub.agent.step` and `bub.agent.step.duration_ms` attributes - `chat ` child span with `gen_ai.operation.name=chat` and diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py index 9788b60..79d44ea 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/exporter.py @@ -12,6 +12,7 @@ from republic import TapeEntry FORCE_FLUSH_TIMEOUT_MS = 3_000 +DEFAULT_AGENT_NAME = "bub" TERMINAL_STEP_STATUSES = frozenset({"ok", "error", "failed", "cancelled"}) TRACER_NAME = "bub_tapestore_otel" @@ -22,6 +23,7 @@ class TapeProjectionModel(BaseModel): class OTelTapeExporterSettings(TapeProjectionModel): service_name: str = "bub" + agent_name: str = DEFAULT_AGENT_NAME class OTelExporterRuntime(TapeProjectionModel): @@ -66,6 +68,7 @@ class StepTrace(TraceProjection): class TapeTrace(TraceProjection): + agent_name: str = DEFAULT_AGENT_NAME system_prompt: str | None = None prompt: str | None = None steps: list[StepTrace] = Field(default_factory=list) @@ -106,14 +109,14 @@ def _append(self, tape: str, entry: TapeEntry) -> None: batch = self._record_entry(tape, entry) if batch is None: return - _instrument_trace(build_tape_trace(tape, batch), tracer=runtime.tracer) + _instrument_trace(build_tape_trace(tape, batch, agent_name=self._settings.agent_name), tracer=runtime.tracer) self._flush(runtime) def _reset(self, tape: str) -> None: runtime = self._ensure_exporter() batch = self._pop_pending(tape) if batch: - _instrument_trace(build_tape_trace(tape, batch), tracer=runtime.tracer) + _instrument_trace(build_tape_trace(tape, batch, agent_name=self._settings.agent_name), tracer=runtime.tracer) _instrument_reset(tape, tracer=runtime.tracer) self._flush(runtime) @@ -146,11 +149,12 @@ def _build_otel_span_processor() -> object: return BatchSpanProcessor(OTLPSpanExporter()) -def build_tape_trace(tape: str, entries: list[TapeEntry]) -> TapeTrace: +def build_tape_trace(tape: str, entries: list[TapeEntry], *, agent_name: str = DEFAULT_AGENT_NAME) -> TapeTrace: steps = [_build_step_trace(tape, step, index) for index, step in enumerate(_split_step_entries(entries), start=1)] prompt_tokens, completion_tokens, total_tokens = _combined_usage(entries) fields = _trace_projection_fields(tape, entries) fields.update( + agent_name=agent_name, system_prompt=_first_message_content(fields["input_messages"], "system"), prompt=_first_prompt(entries), usage_input_tokens=prompt_tokens, @@ -200,6 +204,8 @@ def _trace_projection_fields(tape: str, entries: list[TapeEntry]) -> dict[str, A def _with_trace_attributes(trace: TapeTrace) -> TapeTrace: agent_attributes = _genai_span_attributes(trace, operation_name="invoke_agent") + if trace.agent_name: + agent_attributes["gen_ai.agent.name"] = trace.agent_name agent_attributes.update(_bub_batch_attributes(trace)) agent_attributes.update(_openinference_span_attributes(trace, span_kind="AGENT")) if trace.status: @@ -618,7 +624,7 @@ def _compact_json(value: Any) -> str: def _instrument_trace(trace: TapeTrace, *, tracer: Any) -> None: from opentelemetry.trace import SpanKind - with _otel_span(tracer, "invoke_agent", kind=SpanKind.INTERNAL, attributes=trace.agent_attributes): + with _otel_span(tracer, _agent_span_name(trace), kind=SpanKind.INTERNAL, attributes=trace.agent_attributes): for step in trace.steps: with _otel_span(tracer, "bub.agent.step", kind=SpanKind.INTERNAL, attributes=_step_span_attributes(step)): with _otel_span(tracer, _llm_span_name(step), kind=SpanKind.CLIENT, attributes=step.llm_attributes): @@ -638,6 +644,10 @@ def _llm_span_name(step: StepTrace) -> str: return f"chat {step.model}" if step.model else "chat" +def _agent_span_name(trace: TapeTrace) -> str: + return f"invoke_agent {trace.agent_name}" if trace.agent_name else "invoke_agent" + + def _tool_span_name(call: ToolCall) -> str: return f"execute_tool {call.name or 'tool'}" diff --git a/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py b/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py index 1c4d419..e364581 100644 --- a/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py +++ b/packages/bub-tapestore-otel/src/bub_tapestore_otel/plugin.py @@ -26,6 +26,7 @@ class OTelTapeStoreSettings(bub.Settings): enabled: bool = Field(default=True, validation_alias="BUB_TAPESTORE_OTEL_ENABLED") service_name: str = Field(default="bub", validation_alias="BUB_TAPESTORE_OTEL_SERVICE_NAME") + agent_name: str = Field(default="bub", validation_alias="BUB_TAPESTORE_OTEL_AGENT_NAME") class OTelTapeStorePlugin: @@ -45,6 +46,7 @@ def provide_tape_store(self) -> Any: exporter = OTelTapeExporter( OTelTapeExporterSettings( service_name=settings.service_name, + agent_name=settings.agent_name, ) ) return _wrap_store_result(store, exporter) diff --git a/packages/bub-tapestore-otel/tests/test_exporter.py b/packages/bub-tapestore-otel/tests/test_exporter.py index 9712ae4..6f9b474 100644 --- a/packages/bub-tapestore-otel/tests/test_exporter.py +++ b/packages/bub-tapestore-otel/tests/test_exporter.py @@ -27,6 +27,7 @@ def test_build_tape_trace_exports_genai_and_openinference_llm_attributes() -> No assert trace.agent_attributes["openinference.span.kind"] == "AGENT" assert trace.agent_attributes["gen_ai.operation.name"] == "invoke_agent" + assert trace.agent_attributes["gen_ai.agent.name"] == "bub" assert trace.agent_attributes["gen_ai.provider.name"] == "openai" assert trace.agent_attributes["gen_ai.request.model"] == "gpt-5-mini" assert trace.agent_attributes["gen_ai.conversation.id"] == "chat__1" @@ -164,8 +165,8 @@ def start_as_current_span(self, name, **kwargs): _instrument_trace(trace, tracer=FakeTracer()) assert spans == [ - ("invoke_agent", None, trace.agent_attributes), - ("bub.agent.step", "invoke_agent", exporter._step_span_attributes(trace.steps[0])), + ("invoke_agent bub", None, trace.agent_attributes), + ("bub.agent.step", "invoke_agent bub", exporter._step_span_attributes(trace.steps[0])), ("chat gpt-5-mini", "bub.agent.step", trace.steps[0].llm_attributes), ( "execute_tool search",