Documentation
¶
Overview ¶
Package clickhouse — see doc.go for the package documentation.
Package clickhouse is obsdb's hosted backend (ADR 0024, S3.6): the observability schema as ClickHouse tables that a stock OTel Collector can also feed.
The stance: column-compatible, not DDL-verbatim ¶
otel_traces and otel_logs keep every column name and type the OTel Collector's ClickHouse exporter INSERT expects — pinned at exporter v0.162.0, the version recorded in migrations/0001_init.sql — with the weft identity materialized beside them (skip-indexed bloom filters on SessionId, PublicId, Agent and TraceId, S3.6). The exporter's README tells production users to manage their own schema (create_schema: false); ours owns the engines, the TTLs and the weft columns, and a stock collector pinned to that version can write into the same database — the collector-shape test inserts with its exact column lists. Attribute values in the collector's map columns are strings by that schema's design; the package writes the typed maps into its own JSON columns (WeftAttrs and friends, defaulted empty so collector inserts never name them) and reads them back exactly, falling back to the map columns for collector-written rows.
The weft tables ¶
weft_records is a ReplacingMergeTree(InsertTime) ORDER BY (RunId, Kind, Pos) — the transport idempotency key (I4) — filled by a materialized view from otel_logs where weft.record IN ('event', 'messages'): never 'delta' (D3: counted in weft_runs, never stored) and never 'heartbeat' (no position; it only moves last-seen). It carries its own content TTL, because a source table's TTL does not cascade through a view. Exact reads use FINAL or LIMIT 1 BY on the dedup key; weft records are immutable by construction, so dedup only ever collapses transport duplicates.
weft_runs is an AggregatingMergeTree ORDER BY RunId filled by two materialized views, from otel_logs (heartbeats included, for last-seen) and otel_traces: min for started, max for finished, last-seen, identity strings, the terminal flags and usage — only one non-empty value exists per run, so max never invents one, and a retried batch's identical bytes cannot move a max. Event and message counts are derived at read from weft_records (uniqExact over the dedup key), which a retry cannot inflate. Sessions are a GROUP BY SessionId over weft_runs.
Open, writes, reads ¶
Open(dsn, opts...) returns an obsdb.DB over the DSN's database, creating and versioning the schema in obsdb_migrations (the SQLite backend's numbering rule). Writes go through async inserts (async_insert=1, wait_for_async_insert=1), batched per Write call. Lists read through GROUP BY RunId with the same aggregates; single run pages read FINAL. Status is never stored: every read derives it through obsdb.DeriveStatus. KeepDeltas() turns delta storage on for debugging (rows into weft_deltas, nothing else feeds it); TTL(...) overrides the retention windows (content 30 days, spans and runs 90 by default) with ALTER TABLE ... MODIFY TTL.
Index ¶
- Constants
- Variables
- func Open(dsn string, opts ...Option) (obsdb.DB, error)
- type DB
- func (d *DB) Close() error
- func (d *DB) Events(ctx context.Context, runID string, after int64, limit int) (_ obsdb.EventPage, err error)
- func (d *DB) Experiment(ctx context.Context, id string) (_ obsdb.Experiment, err error)
- func (d *DB) Experiments(ctx context.Context) (_ []obsdb.Experiment, err error)
- func (d *DB) ResolvePublicID(ctx context.Context, publicID string) (_ string, err error)
- func (d *DB) Run(ctx context.Context, id string) (_ obsdb.RunDetail, err error)
- func (d *DB) RunSpans(ctx context.Context, runID string) (_ []obsdb.Span, err error)
- func (d *DB) Runs(ctx context.Context, q obsdb.RunQuery) (_ obsdb.RunPage, err error)
- func (d *DB) SaveExperiment(ctx context.Context, e obsdb.Experiment) (err error)
- func (d *DB) Session(ctx context.Context, id string) (_ obsdb.SessionDetail, err error)
- func (d *DB) Sessions(ctx context.Context, q obsdb.SessionQuery) (_ obsdb.SessionPage, err error)
- func (d *DB) Trace(ctx context.Context, traceID string) (_ []obsdb.Span, err error)
- func (d *DB) Transcript(ctx context.Context, runID string) (_ []json.RawMessage, err error)
- func (d *DB) Write(ctx context.Context, b obsdb.Batch) (err error)
- type Option
Examples ¶
Constants ¶
const ( DefaultContentTTL = 30 * 24 * time.Hour DefaultMetaTTL = 90 * 24 * time.Hour )
DefaultContentTTL and DefaultMetaTTL are S3.6's retention defaults: content-bearing rows (otel_logs, weft_records, weft_deltas) 30 days, spans and runs 90 days.
Variables ¶
var ErrNewerSchema = errors.New("clickhouse: database schema is newer than this obsdb")
ErrNewerSchema is returned by Open when the database's obsdb_migrations is ahead of this binary's highest migration: a database written by a newer weft fails loudly instead of silently ignoring tables it does not know.
Functions ¶
func Open ¶
Open connects using a clickhouse-go DSN (clickhouse://user:pass@host:9000/database?...; clickhouses:// or secure=1 for TLS), brings the schema up to date, applies TTL overrides, and returns an obsdb.DB over the DSN's database. The connection carries async_insert=1 and wait_for_async_insert=1 so batched writes return once the server has flushed them.
Example ¶
Opening the backend: a clickhouse-go DSN — the database in the path is the one the schema lands in — plus options composed at call time, here a one-week spans-and-runs window with the 30-day content default kept, and delta storage on for a debugging session. Open brings the schema up to date and returns the obsdb.DB every backend shares, so the reads are the same calls the SQLite backend takes.
This example carries no // Output: because it needs a server (the README has the one-line container recipe); go test compiles it but does not execute it.
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/weftgo/weft/obsdb"
"github.com/weftgo/weft/obsdb/clickhouse"
)
func main() {
db, err := clickhouse.Open(
"clickhouse://default:weft@127.0.0.1:9000/weft?dial_timeout=10s",
clickhouse.TTL(0, 7*24*time.Hour),
clickhouse.KeepDeltas(),
)
if err != nil {
log.Fatal(err)
}
defer func() { _ = db.Close() }()
page, err := db.Runs(context.Background(), obsdb.RunQuery{Agent: "greeter"})
if err != nil {
log.Fatal(err)
}
fmt.Println("greeter runs:", page.Total)
}
Output:
Types ¶
type DB ¶
type DB struct {
// contains filtered or unexported fields
}
DB is the ClickHouse backend. Create it with Open; the zero value is not usable. It is safe for concurrent use: the driver pools connections, writers batch per Write.
func (*DB) Experiment ¶
Experiment returns one experiment; ErrNotFound otherwise.
func (*DB) Experiments ¶
Experiments lists the saved definitions, newest update first.
func (*DB) ResolvePublicID ¶
func (*DB) SaveExperiment ¶
SaveExperiment upserts one experiment by id (an insert; FINAL reads the newest).
func (*DB) Sessions ¶
func (d *DB) Sessions(ctx context.Context, q obsdb.SessionQuery) (_ obsdb.SessionPage, err error)
Sessions is a GROUP BY SessionId over weft_runs' top-level, non-playground runs (S3.6): turns, first/last seen, summed usage, and the newest turn's derived status — argMax on (Turn, Started) picks the same row SQLite's ORDER BY ... LIMIT 1 does.
The runs collapse per RunId first (the same aggregates the Runs list uses) because unmerged AggregatingMergeTree parts would otherwise double-count usage in the outer sum; the session level's aliases are prefixed s* so no ClickHouse alias substitution can shadow the argMax arguments (aliases are query-global).
func (*DB) Trace ¶
Trace returns any trace's spans — weft and non-weft alike (the polyglot promise). An unknown trace id returns no rows and no error, like the SQLite backend.
func (*DB) Transcript ¶
func (*DB) Write ¶
Write stores one batch: spans into otel_traces and records into otel_logs — both in the collector's own column shape, so the row a stock collector would have written and the row weft writes are the same shape (the materialized views cannot tell them apart). Deltas ride otel_logs (the runs view counts them; the records view filters them) and, with KeepDeltas, are additionally inserted into weft_deltas directly — the option lives in Write, not the schema, because a view cannot be option-gated.
Idempotence (I4) is the engines' job: otel_logs tolerates duplicate rows, weft_records' ReplacingMergeTree collapses the (RunId, Kind, Pos) duplicates at merge (exact reads use FINAL), and weft_runs' min/max aggregates are unchanged by identical values, so a retried transport's duplicate is a no-op once merged and never inflates a count at read time (uniqExact over the key dedups pre-merge).
type Option ¶
type Option interface {
// contains filtered or unexported methods
}
Option configures Open.
func KeepDeltas ¶
func KeepDeltas() Option
KeepDeltas turns delta storage on for debugging (Q4, closed): deltas are counted in weft_runs' DeltaCount high-water mark but never stored by default — the weft_records view filters them out by construction. With this option Write also inserts each delta into weft_deltas (ReplacingMergeTree on (RunId, Pos)), which nothing else feeds; the table stays empty without the option.
func TTL ¶
TTL overrides the retention windows. content applies to otel_logs, weft_records and weft_deltas; meta applies to otel_traces and weft_runs. Values ≤ 0 keep the default for that class (DefaultContentTTL / DefaultMetaTTL), so TTL(0, 7*24*time.Hour) changes only the spans-and-runs window. Open applies non-default windows with ALTER TABLE ... MODIFY TTL after the migrations (a no-op on the server when the clause is unchanged). An Open with both classes at their defaults alters nothing: the tables keep whatever windows they already have — the migration's defaults on a fresh database, or an earlier Open's override — so a process that opens the database without TTL (the studio binary) never undoes the windows the writer set. Windows are whole seconds, rounded up.