Data model
Everything in Morse AI is built from three nested objects: a thread holds many traces, and a trace holds many spans. Spans are typed from the moment they’re recorded — and that one decision is why cost, context, memory, and eval data all show up consistently no matter what instrumented the run.
Span
A span is one unit of work inside a run: a single LLM call, a tool call, a database query, an
HTTP request, a retrieval, or a memory operation. Every span carries a type drawn from a fixed,
canonical set — eight of them:
agent · llm · tool · http · db · custom · memory · retriever
The type isn’t a label you attach for display — it’s enforced at ingest. The valid set is derived directly from the backend enum, so both the SDK-batch and OTLP ingestion paths accept exactly the same types and reject anything else.
Alongside its type, a span records timing (started_at, ended_at, duration_ms), status
(running / completed / failed / timed_out), and — where the type calls for it —
LLM fields (model, input_tokens, output_tokens, cost_usd, cache tokens), context-profile
aggregates, and a type-dependent input/output payload.
Trace
A trace is one complete agent run, assembled from its spans. It carries the run’s status,
success/outcome, duration, and denormalized rollups (total_tokens, total_cost_usd,
span_count) so the product never has to re-aggregate spans on every page load.
Spans link to their trace by value, not by foreign key: a span’s trace_id is matched to the
trace’s trace_id. This is deliberate — the schema is designed to migrate to ClickHouse without
rewriting, and column stores don’t do FK joins. The only foreign key in the whole data plane is a
trace’s link to its agent.
Thread
A thread groups the traces of a single conversation or session — a multi-turn chat, a
long-running workflow — so you can look at “every run in thread Y” as one unit. thread_id lives on
the trace; a related user_id is your application’s own end-user identifier (a string like
usr_123), not a Morse account.
So the hierarchy reads top-down: thread → traces → spans.
Why typed-from-day-one matters
A generic tracer sees a db span as raw SQL and an http span as a URL. Because Morse AI types
spans at the source, every downstream capability reads one normalized model instead of a bespoke
schema per feature:
- Cost is attributed by span type.
llmspans price per-token;memoryspans classify intorag(vector search, per-query) ormemory(per-call);tool/http/db/agent/customspans have no model cost and are excluded from coverage rather than charged a fake zero. - Context & memory profiling keys on type — context-segment processing gates on
llm, memory operations gate onmemory. - Semantic enrichment comes from type: a typed
dbspan can be recognized as a LangGraph checkpoint, a vector search, or a memory store — distinctions a raw-SQL view can’t make.
That’s the payoff of typing once, at ingest: cost, context, memory, and evals all dock onto the same span without special-casing where the run came from.
Related
- Honest numbers — how Morse AI reports what it hasn’t measured.
- Traces — the spans of one run, rendered as a waterfall.
- Send OpenTelemetry directly — the same typed model, no SDK required.