Design: SpanStore protocol + the contrib boundary rule (B4)¶
Status: Accepted — implemented in v0.11.0 (core phases 1–2); contrib shim (phase 3) ships as a separate civitas-contrib release gated on core v0.11.0
Supersedes: telemetry-native.md §12 (B4 placement/pluggability addendum)
Touches: python-civitas (core) and civitas-contrib
1. Context & problem¶
SQLiteBackend (B1) + SQLiteQueryEngine (B2) shipped as two classes sharing an
implicit schema contract — they can silently drift. A hypothetical
PostgresBackend would have to reimplement all three of: normalize_span()
(pure telemetry semantics), the write side, and the read side. B4 was deferred
(telemetry-native.md §12) with two open questions:
- Placement — does durable telemetry storage belong in core or contrib?
- Pluggability — how do we separate telemetry logic from storage mechanics so alternative backends don't reimplement normalization?
This doc resolves both, plus a broader question the placement decision forced:
what is the actual rule for what lives in civitas-contrib?
2. Decisions (signed off)¶
| # | Decision |
|---|---|
| D1 | SpanStore is a read+write merged protocol that extends ExportBackend. One implementation per backend, so read/write schema cannot drift. |
| D2 | Merge the concrete SQLiteBackend + SQLiteQueryEngine into one SQLiteSpanStore, with back-compat aliases for the old names. |
| D3 | Adopt "needs a third-party runtime dependency" as the contrib boundary rule (replacing the old "touches disk → contrib"). Under it, SQLiteSpanStore stays in core, and SQLiteStateStore moves from contrib to core for consistency. |
| D4 | normalize_span() becomes public, stable, documented API in core — every SpanStore impl imports it rather than reimplementing it. |
3. The contrib boundary rule (the principle)¶
Core ships mechanism, protocols, and implementations with no third-party runtime dependency.
civitas-contribships anything that requires a vendor / driver / framework SDK, or that integrates with a specific external product.
This replaces the old, incidental "does it durably persist to disk → contrib"
rule (which put SQLiteStateStore in contrib only because it writes files).
Why the new rule is better:
- SQLite is stdlib. sqlite3 ships with CPython; aiosqlite is a tiny
pure-Python wrapper. A SQLite backend adds no vendor dependency, so core can
ship a batteries-included durable telemetry/state story out of the box.
- Postgres/MySQL need drivers (asyncpg/aiomysql), OTel needs its SDK,
LLM plugins need vendor SDKs, framework adapters need the other framework —
all genuinely third-party. Those stay in contrib.
- The rule is testable and unambiguous: "does this impl import a package
that isn't stdlib or already a core dependency?" If yes → contrib.
Consequences applied in §7 / §8.
4. Architecture: ExportBackend → SpanStore¶
ExportBackend (unchanged) stays the general write contract for anything that
receives spans — including write-only exporters that are not queryable.
SpanStore extends it for durable, queryable stores:
ExportBackend (Protocol): export(spans), shutdown()
├─ ConsoleBackend core (write-only)
├─ FanOutBackend core (write-only, fan-out)
├─ OTelExportBackend contrib (needs OTel SDK)
└─ SpanStore (Protocol, extends ExportBackend):
+ cost_over_time(since, until, bucket_seconds)
+ message_rate_over_time(since, until, bucket_seconds)
+ cost_by_agent(since, until) / cost_by_model(since, until)
+ recent_spans(since, until, limit)
+ spans_in_trace(trace_id, since, until)
├─ SQLiteSpanStore core (stdlib/aiosqlite — via civitas[telemetry])
├─ InMemorySpanStore core (proof-of-seam + test double; no deps)
├─ PostgresSpanStore contrib (needs asyncpg) — future, demand-driven
└─ MySQLSpanStore contrib (needs aiomysql) — future, demand-driven
SpanStore is @runtime_checkable (mirrors StateStore) so isinstance()
checks work for callers that want the query surface only when present.
InMemorySpanStore is built now (not deferred) precisely because it proves
the protocol is genuinely backend-agnostic without waiting for a driver-backed
impl, and it doubles as a fast, dependency-free test double — the same role
InMemoryStateStore plays for StateStore. The concrete PostgresSpanStore
is deferred (demand-driven); the seam makes it a small, isolated add.
5. normalize_span() as public API¶
Promoted from an internal helper to blessed public API, re-exported from a
stable path (e.g. civitas.observability.normalize_span), documented, and
covered by its own tests as a contract. Its §4 normalization table (in
telemetry-native.md) is the spec. Every SpanStore impl — in core or contrib
— calls it; none reimplements the SpanData→columns mapping. The promoted-column
set it returns becomes part of the SpanStore contract.
6. Concrete SQLiteSpanStore (the merge)¶
- New
civitas/observability/span_store.py(or keepsqlite_backend.py):SQLiteSpanStoreimplements the fullSpanStoreprotocol — the currentSQLiteBackendwrite path and the currentSQLiteQueryEngineread methods, over the same window-file machinery, so schema is defined once. - Back-compat aliases (non-breaking):
SQLiteBackend = SQLiteSpanStoreand a thinSQLiteQueryEngineshim (or alias) kept and marked deprecated, so existingfrom civitas.observability.sqlite_backend import SQLiteBackendand... sqlite_query import SQLiteQueryEnginekeep working. Removal is a separate, later, explicitly-versioned breaking change — not part of this cut. - The
SpanStoreprotocol +normalize_spanlive in core regardless of extras; the concreteSQLiteSpanStorestays behindcivitas[telemetry](it importsaiosqlite), exactly as today.
7. SQLiteStateStore move to core (D3 consequence)¶
- Add
SQLiteStateStoreto core (e.g.civitas/plugins/sqlite_store.py). It uses stdlibsqlite3in a thread executor — no new core dependency. - Loader repoint:
plugins/loader.py_STORE_TYPES["sqlite"]→"civitas.plugins.sqlite_store.SQLiteStateStore"(was the contrib path). YAMLtype: sqlitenow works without contrib installed — a strict improvement. - Contrib shim (non-breaking):
civitas_contrib.plugins.sqlite_storere-exportsSQLiteStateStorefrom core with aDeprecationWarning, sofrom civitas_contrib.plugins.sqlite_store import SQLiteStateStorekeeps working. Canonical import becomes the core path. postgres_store/ future driver-backed stores stay in contrib (they needasyncpgetc.) — the loader keeps resolvingtype: postgresto contrib.
8. Contrib inventory (after this change)¶
| Category | Contrib now | Future candidates |
|---|---|---|
| LLM provider plugins | anthropic, openai, gemini, mistral, litellm, fiddler | bedrock, vertex, cohere, groq, ollama |
| Framework adapters | crewai, langgraph, openai-sdk | autogen, llamaindex, pydantic-ai, smolagents |
| StateStores (driver-backed) | postgres_store | redis, dynamodb, mongo |
| SpanStores (driver-backed) | — | postgres, mysql, clickhouse |
| External exporters | otel | datadog, honeycomb, prometheus-remote-write |
| Eval exporters | eval/exporters | eval-platform-specific |
Moves out of contrib → core: sqlite_store (SQLiteStateStore).
Stays in core: all protocols, Console/FanOut/InMemoryStateStore/
InMemorySpanStore/SQLiteSpanStore/SQLiteStateStore, and prometheus_export
(pure stdlib text exposition — no client SDK, consistent with the rule).
9. Back-compat & migration summary¶
Everything in this cut is additive or aliased — no breaking change:
- SQLiteBackend / SQLiteQueryEngine → aliases to the merged store.
- civitas_contrib...sqlite_store.SQLiteStateStore → re-export shim (warns).
- YAML type: sqlite → keeps working, now core-resolved.
Deprecated aliases/shims get removed in a later, explicitly-versioned major.
10. Phased plan & versioning (proposal — ship-as-one vs many is your call)¶
- Phase 1 (core, telemetry):
SpanStoreprotocol +normalize_spanpublic InMemorySpanStore+ merge intoSQLiteSpanStorewith aliases. Tests.- Phase 2 (core, state): add
SQLiteStateStoreto core + loader repoint + tests (incl. YAMLtype: sqlitewithout contrib). - Phase 3 (contrib): deprecation shim for
sqlite_store; docs update; note Postgres/MySQLSpanStoreas enabled-but-not-built.
All additive → a single minor release (v0.11.0) can carry Phases 1–3, or
they can ship as separate minors. Concrete PostgresSpanStore is out of scope
(demand-driven), enabled by the seam.
11. Verification plan¶
SpanStoreprotocol conformance: run the same query test suite against bothSQLiteSpanStoreandInMemorySpanStore(parametrized) — proves the seam is real, not SQLite-shaped.normalize_spancontract tests (each §4 span-kind → expected columns).- Back-compat: old imports (
SQLiteBackend,SQLiteQueryEngine, contribSQLiteStateStore) still work; contrib shim emitsDeprecationWarning. - YAML
type: sqlitestate store loads with contrib absent (real loader). - Full suite + ruff + ruff format + mypy; docs anchors; clean Docker install of the published package.
12. Risks / open questions¶
- Cross-repo coordination: the contrib shim must land with or after the
core add so
civitas_contribnever imports a symbol core hasn't shipped. Sequence: release corev0.11.0first, then the contrib release that adds the shim depends oncivitas>=0.11.0. SQLiteQueryEnginealias fidelity: it takesdb_dir/window_daysand is read-only; the merged store must accept the same construction to alias cleanly, or the alias is a thin adapter. Confirm during Phase 1.- Two SQLite styles:
SQLiteSpanStoreusesaiosqlite;SQLiteStateStoreuses syncsqlite3in an executor. Left as-is (both work); not unified here.