Documentation
¶
Overview ¶
Package obsdb is the observability database: what weft/otel's local sink writes and Weft Studio reads, in one module so neither owns the schema (ADR 0024, S3).
The model is OTLP-shaped — spans and log records, resource included — so a stock OpenTelemetry pipeline's data and weft's own land in the same tables. Weft identity (the run → session → public-id chain) is derived from attributes, never parsed out of ids, and is stored as indexed columns through the Weft struct.
Three moving parts:
- DB is the backend interface: Write (idempotent on (run, record kind, position) and (trace, span)), run/session queries, the event page with its gap detector, the transcript a replay reads, and the spans of a run or trace. Status is derived from the four-row table (DeriveStatus), never stored.
- The hub (Hub, Frame) is the live lane: every Write publishes its frames before returning, so an in-process subscriber — Studio's SSE handler in setup A — sees a record without a network hop.
- obsdbtest is the conformance table every backend runs (the storetest pattern); sqlite is the default backend, clickhouse the hosted one (its own module).
Deltas travel but are not stored (their own counter means their absence never looks like a lost event); heartbeats are never stored, they only move a run's last-seen. Both rules are Write's, shared by every backend.
Index ¶
- Constants
- Variables
- func ApplyView(transcript []core.Message, v Compaction) ([]core.Message, error)
- func AttachRaw(reqs []RequestRecord, raws []StoredRecord)
- func CheckSearch(q SearchQuery) ([]string, error)
- func CheckToolArgs(tool string, schema, args json.RawMessage) error
- func DedupTranscript(bodies []json.RawMessage) []json.RawMessage
- func ExplainMissing(ctx context.Context, db DB, runID, kind, hash string) error
- func HoleNote(h Hole) (reason, fix string)
- func HoleNoteFor(h Hole, cause HoleCause) (reason, fix string)
- func LastAssistantText(body []byte) (string, bool)
- func LimitOf(n int) int
- func MetaOf(attrs map[string]any) map[string]string
- func ParseSearch(text string) ([]string, error)
- func ParseSeverity(s string) (n int, ok bool)
- func PromptPreview(s string) string
- func PromptText(body []byte, input bool) (string, bool)
- func SeverityText(n int) string
- func SortCompactions(cs []Compaction)
- func StepRange(reqs []RequestRecord) (lo, hi int, ok bool)
- func TranscriptBodies(batches []TranscriptBatch) []json.RawMessage
- func TurnPrompt(input, next []byte) (string, bool)
- type Assembler
- type Batch
- type Compaction
- type DB
- type EventPage
- type Experiment
- type ExperimentCommand
- type ExperimentInput
- type ExperimentVariant
- type Frame
- type Hole
- type HoleCause
- type HoleError
- type Hub
- type HubOption
- type LogCandidates
- type LogPage
- type LogQuery
- type MessageText
- type OtherLog
- type PosEvent
- type PromptRecord
- type RawRecord
- type RawSide
- type Record
- type RequestBody
- type RequestMessagesRef
- type RequestModel
- type RequestParams
- type RequestQuery
- type RequestRecord
- type RequestThinking
- type RequestToolChoice
- type RequestTools
- type RunDetail
- type RunPage
- type RunQuery
- type RunRow
- type SearchQuery
- type SearchSort
- type Searcher
- type Selector
- type SessionDetail
- type SessionPage
- type SessionQuery
- type SessionRow
- type Span
- type SpanEvent
- type SpanKey
- type SpanLink
- type Status
- type StepMessages
- type StepMessagesError
- type StoredRecord
- type ToolEntry
- type ToolsRecord
- type TracePage
- type TraceQuery
- type TraceRoot
- type TraceRow
- type TranscriptBatch
- type Weft
Examples ¶
Constants ¶
const ( CompactionRun = "run" CompactionSession = "session" ReasonCompacted = "compacted" // RecordCompaction is the weft.record of thread's session marker. RecordCompaction = "compaction" )
The two compaction scopes (weft.compaction.scope) and the one weft.messages.reason ADR 0028 §8 defines.
const ( FrameRecord = "record" FrameRun = "run" )
Frame kinds.
const ( MaxSearchTerms = 16 MaxSearchTermBytes = 256 MaxSearchOffset = 100_000 )
Search bounds: a query is a few words, never a document.
const InterruptedAfter = 30 * time.Second
InterruptedAfter is the "last seen" age that turns a non-terminal run interrupted: three missed heartbeats.
const LogSkew = time.Minute
LogSkew widens a run's time window for the app-log scan (the otel_logs read on ClickHouse, the same bound on SQLite): clock skew between the emitters of one run.
const MaxGaps = 1000
MaxGaps bounds EventPage.Gaps: positions are the sender's numbers, and one stray high position must not turn a page read into a list of every position below it.
const MaxLogCandidates = 10_000
MaxLogCandidates caps how many log records one DB.OtherLogs read loads: the first non-weft records of the run's traces in its time window, by time — before attribution, so a subagent's or a sibling run's lines in the same trace (and duplicates) count against it. A run whose traces hold more reads LogPage.Truncated.
const MaxTies = 500
MaxTies bounds how far a page may run past its Limit to finish a group of rows sharing the cursor's time (RunPage, SessionPage): one page never grows unbounded however many rows share one timestamp.
const PromptPreviewRunes = 200
PromptPreviewRunes caps RunRow.PromptPreview.
const RecordRaw = "raw"
RecordRaw is the raw record kind (ADR 0028 §12, plan A6): one model call attempt's wire exchange — the provider request and response a first-party adapter sent and received — emitted only under otel.RawBodies(true) with WEFT_ENV=dev, to the Local and Studio destinations. It is positioned by weft.raw.index and stored beside the request records; DB.Requests attaches it to its attempt's row (RequestRecord.Wire).
const SweepBatch = 500
SweepBatch bounds one pass of DB.Sweep: at most this many root runs (each with its subtree of not-running children) are deleted per writer transaction on SQLite, per round of DELETE mutations on ClickHouse. Between passes the writer is free: a sweep of a large backlog (the first sweep of `weft studio --retain` on an old database) interleaves with ingest instead of blocking it.
Variables ¶
var ErrClosed = errors.New("obsdb: closed")
ErrClosed is returned by a backend used after Close.
var ErrInvalidSearch = errors.New("obsdb: invalid search")
ErrInvalidSearch is returned by ParseSearch and Search for a query outside the grammar or its bounds; Studio answers it 400.
var ErrNotFound = errors.New("obsdb: not found")
ErrNotFound is returned by Run, Session, Events, Transcript, RunSpans, OtherLogs, Requests, Prompt, Tools, Catalogs, Experiment, DeleteRun, DeleteSession and ResolvePublicID for an id the database does not hold (Prompt and Tools also for a hash, possibly as a *HoleError). Trace answers an unknown trace with no spans instead: a trace is only ever the spans that arrived.
var ErrStepMessages = errors.New("obsdb: the records do not rebuild the step's messages")
ErrStepMessages is matched (errors.Is) by every *StepMessagesError: the stored records do not rebuild the messages a request names.
Functions ¶
func ApplyView ¶ added in v0.13.0
ApplyView rebuilds the messages a compacted request carried (ADR 0028 §8): transcript — the growth records below the view's index, concatenated — with the range [FromSeq, ToSeq) replaced by the view's messages. A range that does not fit the transcript, or a body that is not messages, is an error.
func AttachRaw ¶ added in v0.14.0
func AttachRaw(reqs []RequestRecord, raws []StoredRecord)
AttachRaw sets each request record's Wire to the raw record of its attempt — the same step and attempt — and leaves it nil where the run holds none (RawBodies off, a third-party adapter that does not report, a run written before the raw record). Where an SDK's own transport retries put several exchanges on one attempt, the last (the highest weft.raw.index) is the attempt's. raws are stored raw records in any order; every backend answers through it.
func CheckSearch ¶ added in v0.14.0
func CheckSearch(q SearchQuery) ([]string, error)
CheckSearch validates the parts of a SearchQuery a backend cannot read safely: the sort, the offset and the text's terms (returned).
func CheckToolArgs ¶ added in v0.13.0
func CheckToolArgs(tool string, schema, args json.RawMessage) error
CheckToolArgs checks a tool call's arguments against the tool's input schema as a tools record stores it (ToolEntry.Schema): the check a playground `tool_args` transcript edit passes before it is accepted (ADR 0029 §8), on both of its surfaces — weft/runtime's authoritative copy and Studio's mirror — so the two refuse in one wording. A nil error means the arguments fit.
The failure is a *core.ToolError coded INVALID_INPUT whose cause is core.ErrInvalidToolInput, worded like the loop where the loop has a sentence for it — `INVALID_INPUT: tool "lookup_order": field "days": expected integer, got string` — naming the field by its JSON path (dotted; an array element by its index). The loop's decode does not check required fields or enums; their sentences ("missing required field …", "… is not one of the schema's values") are this check's own. A null field is a no-op, as encoding/json decodes it; an integer must be written as one (3.0 is refused, as the loop refuses it).
The arguments must be one JSON object (empty or null reads as {}, as the loop decodes them). The schema walk covers the vocabulary weft's reflector and a hand-written schema share: type (one or a list), properties, required, additionalProperties (false refuses an undeclared field; a schema checks each extra one), items and enum. Any other keyword is not checked. An empty or unreadable schema checks the object shape alone.
Example ¶
A playground tool_args edit is checked against the schema the run's tools record stored before the replay is accepted: a wrong type is refused in the loop's own words, naming the field.
package main
import (
"encoding/json"
"fmt"
"github.com/weftgo/weft/obsdb"
)
func main() {
schema := json.RawMessage(`{"type":"object","properties":{"order_id":{"type":"string"}},"required":["order_id"]}`)
fmt.Println(obsdb.CheckToolArgs("lookup_order", schema, json.RawMessage(`{"order_id":"7"}`)))
fmt.Println(obsdb.CheckToolArgs("lookup_order", schema, json.RawMessage(`{"order_id":7}`)))
}
Output: <nil> INVALID_INPUT: tool "lookup_order": field "order_id": expected string, got number
func DedupTranscript ¶
func DedupTranscript(bodies []json.RawMessage) []json.RawMessage
DedupTranscript makes a run's messages bodies concatenate to the transcript the run actually held. bodies are the messages records' bodies in index order, as a backend's Transcript reads them; every backend's Transcript returns its result through this.
One stored shape needs it: a run recorded before ADR 0028 §8's partial-resume shape. A resume whose input already holds a partial tool message (some calls of the step answered, the rest parked) was recorded with that input as fed — index 0 — and then the tool message it rebuilt over the partial one, at index 1. Concatenated naively the two versions of the one message sit side by side; the later record is the authoritative one. The current core never writes that shape (its input record stops at the last assistant message with tool calls and the rebuilt message is the next growth record, step 0); this stays for runs stored in the old one. So when the second body is a lone tool message and the first holds, directly after its last assistant message with tool calls, a tool message whose results the lone one repeats, the partial message is dropped from the first body. Every other message stays byte-for-byte, the number of bodies never changes (a body's place is still its record's index), and any other shape is returned untouched.
func ExplainMissing ¶ added in v0.10.0
ExplainMissing is the error DB.Prompt and DB.Tools return for a hash the run holds no record of (kind "prompt" or "tools"): a *HoleError when the reason is known (HoleError's table), a plain ErrNotFound otherwise, and the read's own error when the run or its requests cannot be read. Every backend answers through it, so both say the same thing for the same records.
func HoleNote ¶ added in v0.10.0
HoleNote is a badge's one-line reason and, where one exists, its one-line fix ("" when the hole has none: a redaction is by design, a derived value has nothing to repair) — ADR 0028 §11's table in words, the one source both Studio surfaces render (its golden, studio/testdata/holes.golden.json, is what Studio's TypeScript table is checked against). A reader that knows more (a step's own gap, a run built from spans) says so in its own reason and keeps this fix. An unknown hole has neither.
func HoleNoteFor ¶ added in v0.10.0
HoleNoteFor is HoleNote for a hole whose cause the reader knows: the cause's own reason and fix where the table words it, HoleNote(h) otherwise (CauseDefault included). The one table for every surface (run page, panel, export).
func LastAssistantText ¶ added in v0.14.0
LastAssistantText is the text of a body's last assistant message with non-empty text: the run's answer when the body is its latest messages record holding one.
func LimitOf ¶
LimitOf normalizes a query limit: 0 means the default 50, values above 500 clamp to 500, negatives read as the default.
func MetaOf ¶
MetaOf returns the caller metadata a set of attributes carries: every string value under a key outside the record/span contract (what core.Metadata put there, enduser.id included). Keys under "weft." that are not part of the contract — thread's weft.session.parent, a future module's own — are metadata and are kept.
func ParseSearch ¶ added in v0.14.0
ParseSearch splits search text into its terms: whitespace separates terms, and a double-quoted span is one term, spaces kept (a phrase: "refund failed" matches those two words adjacent, in that order). An unclosed quote runs to the end of the text; an empty quoted span is no term. Terms are matched case-insensitively as substrings — no stemming, no operators: AND, OR and * are ordinary text. More than MaxSearchTerms terms, or a term longer than MaxSearchTermBytes, is ErrInvalidSearch.
func ParseSeverity ¶ added in v0.10.0
ParseSeverity reads a severity filter: a level name (trace, debug, info, warn or warning, error, fatal — any case, the range's first number), a SeverityText spelling ("WARN2"), or a number 1–24. ok is false for anything else.
func PromptPreview ¶ added in v0.14.0
PromptPreview cuts a prompt to its first PromptPreviewRunes runes — the cut both backends make in SQL (substr, leftUTF8), here for a writer that holds the text in Go.
func PromptText ¶ added in v0.14.0
PromptText is the turn's own prompt as one messages record body holds it. The input record (weft.messages.input: what the run was fed — on a continued conversation the whole history, the turn's question last) gives its last user message with non-empty text; any other record its first. A record with no user text gives none.
A run's prompt (TurnPrompt) is its input record's when that holds one, else the lowest-index record's that does — so a message steered in at a later step is never the prompt — with one exception, the resume: an input record that ends at an assistant message carrying tool calls was cut there by the core (core/loop.go), and what the run was fed after that message — the rebuilt tool message, then a new prompt if the resume carried one — is the next growth record (index + 1). Its user text before its first assistant message is the turn's prompt when there is any; a plain resume (Approve or Deny, no new prompt) keeps the input record's.
An accepted edge of the resume rule: on a plain Approve or Deny resume, a user message steered in before the resumed batch's first assistant reply lands in the same next record, before that reply, and is indexed as the prompt. It is structurally indistinguishable from a resume carrying a new prompt — the transcript carries no steered mark — so the rule cannot tell the two apart.
func SeverityText ¶ added in v0.10.0
SeverityText is an OTLP severity number's short name: "TRACE", "DEBUG", "INFO", "WARN", "ERROR" or "FATAL" for the first number of each range, with the range's ordinal after it for the rest ("WARN2" is 14); "" for 0 (unspecified) and anything outside 1–24.
func SortCompactions ¶ added in v0.10.0
func SortCompactions(cs []Compaction)
SortCompactions orders DB.Compactions' answer: the views by index, then the session markers in the order given (a backend reads them in emission order). A marker is filed under the run that produced the compacted context, so it follows that run's own views.
func StepRange ¶ added in v0.14.0
func StepRange(reqs []RequestRecord) (lo, hi int, ok bool)
StepRange is the smallest and largest Step of reqs, for a backend's raw-record read; ok is false for an empty page.
func TranscriptBodies ¶ added in v0.10.0
func TranscriptBodies(batches []TranscriptBatch) []json.RawMessage
TranscriptBodies returns the batches' bodies in order — Transcript's answer, for a backend that reads both through one query.
func TurnPrompt ¶ added in v0.14.0
TurnPrompt is a run's prompt from its input record and the record at the next index (nil when none is stored): PromptText's resume rule over the pair — the next record's first user text before its first assistant message when the input record ends at an assistant message with tool calls, else the input record's last user text. A user message steered in before the first assistant reply of a plain resume's batch reads as that resume's new prompt (PromptText names the edge: the transcript carries no steered mark).
Types ¶
type Assembler ¶ added in v0.13.0
type Assembler struct {
// contains filtered or unexported fields
}
Assembler is AssembleStep over one run's growth batches decoded once: each Step call answers exactly what AssembleStep answers for the same records and step, without re-reading every batch (a reader that walks every step of a run — the wefttest fixture export — would otherwise decode the transcript once per step). The messages Step returns share their parts with the Assembler's decoded batches: read them, or copy before patching. Not safe for concurrent use.
func NewAssembler ¶ added in v0.13.0
func NewAssembler(batches []TranscriptBatch) *Assembler
NewAssembler decodes batches once for Step.
func (*Assembler) Step ¶ added in v0.13.0
func (a *Assembler) Step(requests []RequestRecord, compactions []Compaction, step int) (StepMessages, error)
Step is AssembleStep for one step over the decoded batches.
type Batch ¶
Batch is one write: spans and records as ingested, in arrival order. Write is idempotent on (run, record kind, pos) and (trace, span), so a retried transport's duplicate is a no-op.
type Compaction ¶ added in v0.10.0
type Compaction struct {
Scope string
Hash string // weft.compaction.hash
Index int64
Step int
FromSeq int64
ToSeq int64
Messages json.RawMessage
Reason string
Replaced int
Entries int
TokensBefore int64
TokensAfter int64
}
Compaction is one compaction a run's records name (ADR 0028 §8), read by DB.Compactions. Two scopes:
- CompactionRun: a compaction view, the messages record the core emits when a PrepareStep sent a request whose messages are not the run's transcript. The request whose messages_ref names Index saw the growth records up to Index, concatenated, with the transcript range [FromSeq, ToSeq) replaced by Messages. It is never part of the plain transcript (Transcript and TranscriptBatches hold growth records only) and never applies to another request.
- CompactionSession: the informational marker weft/thread emits (a record of kind compaction) when the session compacts, filed under the last run that produced the compacted context (or, for a context no run produced, under the next run). The next run's input record holds the compacted context literally; the marker carries no messages and a reader never applies it. Index and Step are -1, FromSeq and ToSeq 0.
Replaced and Entries are the change in messages: for a view, ToSeq-FromSeq and the length of Messages; for a session marker, the counts thread reported over the session's context. TokensBefore and TokensAfter are thread's estimates (session scope only; 0 for a view, whose tokens the core does not estimate). Reason is thread's compaction reason (manual, threshold, overflow, from_hook, trim); "" for a view.
func CompactionOf ¶ added in v0.10.0
func CompactionOf(kind string, index int64, step int, attrs map[string]any, body []byte) (c Compaction, ok bool, err error)
CompactionOf reads one stored record as a Compaction, so every backend parses alike. kind is the record's weft.record, index its position, step its weft.step.index, attrs its attributes (numbers as numbers or numeric strings) and body its body. ok is false for a record that names no compaction — a growth messages record, any other kind. A messages record whose weft.messages.reason is not one this build knows is an error: ADR 0028 §8 has readers fail loudly rather than mistake a view they cannot apply for something else.
func ViewOf ¶ added in v0.13.0
func ViewOf(rec RequestRecord, compactions []Compaction) (Compaction, bool)
ViewOf returns the run-scope compaction view a request record names: the one whose index is the request's messages_ref.index. ok is false for a request that carried the plain transcript (its ref names a growth record) or names nothing.
type DB ¶
type DB interface {
// Write stores one batch, idempotent on (run, record kind, pos)
// and (trace, span): a retried transport's duplicate is a no-op.
// Deltas are counted, never stored; heartbeats are never stored —
// they only move the run's last-seen (DeriveStatus's clock).
Write(ctx context.Context, b Batch) error
Runs(ctx context.Context, q RunQuery) (RunPage, error)
Run(ctx context.Context, id string) (RunDetail, error) // ErrNotFound
// Events pages a run's durable events in position order. after is
// exclusive — pass -1 to read from the start (0 would skip position
// 0, the run_start) and EventPage.NextAfter to continue. limit: 0 =
// 100, max 1000.
Events(ctx context.Context, runID string, after int64, limit int) (EventPage, error)
// Transcript returns the messages bodies in index order, one per
// growth messages record (a compaction view, ADR 0028 §8, is not
// transcript: see Compactions), read through DedupTranscript — so
// they concatenate to the transcript the run held.
Transcript(ctx context.Context, runID string) ([]json.RawMessage, error)
// TranscriptBatches is Transcript with what each messages record
// stored beside its body: its index, the step it joined
// (weft.step.index, -1 when the record carried none) and whether
// it is the run's input (weft.messages.input). The bodies are
// Transcript's, through DedupTranscript, one per record.
TranscriptBatches(ctx context.Context, runID string) ([]TranscriptBatch, error)
// Compactions returns the compactions a run's records name (ADR
// 0028 §8): every run-scope view in index order, then the session
// markers thread filed under the run, in emission order — what a reader needs to draw the compaction and
// rebuild a request's messages. Transcript and TranscriptBatches
// never include a view: the plain transcript is growth records
// only. A messages record with an unknown weft.messages.reason is
// an error. ErrNotFound for an unknown run; empty, not an error,
// for a run that never compacted.
Compactions(ctx context.Context, runID string) ([]Compaction, error)
// The request record (ADR 0028). Requests pages a run's request
// records in index order, one per model-call attempt
// (RequestQuery). Prompt and Tools return the run's prompt or tools
// record of one hash: ErrNotFound when the run holds none, as a
// *HoleError when the reason is known (not_recorded, stripped,
// gap — ExplainMissing). Catalogs returns every tools record of the
// run, one per hash, in index order; a content-off run has none.
// All four answer ErrNotFound for an unknown run.
Requests(ctx context.Context, runID string, q RequestQuery) ([]RequestRecord, error)
Prompt(ctx context.Context, runID, hash string) (PromptRecord, error)
Tools(ctx context.Context, runID, hash string) (ToolsRecord, error)
Catalogs(ctx context.Context, runID string) ([]ToolsRecord, error)
RunSpans(ctx context.Context, runID string) ([]Span, error)
// OtherLogs pages a run's app log records — the non-weft records
// the writers store beside weft's (other_logs on SQLite, otel_logs
// on ClickHouse), attributed to the run through the spans they were
// emitted under — in time order (LogQuery, ReadOtherLogs), with
// what the read could not show (LogPage: partial while running,
// lines under a never-stored span, the candidate cap).
// ErrNotFound for an unknown run; empty Logs, not nil, for a run
// with no app logs; a *HoleError (Kind "logs", HoleNotRecorded) for
// a finished run with no span to attribute them through.
OtherLogs(ctx context.Context, runID string, q LogQuery) (LogPage, error)
Trace(ctx context.Context, traceID string) ([]Span, error) // empty, not an error, for an unknown trace
// Traces lists traces — weft and non-weft alike, one row per trace
// id the spans table holds — newest first (TraceQuery, TracePage):
// the row is computed from the trace's spans alone, so a trace
// whose root never arrived is listed with Root nil.
Traces(ctx context.Context, q TraceQuery) (TracePage, error)
Sessions(ctx context.Context, q SessionQuery) (SessionPage, error)
Session(ctx context.Context, id string) (SessionDetail, error)
ResolvePublicID(ctx context.Context, publicID string) (sessionID string, err error)
// The playground's saved experiments (WEFT-PLAYGROUND §10.4,
// PQ4): the definition rows keyed by the weft.experiment.id the
// runs carry — one small table beside runs.
SaveExperiment(ctx context.Context, e Experiment) error // upsert by ID
Experiments(ctx context.Context) ([]Experiment, error) // newest update first
Experiment(ctx context.Context, id string) (Experiment, error)
// Deletion and retention (plan A7, §13.13). Every one touches the
// observability tables alone — never another module's tables
// sharing the file (thread's sessions in one SQLite file stay
// byte-identical) and never the experiments table (its definitions
// name runs, they do not hold them).
//
// DeleteRun removes a run and its subagent children — every run
// whose ParentRunID chain reaches id — with everything stored under
// them: records (deltas kept by KeepDeltas included), the search
// index rows, the spans named by DeletedSpans and the app log
// records emitted under those spans. ErrNotFound for an unknown id.
// A run still being written is deleted as it stands: a later write
// for it starts a new run row from what that write carries.
DeleteRun(ctx context.Context, id string) error
// DeleteSession is DeleteRun for every run carrying the session id
// (weft.session.id), children included. The app's own conversation
// store is not obsdb's and is never touched. ErrNotFound when no
// run carries the id.
DeleteSession(ctx context.Context, id string) error
// Sweep deletes, as DeleteRun does, the runs whose LastSeen is
// before the cutoff and whose status is not running (succeeded,
// failed or interrupted — a run silent past InterruptedAfter is
// interrupted, so it counts as not running and is swept), with
// their children that are not running either; a running child keeps
// its own subtree and is swept on its own once it ends. Until then
// that child of a swept parent is listed only under RunQuery's
// ParentRunID "*" (the API's all=1 / parent=*), never among the
// top-level runs. It works in passes of at most SweepBatch roots
// (each with its subtree), so a large backlog never holds the
// writer for the whole sweep; ctx is checked between passes. It
// returns how many runs it deleted, children counted, over every
// pass — on an error, the passes before it stay deleted.
Sweep(ctx context.Context, before time.Time) (int, error)
// Vacuum gives the space deletions freed back: SQLite runs VACUUM
// (the whole file is rewritten, other modules' tables with their
// contents unchanged); ClickHouse runs OPTIMIZE TABLE … FINAL on
// the tables deletions mutate, which merges every part — an
// operator's command, heavy on a large database.
Vacuum(ctx context.Context) error
Close() error
}
DB is the observability database every backend implements (sqlite by default, clickhouse hosted). Implementations must be safe for concurrent use: writers are rare (the local sink's synchronous processors), readers many (Studio).
type EventPage ¶
type EventPage struct {
Events []PosEvent
NextAfter *int64
Done bool // the run is terminal and every stored event was returned
Gaps []int64
}
EventPage is one page of a run's durable events. Gaps are durable positions missing below the high-water mark: a lost batch, never a delta — deltas are on their own counter, so their absence cannot open a hole here (D3). At most MaxGaps are listed, lowest first.
type Experiment ¶
type Experiment struct {
ID, Name, Agent string
Created, Updated time.Time
Variants []ExperimentVariant
Inputs []ExperimentInput
// ExperimentCommand is the rest of the §5.1 command the matrix
// sent, so a saved experiment reopens as the form that ran it.
// Zero on an experiment saved before obsdb kept it.
ExperimentCommand
}
Experiment is one saved playground group: a name, the variants and inputs that define it, keyed by the id its runs carry as weft.experiment.id (§10.4). Variants and inputs are the wire shapes verbatim (overrides JSON included); the runs themselves stay in the runs table.
type ExperimentCommand ¶ added in v0.14.0
type ExperimentCommand struct {
Engine string `json:"engine,omitempty"` // live | scripted
SideEffects string `json:"side_effects,omitempty"` // substitute | park | allow
Thread string `json:"thread,omitempty"` // ephemeral | fork
FromStep *int `json:"from_step,omitempty"` // the step every cell continues from
SourceRunID string `json:"source_run_id,omitempty"` // the run the cells replay from
TranscriptEdits json.RawMessage `json:"transcript_edits,omitempty"` // the list as sent
}
ExperimentCommand is the experiment-wide part of the §5.1 run command, kept as sent: the modes, the step the cells continue from, the source run and the transcript edits. Every field is optional — zero is "not recorded" (an older row) or the command's default, and a reader does not tell the two apart. obsdb stores it as one JSON document beside the definition and does not validate it (Studio does, on the way in).
type ExperimentInput ¶
type ExperimentInput struct {
Key string `json:"key"`
SourceRunID string `json:"source_run_id,omitempty"`
Text string `json:"text,omitempty"`
}
ExperimentInput is one row of the matrix: a key, a source run to take the turn from, or a literal text.
type ExperimentVariant ¶
type ExperimentVariant struct {
Key string `json:"key"`
Overrides json.RawMessage `json:"overrides,omitempty"`
Engine string `json:"engine,omitempty"`
SideEffects string `json:"side_effects,omitempty"`
Thread string `json:"thread,omitempty"`
}
ExperimentVariant is one column of the matrix: a key, the overrides object it applies (verbatim — instructions, tools_enabled, model, thinking, options, params, tool_choice, park_on, only_tools, and any key a later command adds), and the modes it runs under when they differ from the experiment's (empty: the experiment's own).
type Frame ¶
type Frame struct {
Kind string // "record" | "run"
Record *Record // Kind == record; Weft derived
Weft Weft
Run *RunRow // Kind == run
Seq uint64 // hub-wide, monotonic: the SSE id and the resume cursor
}
Frame is one live-lane message: a record as ingested (deltas included), or a run row that changed.
func RecordFrame ¶
RecordFrame builds the frame a Write publishes for one record: the record as ingested with its derived identity, deltas included.
type Hole ¶ added in v0.10.0
type Hole string
Hole is one badge of ADR 0028 §11's closed table: the one word a reader shows wherever a record is missing, cut or derived. The vocabulary is closed — a new badge is an amendment to that ADR — and Holes lists it. The zero value means no hole.
const ( HoleTruncated Hole = "truncated" // a destination's cap cut the content (weft.content.truncated_bytes) HoleStripped Hole = "stripped" // the destination's chain is content-off (weft.content = stripped) HoleRedacted Hole = "redacted" // the destination's Redact changed the content (reserved: weft's pipeline does not mark a redaction) HoleMaxTokens Hole = "max_tokens" // the step finished on the output token limit HoleInterrupted Hole = "interrupted" // the run stopped reporting (DeriveStatus) HoleGap Hole = "gap" // a hole in a contiguous counter, or a record a destination dropped HoleNotRecorded Hole = "not_recorded" // the record kind did not exist in the version that wrote the run HoleDerived Hole = "derived" // the value was computed by the reader, not recorded HoleHidden Hole = "hidden" // the reader's scope may not see it HoleCompacted Hole = "compacted" // the model saw a compacted view )
The closed table, in ADR 0028 §11's order.
type HoleCause ¶ added in v0.10.0
type HoleCause string
HoleCause narrows a hole to the cause a reader established, when the table words that cause apart: CauseDefault is the hole's own note.
const ( CauseDefault HoleCause = "" // CauseNoSpans: the run's records arrived but no span did — it ran // without a tracer (not_recorded: attempts, the answering model). CauseNoSpans HoleCause = "no_spans" // CauseResultCap: the core's own tool-result cap cut the content // before any destination saw it (truncated: a tool result the model // itself read as a prefix plus the marker, weft.MaxResultBytes). CauseResultCap HoleCause = "result_cap" // CauseLogCap: the app-log reader scanned its candidate cap // (MaxLogCandidates) before attributing lines to the run, so later // lines may be missing (truncated: no destination setting changes // it). CauseLogCap HoleCause = "log_cap" // CauseNoPublicID: the session's turns carry no weft.public_id — // a thread session created without thread.PublicID, or an app that // never stamped one (not_recorded: Studio's // GET /api/sessions/{id}/public_id). CauseNoPublicID HoleCause = "no_public_id" // CauseDevTokenOnly: the route answers the dev (server) token and // setup A's open API only; a panel token, scoped to one public id, // may not learn another (hidden: the session-to-public-id lookup). CauseDevTokenOnly HoleCause = "dev_token_only" // CauseNotServed: the Studio asked serves no request record (its // requests capability is off, or it predates the record), so the // run's request, prompt and tools records were not read — the run // may hold them (not_recorded: the panel's Request tab). CauseNotServed HoleCause = "not_served" // CauseResponseCap: the route bounds how much one response reads // (Studio's GET /api/diff compares a bounded number of steps), and // the run holds more than it read (truncated: nothing was lost; the // rest is a read away). CauseResponseCap HoleCause = "response_cap" // CauseRawOff: the attempt has no raw record (ADR 0028 §12) — wire // bodies are recorded only under otel.RawBodies(true) with // WEFT_ENV=dev, by an adapter that reports them (not_recorded: the // requests row's raw, the wire tab). CauseRawOff HoleCause = "raw_off" )
func HoleCauses ¶ added in v0.10.0
func HoleCauses() []HoleCause
HoleCauses lists the causes the table words, in order.
type HoleError ¶ added in v0.10.0
HoleError is the ErrNotFound DB.Prompt and DB.Tools return when the reader knows why the record is missing: errors.Is(err, ErrNotFound) holds, and errors.As yields the badge. Hole is one of:
- HoleNotRecorded: the run was written before ADR 0028 (its InstructionsHash is ""), so no such record ever existed;
- HoleStripped: a request naming the hash came through a content-off chain, which drops prompt and tools records;
- HoleGap: a request naming the hash was stored as emitted, yet the record it names is absent — a destination dropped it.
A hash no request of the run names is a plain ErrNotFound.
DB.OtherLogs returns one with Kind "logs", no Hash and HoleNotRecorded for a run that has no span to attribute app logs through (ReadOtherLogs).
type Hub ¶
type Hub interface {
Publish(ctx context.Context, f Frame)
Subscribe(ctx context.Context, sel Selector, after uint64) (<-chan Frame, error)
}
Hub is the live lane. Publish never blocks the caller beyond a bounded per-subscriber queue; a slow subscriber is dropped with Overflow.
type HubOption ¶
type HubOption interface {
// contains filtered or unexported methods
}
HubOption configures NewHub.
type LogCandidates ¶ added in v0.10.0
type LogCandidates func(ctx context.Context, traceIDs []string, from, to time.Time, limit int) ([]OtherLog, error)
LogCandidates is a backend's half of DB.OtherLogs: the non-weft log records (no weft.run.id) of the given traces whose time is within [from, to]: the first limit of them, returned in time order (ties in a stable order of the backend's). ReadOtherLogs does the rest.
type LogPage ¶ added in v0.10.0
LogPage is one DB.OtherLogs answer: the page and what the reader knows about what it could not show.
- Partial: the run is running. Spans are exported when they end, so a line under an in-flight span (invoke_agent, an open execute_tool) is not attributable yet: it appears once its span arrives, and it may then take an index below lines already served — indexes are stable only once the run has ended.
- Gap: lines in the run's traces (deduplicated, in its window) that name a span no stored span has — dropped (a lost batch, a process that exited before exporting), or still open (an app span that encloses the run and has not ended). They may belong to this run or to another in the same trace, and are not in Logs. Always 0 while Partial (the span may still be on its way).
- Truncated: the run's traces hold more than MaxLogCandidates log lines in its window — anyone's, the cap applies before attribution — so only lines among the first MaxLogCandidates by time were read: later lines of this run may be missing, and Logs may hold far fewer than MaxLogCandidates.
func ReadOtherLogs ¶ added in v0.10.0
func ReadOtherLogs(ctx context.Context, db DB, runID string, q LogQuery, candidates LogCandidates) (LogPage, error)
ReadOtherLogs is DB.OtherLogs for every backend, so both attribute, order, index and page alike. An app log names no run — it carries no weft.run.id, or the writer would have taken it for a weft record — so it is attributed through the span it was emitted under: a record belongs to the run when its (trace, span) is one of the run's spans (invoke_agent, chat, execute_tool: a tool handler's log line), or a non-weft span below one of them (the app's own span inside a tool), never one below another run's span (a subagent's lines are the child's). A record emitted with no span context cannot be attributed and is in no run's logs.
The scan reads the run's traces, bounded by its time window (Started − LogSkew to LastSeen + LogSkew). Duplicates — a retried transport's batch, which neither backend's log table collapses — are dropped (same time, trace, span, severity, event name, body and attributes). The order is time, then span id, severity and body; the filter applies after indexing (LogQuery). An unknown run is ErrNotFound; a run with spans and no app logs has an empty Logs, never nil. A run with no span at all has nothing to attribute logs through: a running one reads empty and Partial (its spans may not have arrived yet), any other is a *HoleError{Kind: "logs", Hole: HoleNotRecorded} — it was recorded without a tracer.
While the run is running the page is Partial and its indexes may shift (LogPage): a client paging with From may re-read lines and miss as many. TODO(phase 2): a stable cursor — (time, span, seq) rather than an index — so a live walk neither repeats nor skips.
At most MaxLogCandidates records are read (Truncated past it), chosen before attribution. TODO(phase 2): filter by the attributed span set in the backends' SQL, so the cap counts this run's lines only. Lines naming a span never stored are counted, not shown (Gap).
type LogQuery ¶ added in v0.10.0
LogQuery selects a run's app log records for DB.OtherLogs. The zero value reads every record from index 0, 100 at a time. From is the first index read (inclusive): pass the last returned Index + 1 to continue; a page shorter than PageLimit is the last. MinSeverity, when above 0, keeps the records whose OTLP severity number is at least that (ParseSeverity reads a level name); Index is the record's place among all of the run's app logs, so a filtered page's indexes skip and From continues a filtered walk alike. Limit: 0 = 100, max 1000.
type MessageText ¶ added in v0.14.0
MessageText is one message of a messages record body reduced to what search reads: its role and the text of its text parts, joined by "\n" (every part of type "text", an empty one included, in order), and whether it carries a tool call (a part of type "tool_call").
func MessagesText ¶ added in v0.14.0
func MessagesText(body []byte) []MessageText
MessagesText reads a messages record body — a JSON array of core.Message — into its messages' text, in order. A body that does not parse reads as no messages: search indexes what it can read and never fails a write over it.
type OtherLog ¶ added in v0.10.0
type OtherLog struct {
Index int64
Time time.Time
Severity int
EventName string
Body string
Attrs map[string]any
TraceID string
SpanID string
Service string
}
OtherLog is one app log record of a run: a non-weft record (no weft.run.id — the app's own slog or OTel Logs API lines, which the writers keep in other_logs on SQLite and in otel_logs on ClickHouse) attributed to the run through the span it was emitted under (DB.OtherLogs). Index is its place in the run's app logs, in time order; Severity the OTLP severity number (0 unspecified, 1–24; SeverityText names it). Attrs are the record's attributes: typed on SQLite, strings on ClickHouse, whose otel_logs keeps the collector's string map only.
type PosEvent ¶
type PosEvent struct {
Pos int64
Time time.Time
Event json.RawMessage // the body, verbatim
Content string
TruncatedBytes int64
}
PosEvent is one positioned event of a run: the body verbatim, at its durable position, with the two content attributes the destination's chain stamped on it (ADR 0028 §11): Content is weft.content ("stripped" from a content-off chain, "" as emitted) and TruncatedBytes weft.content.truncated_bytes (what a content-on chain's cap cut, 0 for none). A ClickHouse row written before migration 0004 carries neither.
type PromptRecord ¶ added in v0.10.0
type PromptRecord struct {
Index int64
Time time.Time
Hash string
Text string
Content Hole
TruncatedBytes int64
}
PromptRecord is one stored prompt record (ADR 0028 §4): the composed system text of one distinct system hash. Index is weft.prompt.index. Content is HoleTruncated when a destination's cap cut Text (TruncatedBytes > 0), HoleDerived when the body did not parse (a malformed producer; Hash is then the weft.system.hash attribute). Hash is over the text before the cut.
func PromptRecordOf ¶ added in v0.10.0
func PromptRecordOf(r StoredRecord) PromptRecord
PromptRecordOf builds a PromptRecord from a stored prompt record. Content is HoleTruncated when a cap cut the text; a body that does not parse (a malformed producer) reads HoleDerived, with no text and the hash of the record's weft.system.hash attribute.
type RawRecord ¶ added in v0.14.0
type RawRecord struct {
Index int64 `json:"index"`
Step int `json:"step"`
Attempt int64 `json:"attempt"`
Time time.Time `json:"time"`
Request RawSide `json:"request"`
Response *RawSide `json:"response"`
RedactedHeaders []string `json:"redacted_headers"`
RedactedQuery []string `json:"redacted_query,omitempty"`
Content Hole `json:"content"`
TruncatedBytes int64 `json:"truncated_bytes"`
}
RawRecord is one stored raw record: the attempt's wire exchange, as the core shaped it — credential headers stripped and named in RedactedHeaders, credential query values replaced and named in RedactedQuery, each body capped at 256 KiB with the cut counted. Index is weft.raw.index, Step the stored weft.step.index, Attempt the body's attempt (the attempt in flight when the adapter reported). TruncatedBytes is weft.content.truncated_bytes, both bodies' cut; Content is HoleTruncated when it is above 0, HoleDerived when the body did not parse (a malformed producer: the sides are zero).
func RawRecordOf ¶ added in v0.14.0
func RawRecordOf(r StoredRecord) RawRecord
RawRecordOf builds a RawRecord from a stored raw record.
type RawSide ¶ added in v0.14.0
type RawSide struct {
Method string `json:"method,omitempty"`
URL string `json:"url,omitempty"`
Status int `json:"status,omitempty"`
Headers map[string][]string `json:"headers"`
MediaType string `json:"media_type,omitempty"`
Body string `json:"body"`
Encoding string `json:"encoding,omitempty"`
Bytes int64 `json:"bytes"`
TruncatedBytes int64 `json:"truncated_bytes,omitempty"`
// Complete is the response side's: false when the body was not read
// to its end (closed unread by a transport retry, a timeout, a
// reader that stopped) — the body is what was read, never the whole;
// nil on the request side and on records that do not say.
Complete *bool `json:"complete,omitempty"`
}
RawSide is one side of the exchange. Body is the bytes as text, or base64 when Encoding is "base64" (the bytes were not UTF-8); Bytes is the whole body's size before the cap, TruncatedBytes what the cap removed. Method and URL are the request's, Status the response's (absent on the other side). A nil Response is an exchange that got no response (a transport error).
type Record ¶
type Record struct {
Time, Observed time.Time
TraceID, SpanID string
Severity int
EventName string
Body string
Service string
Attrs map[string]any
Resource map[string]any
}
Record is one log record, OTLP-shaped.
func FromOTLPLogs ¶
func FromOTLPLogs(req *collogspb.ExportLogsServiceRequest) []Record
FromOTLPLogs decodes an OTLP/HTTP log export request into the model. weft records (weft.run.id present) and non-weft records (slog lines, other services) both pass through; the kind they carry decides where Write puts them.
type RequestBody ¶ added in v0.10.0
type RequestBody struct {
Step int `json:"step"`
Attempt int64 `json:"attempt"`
SystemHash string `json:"system_hash"`
MessagesRef RequestMessagesRef `json:"messages_ref"`
Tools RequestTools `json:"tools"`
ToolChoice *RequestToolChoice `json:"tool_choice,omitempty"`
Thinking *RequestThinking `json:"thinking,omitempty"`
SequentialTools bool `json:"sequential_tools"`
Params RequestParams `json:"params"`
Model RequestModel `json:"model"`
Stream bool `json:"stream"`
}
RequestBody is the request record's body (ADR 0028 §3), keys as the core writes them; an absent field is unset.
type RequestMessagesRef ¶ added in v0.10.0
RequestMessagesRef says which messages the request carried: the run's view as of messages record Index, Count messages long. Index is nil where no messages record exists (capture off).
type RequestModel ¶ added in v0.10.0
type RequestModel struct {
Provider string `json:"provider,omitempty"`
Name string `json:"name,omitempty"`
}
RequestModel is the model the attempt requested.
type RequestParams ¶ added in v0.10.0
type RequestParams struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
Stop []string `json:"stop,omitempty"`
Seed *int64 `json:"seed,omitempty"`
}
RequestParams are the sampling parameters; nil is the adapter's default.
type RequestQuery ¶ added in v0.10.0
RequestQuery selects a run's request records for DB.Requests, in request-index order. The zero value reads every step from index 0, 100 at a time. Step, when set, keeps step *Step's attempts only. From is the first index read (inclusive): pass the last returned Index + 1 to continue; a page shorter than PageLimit is the last. Limit: 0 = 100, max 1000.
func (RequestQuery) PageLimit ¶ added in v0.10.0
func (q RequestQuery) PageLimit() int
PageLimit is the query's normalized limit: 100 for 0 (or negative), 1000 at most.
type RequestRecord ¶ added in v0.10.0
type RequestRecord struct {
Index int64
Step int
Attempt int64
Time time.Time
SystemHash string
CatalogHash string
Content Hole
TruncatedBytes int64 // informational only; a request record is never capped by weft
Body RequestBody
Raw json.RawMessage
Wire *RawRecord
}
RequestRecord is one stored request record (ADR 0028 §3): one model call attempt. Index is weft.request.index, Step the stored weft.step.index (-1 when absent), Attempt the body's attempt. The record's weft.system.hash and weft.catalog.hash attributes win over the body's system_hash and tools.catalog_hash when set and they disagree; SystemHash and CatalogHash fall back to the body's where the attribute is absent. Raw is the body verbatim.
Content is the record's hole, from ADR 0028 §11's table:
- "": stored as emitted;
- HoleStripped: it came through a content-off chain (weft.content = stripped: its params.stop and messages_ref.index were removed, and that chain dropped the prompt and tools records it names) — whether or not its body parses;
- HoleDerived: a malformed producer — an unstripped record whose body did not parse, so Body is zero, Attempt is 0, and the two hashes come from the record's attributes.
A request record is never capped (only prompt and tools records are), and a destination's Redact over params.stop is not marked on the record: HoleRedacted is reserved for the readers that can tell (ADR 0028 §11, A3).
Wire is the attempt's raw record (ADR 0028 §12: the provider request and response on the wire, recorded only under otel.RawBodies in development), nil when the run holds none for this attempt (AttachRaw).
func RequestRecordOf ¶ added in v0.10.0
func RequestRecordOf(r StoredRecord) RequestRecord
RequestRecordOf builds a RequestRecord from a stored request record: the hashes are the attributes' where set, the body's otherwise; a stripped mark is HoleStripped, and only an unstripped record whose body does not parse is HoleDerived (RequestRecord).
type RequestThinking ¶ added in v0.10.0
RequestThinking is the thinking configuration in force.
type RequestToolChoice ¶ added in v0.10.0
RequestToolChoice is the tool choice in force.
type RequestTools ¶ added in v0.10.0
RequestTools is the catalog a request offered: its hash ("" for no tools) and the names in the order offered.
type RunDetail ¶
RunDetail is one run with its subagent children (runs whose ParentRunID is this run, by Started).
type RunPage ¶
RunPage is one Runs result, newest Started first (ties by ID, descending). NextBefore and NextBeforeID are the next page's RunQuery.Before and BeforeID, nil and "" on the last page. A page does not end inside a group of runs sharing one Started: the rest of the group rides along, so a caller paging on Before alone skips nothing — up to MaxTies rows past Limit. A longer tie (a bulk import at one truncated timestamp) is cut there, and only the (Before, BeforeID) pair resumes inside it. Total counts the whole match, cursor aside.
type RunQuery ¶
type RunQuery struct {
Agent, SessionID, PublicID, ParentRunID string // ParentRunID: "" top-level only, "*" all
Status Status // "" any
Playground *bool // nil any
ExperimentID string // the runs of one experiment
Meta map[string]string // subset match on metadata
Before time.Time // cursor on Started; zero = newest
// LastSeenAfter keeps the runs last seen at or after it (zero = no
// bound). A running run is always among them once the bound is
// older than InterruptedAfter: running means seen that recently.
LastSeenAfter time.Time
// BeforeID, with Before, makes the cursor the pair (Started, ID):
// the runs after that one in the list order — exact inside a group
// sharing one Started. Pass RunPage.NextBeforeID.
BeforeID string
Limit int // 0 = 50, max 500
}
RunQuery selects runs for Runs. The zero value lists top-level runs, newest first, 50 at a time.
type RunRow ¶
type RunRow struct {
ID, ParentRunID, ParentCallID, TraceID string
Agent, Provider, Model, ManifestHash, WeftVersion, Service string
SessionID, PublicID string
Turn int
Playground bool
ExperimentID, ForkedFrom string
Meta map[string]string
Started time.Time
Finished *time.Time
LastSeen time.Time
Status Status
Err string
Steps, Pending int
StopReason string
Usage core.Usage
EventCount, MessageCount int64
DeltaCount int64 // deltas are counted, never stored (Q4)
// ADR 0028 §10: run_start's weft.instructions.hash ("" on a run
// written before the request record — RequestsHole reads the
// table), request index 0's weft.catalog.hash ("" when it offered
// no tools) and the request records' high-water mark (max
// weft.request.index + 1). The API names them instructions_hash,
// catalog_hash and request_count.
InstructionsHash, CatalogHash string
RequestCount int64
// PromptPreview is the run's prompt — the turn's own, PromptText's
// rule with TurnPrompt's resume clause (Searcher's prompt field) — cut to
// PromptPreviewRunes runes; "" when the records carried no content
// (a content-off run stores no messages) or no user text.
PromptPreview string
}
RunRow is one run in a list or detail: the identity chain, timing, derived status and the denormalised usage and counts the list view reads without a second query.
func (RunRow) RequestsHole ¶ added in v0.10.0
RequestsHole is ADR 0028 §10's reading table over a run row: HoleNotRecorded when InstructionsHash is "" and no request record exists (written before the request record existed: no request, prompt or tools records), "" otherwise — with RequestCount 0 the run made no model call, above 0 its requests are recorded. A row with request records but no instructions hash lost the batch that carried run_start (or has not received it yet): its requests are recorded, and a prompt or catalog they name but the store lacks is a gap (ExplainMissing), never not_recorded — "upgrade weft" would be the wrong fix for a run this weft wrote.
type SearchQuery ¶ added in v0.14.0
type SearchQuery struct {
RunQuery
Text, Tool, Model, Error string
Since, Until time.Time
MinTokens int64
Sort SearchSort
Offset int
}
SearchQuery selects runs for Search. Its RunQuery carries the filters /api/runs reads — agent, status, session, public id (the panel token's scope), parent, playground, experiment, metadata — with the same meaning, so a scope applied to one applies to the other; RunQuery.Before and BeforeID are not read (a search pages by Offset) and RunQuery.Limit is the page size (0 = 50, max 500).
The search fields:
- Text: the search terms (ParseSearch's grammar). A run matches when every term occurs, case-insensitively, as a substring of at least one of the run's search fields — its prompt (the turn's own: PromptText), its answer (the text of its last assistant message that carries text), its error, or the name of a tool it called (tool_start events) — each term free to match a different field. A content-off run has no prompt or answer: only its error and tool names can match.
- Tool: the exact name of a tool the run called.
- Model: the exact model name the run row carries.
- Error: a case-insensitive substring of the run's error.
- Since, Until: Started ≥ Since, Started < Until (zero = open).
- MinTokens: input + output tokens ≥ MinTokens (0 = any).
- Sort: the order (SearchSort; "" is SortStarted).
- Offset: rows to skip in that order.
type SearchSort ¶ added in v0.14.0
type SearchSort string
SearchSort is a search's order. Every order breaks ties by Started, then run id, both descending, so a result is total and stable.
const ( SortStarted SearchSort = "started" // newest first (the default) SortDuration SearchSort = "duration" // longest first: Finished (LastSeen while unfinished) − Started SortTokens SearchSort = "tokens" // most input + output tokens first )
type Searcher ¶ added in v0.14.0
type Searcher interface {
// Search answers the runs SearchQuery matches, sorted as it asks,
// with Total counting the whole match. NextBefore and NextBeforeID
// stay nil and "": a search pages by SearchQuery.Offset.
Search(ctx context.Context, q SearchQuery) (RunPage, error)
}
Searcher is the optional search capability of a DB (plan A7): runs found by what they said and did, not only by who ran them. Both first-party backends implement it — obsdb/sqlite through an FTS5 trigram index maintained on write, obsdb/clickhouse through case-insensitive substring scans over the stored records — and the obsdbtest conformance table holds them to the same answers. A backend that does not implement it is a database that cannot search: Studio then leaves its search capability off and says why.
Example ¶
A Searcher finds runs by what they said and did: every term a case-insensitive substring of the run's prompt, answer, error or a tool name, each term in any of them. The run row carries its prompt preview.
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/weftgo/weft/obsdb"
"github.com/weftgo/weft/obsdb/sqlite"
)
func main() {
db, err := sqlite.Open(":memory:")
if err != nil {
log.Fatal(err)
}
defer func() { _ = db.Close() }()
t0 := time.Unix(0, 1790845923120000000).UTC()
write := func(run, prompt, answer string, at time.Duration) {
rec := func(idx int64, body string) obsdb.Record {
return obsdb.Record{Time: t0.Add(at), Body: body, Attrs: map[string]any{
"weft.record": "messages", "weft.run.id": run, "weft.messages.index": idx}}
}
if err := db.Write(context.Background(), obsdb.Batch{Records: []obsdb.Record{
rec(0, `[{"role":"user","content":[{"type":"text","text":"`+prompt+`"}]}]`),
rec(1, `[{"role":"assistant","content":[{"type":"text","text":"`+answer+`"}]}]`),
}}); err != nil {
log.Fatal(err)
}
}
write("run_1", "Refund order 7", "The refund failed: card declined.", 0)
write("run_2", "Refund order 9", "Refunded.", time.Second)
page, err := db.(obsdb.Searcher).Search(context.Background(), obsdb.SearchQuery{Text: "refund failed"})
if err != nil {
log.Fatal(err)
}
for _, r := range page.Runs {
fmt.Println(r.ID, "-", r.PromptPreview)
}
}
Output: run_1 - Refund order 7
type Selector ¶
type Selector struct {
RunID, SessionID, PublicID, Agent string
// All matches every run's frames (subagent children included; a
// record that names no run is no run's, and is not matched). It is
// a scope of its own: set with any other field, the selector is
// over-set and matches nothing.
All bool
}
Selector scopes a subscription: exactly one field set. A run-scoped subscription follows one run; a session- or public-id-scoped one spans the many runs of one conversation, which is why the cursor is hub-wide rather than per run. All is the explicit every-run scope (Studio's Live page): it must be asked for, never implied.
type SessionDetail ¶
type SessionDetail struct {
SessionRow
Runs []RunRow
}
SessionDetail is one session with its turns — all of them: top-level runs by Turn, then Started; experiments excluded (they hang off runs via ForkedFrom).
type SessionPage ¶
type SessionPage struct {
Sessions []SessionRow
Total int
NextBefore *time.Time
NextBeforeID string
}
SessionPage is one Sessions result, paged as RunPage is: newest LastSeen first (ties by ID, descending), NextBefore/NextBeforeID are the next SessionQuery.Before/BeforeID, sessions sharing the last row's LastSeen ride along up to MaxTies, and Total counts the whole match.
type SessionQuery ¶
type SessionQuery struct {
Agent, PublicID string
Before time.Time // cursor on LastSeen; zero = newest
BeforeID string // with Before: the (LastSeen, ID) pair, as RunQuery.BeforeID
Limit int // 0 = 50, max 500
}
SessionQuery selects sessions. The zero value lists them newest activity first, 50 at a time.
type SessionRow ¶
type SessionRow struct {
ID, PublicID, Agent string
Turns int
FirstSeen, LastSeen time.Time
Status Status // the newest turn's
Usage core.Usage
}
SessionRow is one session (a GROUP BY over its top-level, non-playground runs).
type Span ¶
type Span struct {
TraceID, SpanID, ParentSpanID string // lowercase hex
Name string
Kind int // OTLP SpanKind
Start, End time.Time
StatusCode int // 0 unset, 1 ok, 2 error
StatusMessage string
Service string // resource service.name
Attrs map[string]any // string, bool, int64, float64, []any
Resource map[string]any
Events []SpanEvent // exceptions etc.
}
Span is one finished span, OTLP-shaped.
func FromOTLPTraces ¶
func FromOTLPTraces(req *coltracepb.ExportTraceServiceRequest) []Span
FromOTLPTraces decodes an OTLP/HTTP trace export request into the model. The receiver (studio/ingest) and a stock collector's dump produce the same rows the local sink does; non-weft spans pass through unchanged — a polyglot trace is a first-class citizen.
type SpanKey ¶ added in v0.14.0
type SpanKey struct {
TraceID, SpanID string
}
SpanKey names one span.
func DeletedSpans ¶ added in v0.14.0
DeletedSpans is the rule both backends delete spans by (DB.DeleteRun, DeleteSession, Sweep): the deleted runs' own spans and, transitively, the non-weft spans below them — a span carrying a run id stops the walk, so a run that is kept keeps its spans. It is the rule a run's app logs are attributed by (DB.OtherLogs), so a deleted run takes exactly the log records its logs page showed: the backends delete the non-weft log records emitted under the returned spans. all holds every span of the traces the deleted runs' spans are in; deleted is the set of run ids being deleted. The keys come back sorted.
type SpanLink ¶ added in v0.14.0
type SpanLink struct {
TraceID, SpanID, ParentSpanID, RunID string
}
SpanLink is one stored span's place in its trace, what DeletedSpans walks: its key, its parent and the weft.run.id it carries ("" for a non-weft span).
type Status ¶
type Status string
Status is a run's derived state. running, succeeded and failed are what the data says; interrupted is what a reader concludes from a stale last-seen (DeriveStatus) — never stored, so a crash leaves evidence and a live run in another process is not mistaken for a corpse (the store's rule, moved to last-seen).
func DeriveStatus ¶
DeriveStatus applies the four-row table every backend reads through:
the invoke_agent span ended with status error → failed a run_finish record exists (and no error span) → succeeded neither, and now - LastSeen ≤ InterruptedAfter → running neither, and older → interrupted
spanFailed is the invoke_agent span's error status; runFinished is the run_finish record's presence. LastSeen is the newest record or span time for the run, heartbeats included. failed outranks succeeded: a run whose span errored failed, whatever records raced behind it. Pending > 0 on a succeeded run means parked at an approval, not stuck.
type StepMessages ¶ added in v0.13.0
type StepMessages struct {
Step int
Messages []core.Message
View *Compaction
Derived bool
}
StepMessages is the messages one step's model call carried — what the model saw at that step (ADR 0028 §8, ADR 0029): the replay prefix for from_step Step, the "compacted messages" a fixture keys on. Step is the step asked for. View is the run-scope compaction view the step's request named (its messages_ref.index), nil when the request carried the plain transcript; Messages is then the growth records up to the view's index with its range replaced, else the growth records up to the request's own index. Derived is true when no request record placed the messages — a run written before ADR 0028, or a request whose messages_ref a content-off chain stripped — and the from_step cut rule did instead: the transcript before step Step's assistant message (no view can apply there: nothing names one).
func AssembleStep ¶ added in v0.13.0
func AssembleStep(batches []TranscriptBatch, requests []RequestRecord, compactions []Compaction, step int) (StepMessages, error)
AssembleStep is MessagesAsOf over records already read: the run's growth batches (TranscriptBatches), its request records (any steps; the answering — last — attempt of step is used) and its compactions. A reader assembling several steps of one run decodes the batches once with NewAssembler instead.
func MessagesAsOf ¶ added in v0.13.0
MessagesAsOf answers the messages step step of run runID's model call carried (StepMessages), read through TranscriptBatches, Requests and Compactions — one assembly (AssembleStep) for every backend and every reader: Studio's transcript route with ?step=, weft/runtime's replay prefix, the wefttest fixtures. ErrNotFound for an unknown run and for a step the run never reached (no request record and no assistant message of that step); an error wrapping ErrStepMessages when the records do not rebuild what the request names.
A session-scope compaction marker is never applied: the run's input record already holds the compacted context literally. A run-scope view applies only to the request that names it, never to a later step's.
Example ¶
ADR 0028 §8's worked example, read back as the model saw it: growth records 0 (the input, u1), 1 (a1) and 2 (t1), then step 1's PrepareStep sent [u1, s] — the core recorded view 3 replacing seqs [1, 3) with s, and step 1's request names it. MessagesAsOf answers each step's messages: step 0 the plain transcript, step 1 the view applied (the replay prefix for from_step 1, ADR 0029).
package main
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/weftgo/weft/obsdb"
"github.com/weftgo/weft/obsdb/sqlite"
)
func main() {
db, err := sqlite.Open(":memory:")
if err != nil {
log.Fatal(err)
}
defer func() { _ = db.Close() }()
ctx := context.Background()
at := time.Unix(0, 1790845923120000000).UTC()
text := func(role, t string) string {
return `{"role":"` + role + `","content":[{"type":"text","text":"` + t + `"}]}`
}
msgs := func(index, step int64, body string, extra map[string]any) obsdb.Record {
attrs := map[string]any{"weft.record": "messages", "weft.run.id": "run_1",
"weft.messages.index": index, "weft.step.index": step}
for k, v := range extra {
attrs[k] = v
}
return obsdb.Record{Time: at, EventName: "weft.messages", Body: body, Attrs: attrs}
}
request := func(index, step int64, ref int64, count int) obsdb.Record {
return obsdb.Record{Time: at, EventName: "weft.request",
Body: fmt.Sprintf(`{"step":%d,"attempt":1,"messages_ref":{"index":%d,"count":%d},"tools":{"catalog_hash":"","names":[]},"params":{},"model":{"name":"m"}}`, step, ref, count),
Attrs: map[string]any{"weft.record": "request", "weft.run.id": "run_1",
"weft.request.index": index, "weft.step.index": step, "weft.attempt.index": int64(1)}}
}
err = db.Write(ctx, obsdb.Batch{Records: []obsdb.Record{
{Time: at, EventName: "weft.event", Body: `{"type":"run_start","id":"run_1"}`,
Attrs: map[string]any{"weft.record": "event", "weft.run.id": "run_1", "weft.event.type": "run_start", "weft.event.pos": int64(0)}},
msgs(0, 0, "["+text("user", "u1")+"]", map[string]any{"weft.messages.input": true}),
request(0, 0, 0, 1),
msgs(1, 0, `[{"role":"assistant","content":[{"type":"tool_call","id":"c1","name":"lookup","args":{}}]}]`, nil),
msgs(2, 0, `[{"role":"tool","content":[{"type":"tool_result","call_id":"c1","name":"lookup","content":"t1"}]}]`, nil),
msgs(3, 1, "["+text("user", "s")+"]", map[string]any{"weft.messages.reason": "compacted",
"weft.messages.from_seq": int64(1), "weft.messages.to_seq": int64(3),
"weft.compaction.scope": "run", "weft.compaction.hash": "h1"}),
request(1, 1, 3, 2),
}})
if err != nil {
log.Fatal(err)
}
for _, step := range []int{0, 1} {
sm, err := obsdb.MessagesAsOf(ctx, db, "run_1", step)
if err != nil {
log.Fatal(err)
}
var roles []string
for _, m := range sm.Messages {
roles = append(roles, string(m.Role))
}
fmt.Println(step, roles, sm.View != nil)
}
_, err = obsdb.MessagesAsOf(ctx, db, "run_1", 5)
fmt.Println(errors.Is(err, obsdb.ErrNotFound))
}
Output: 0 [user] false 1 [user user] true true
type StepMessagesError ¶ added in v0.13.0
StepMessagesError is MessagesAsOf's and AssembleStep's refusal, with the hole that explains it (ADR 0028 §11's table): HoleGap when a record the step's request names is missing or does not fit — a growth record below its index, the growth record its plain ref names, a view that does not fit or was never written (a rewritten request with no view index), a body that is not messages — and HoleStripped when the run stored no messages at all (content capture off, or a content-off chain). errors.Is(err, ErrStepMessages) holds; a reader badges Hole and never guesses.
func (*StepMessagesError) Error ¶ added in v0.13.0
func (e *StepMessagesError) Error() string
func (*StepMessagesError) Is ¶ added in v0.13.0
func (e *StepMessagesError) Is(target error) bool
Is makes a StepMessagesError an ErrStepMessages.
type StoredRecord ¶ added in v0.10.0
type StoredRecord struct {
Index int64
Step int
Time time.Time
Body []byte
Content string
TruncatedBytes int64
SystemHash string
CatalogHash string
}
StoredRecord is one request, prompt or tools record as a backend stored it: its index, stored step (-1 when absent), time and body, and the four attributes the readers need — weft.content (the mark), weft.content.truncated_bytes, weft.system.hash and weft.catalog.hash. A backend fills it from its columns (or its attribute column) and builds the read type through RequestRecordOf, PromptRecordOf or ToolsRecordOf, so every backend parses alike.
type ToolEntry ¶ added in v0.10.0
type ToolEntry struct {
Name string `json:"name"`
Description string `json:"description"`
Schema json.RawMessage `json:"schema"`
TimeoutMS int64 `json:"timeout_ms"`
Approval bool `json:"approval"`
Replay string `json:"replay"`
MaxResultBytes int `json:"max_result_bytes"`
Sequential bool `json:"sequential"`
Source string `json:"source"`
}
ToolEntry is one tool of a catalog: the model-visible triple and the policy chips (ADR 0028 §5).
type ToolsRecord ¶ added in v0.10.0
type ToolsRecord struct {
Index int64
Time time.Time
Hash string
Tools []ToolEntry
Content Hole
TruncatedBytes int64
}
ToolsRecord is one stored tools record (ADR 0028 §5): the catalog of one distinct catalog hash, tools in name order. Content is HoleTruncated when a destination's cap dropped entries from the end (TruncatedBytes > 0), HoleDerived when the body did not parse (a malformed producer; Hash is then the weft.catalog.hash attribute). Hash is over the whole catalog.
func ToolsRecordOf ¶ added in v0.10.0
func ToolsRecordOf(r StoredRecord) ToolsRecord
ToolsRecordOf builds a ToolsRecord from a stored tools record. Content is HoleTruncated when a cap dropped entries; a body that does not parse (a malformed producer) reads HoleDerived, with no tools and the hash of the record's weft.catalog.hash attribute.
type TracePage ¶ added in v0.14.0
TracePage is one Traces result, paged as RunPage is: newest Start first (ties by TraceID, descending), NextBefore/NextBeforeID are the next TraceQuery.Before/BeforeID (nil and "" on the last page), traces sharing the last row's Start ride along up to MaxTies, and Total counts the whole match, cursor aside. A trace's Start is its earliest span, so a trace whose earlier span arrives between page reads can be listed again on a later page (never skipped); clients dedupe by TraceID.
type TraceQuery ¶ added in v0.14.0
type TraceQuery struct {
// Service keeps the traces holding at least one span whose resource
// service name is exactly this ("" any).
Service string
// PublicID keeps the traces every span of which carries
// weft.public_id = PublicID — the rule a panel token's
// /api/traces/{id} read applies, so a scoped list offers no trace
// its detail would refuse (a non-weft span or another public id's
// run in the trace excludes it).
PublicID string
Since time.Time // Start at or after; zero = no bound
Until time.Time // Start before; zero = no bound
Before time.Time // cursor on Start; zero = newest
// BeforeID, with Before, makes the cursor the pair (Start, TraceID),
// as RunQuery.BeforeID: pass TracePage.NextBeforeID. A late earlier
// span can re-list a trace on a later page (TracePage).
BeforeID string
Limit int // 0 = 50, max 500
}
TraceQuery selects traces for Traces. The zero value lists every trace, newest first, 50 at a time. A trace's time is its Start: the earliest start of the spans it holds.
type TraceRoot ¶ added in v0.14.0
type TraceRoot struct {
SpanID, Name string
}
TraceRoot is a trace's root span: its id and name.
type TraceRow ¶ added in v0.14.0
type TraceRow struct {
TraceID string
Root *TraceRoot
Service string
Start, End time.Time
SpanCount int
Error bool
RunIDs []string
}
TraceRow is one trace in a list, computed from its spans: the time range they cover, how many there are (each (trace, span) once, whatever a retried batch duplicated), whether any ended with status error, the distinct weft.run.id values they carry (sorted: a subagent child shares its parent's trace, so both are here) and the root — the earliest span with no parent span id, nil when none arrived (only child spans were stored: a hole the reader badges). Service is the root's, or the earliest span's when the root is missing.
type TranscriptBatch ¶ added in v0.10.0
type TranscriptBatch struct {
Index int64
Step int
Input bool
InputDerived bool // Input was inferred by the backend, not read from the record
Messages json.RawMessage
}
TranscriptBatch is one messages record as stored (ADR 0028 §8): readers place a batch by its Step, never by its neighbours. Step is -1 when the record carried no weft.step.index — a ClickHouse row written before migration 0004, or a producer that never stamped it; a reader that places such a batch anyway infers the step and says so (the derived badge, ADR 0028 §11). Input is the record's weft.messages.input: the input record — on a partial resume a prefix of what the run was fed, ending at the last assistant message with tool calls; the tail (the rebuilt tool message and what follows it) is the next batch, at step 0, not flagged. A backend that did not store the flag for a row (ClickHouse rows written before weft_records.Input existed) infers Input on index 0 — true unless the body is a lone assistant message, which is step 0 of a run fed no messages (the core writes the input record first, and none for an empty input) — and sets InputDerived on that batch so readers badge it HoleDerived.
func DedupBatches ¶ added in v0.10.0
func DedupBatches(batches []TranscriptBatch) []TranscriptBatch
DedupBatches runs DedupTranscript over the batches' bodies; the number of batches and their stored fields never change.
type Weft ¶
type Weft struct {
RunID, ParentRunID, ParentCallID string
SessionID, PublicID string
Turn int
Agent string
Record string // event | delta | messages | heartbeat | "" (non-weft)
EventType string
Pos int64 // event pos, messages index, delta pos, or request/prompt/tools index
Step int // -1 when absent
ToolSeq int64 // -1 when absent
Playground bool
ExperimentID, ForkedFrom string
// Reason is a messages record's weft.messages.reason: "" for
// transcript growth, "compacted" for a compaction view (ADR 0028
// §8), which is never counted or concatenated as transcript. A
// non-string reason reads as its text (never "": the record is not
// growth), as ClickHouse's stringified attribute column reads it.
Reason string
// Input is a messages record's weft.messages.input: the run's input
// record (ADR 0028 §8) — what search reads the turn's prompt from.
Input bool
}
Weft is what obsdb derives from a span or record's attributes, stored as indexed columns. Zero values mean absent.
func DeriveRecord ¶
DeriveRecord returns the weft identity a log record carries. Pos is the event position for event records, the messages index for transcript records, the delta position for delta records, and the request, prompt, tools or raw index for those kinds (ADR 0028) — the value the (run, kind, pos) key and the live lane's dedup need. A record with no weft.run.id is non-weft and lands in other_logs.
func DeriveSpan ¶
DeriveSpan returns the weft identity a span carries, from its attributes. A span with no weft.run.id is non-weft (a stock OTel application's span): every field reads absent.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package clickhouse — see doc.go for the package documentation.
|
Package clickhouse — see doc.go for the package documentation. |
|
internal
|
|
|
reqread
Package reqread holds what obsdb's two backends share to answer DB.Prompt, DB.Tools and DB.Catalogs over the records they read: the lookups by hash and the one-per-hash catalog list.
|
Package reqread holds what obsdb's two backends share to answer DB.Prompt, DB.Tools and DB.Catalogs over the records they read: the lookups by hash and the one-per-hash catalog list. |
|
Package obsdbtest is the shared conformance table for obsdb.DB backends — the executable form of S3.5's promises, the storetest pattern.
|
Package obsdbtest is the shared conformance table for obsdb.DB backends — the executable form of S3.5's promises, the storetest pattern. |
|
Package sqlite is obsdb's default backend: the observability schema in a single SQLite file on the CGO-free modernc.org/sqlite driver (thread/sqlite's choice).
|
Package sqlite is obsdb's default backend: the observability schema in a single SQLite file on the CGO-free modernc.org/sqlite driver (thread/sqlite's choice). |